Files
CRM_Montzar/apps/web/src/components/OrgSwitcher.vue
T

369 lines
8.1 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
defineProps<{
compact?: boolean;
}>();
const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false);
const createOpen = ref(false);
const loading = ref(false);
const creating = ref(false);
const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0;
const currentName = computed(
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
);
const canSwitch = computed(() => orgs.value.length > 1);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function loadOrgs() {
try {
orgs.value = await auth.listOrganizations();
} catch {
orgs.value = [];
}
}
async function toggle() {
if (!canSwitch.value) return;
error.value = '';
if (open.value) {
open.value = false;
return;
}
ignoreDocClickUntil = Date.now() + 400;
open.value = true;
await nextTick();
placeMenu();
await loadOrgs();
}
function placeMenu() {
const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect();
menuStyle.value = {
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
right: `${Math.round(window.innerWidth - box.right)}px`,
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
};
}
async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) {
open.value = false;
return;
}
loading.value = true;
error.value = '';
try {
await auth.switchOrganization(org.id);
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'تغییر محیط ناموفق بود');
loading.value = false;
}
}
function openCreate() {
open.value = false;
newName.value = '';
newField.value = 'personal';
error.value = '';
createOpen.value = true;
}
async function submitCreate() {
const name = newName.value.trim();
if (name.length < 2) {
error.value = 'نام محیط باید حداقل دو حرف باشد.';
return;
}
creating.value = true;
error.value = '';
try {
await auth.createOrganization({
name,
businessField: newField.value,
});
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
creating.value = false;
}
}
function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false;
}
onMounted(() => {
void loadOrgs();
document.addEventListener('click', onDocClick);
});
onUnmounted(() => {
document.removeEventListener('click', onDocClick);
});
</script>
<template>
<div ref="rootEl" class="org-switch">
<button
class="org-btn"
:class="{ compact, static: !canSwitch }"
type="button"
:title="currentName"
:aria-disabled="!canSwitch"
@click.stop="toggle"
>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
</button>
<Teleport to="body">
<div
v-if="open && canSwitch"
ref="menuEl"
class="menu"
:style="menuStyle"
role="listbox"
>
<p class="hint">ورود به محیط</p>
<button
v-for="org in orgs"
:key="org.id"
type="button"
class="org-item"
:class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading"
@click="choose(org)"
>
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</button>
<button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div>
</Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div>
</template>
<style scoped>
.org-switch {
position: relative;
min-width: 0;
width: 100%;
}
.org-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
border: 0;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
text-align: right;
padding: 0 0.45rem 0.15rem;
overflow: hidden;
}
.org-btn span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-btn:hover {
color: #0f172a;
}
.org-btn.static {
cursor: default;
}
.org-btn.static:hover {
color: #94a3b8;
}
.org-btn.compact {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: var(--radius);
}
.org-btn.compact span {
display: none;
}
.org-btn.compact:hover {
background: #f8fafc;
}
.org-btn.compact.static:hover {
background: transparent;
}
.menu {
position: fixed;
z-index: 90;
padding: 0.45rem;
border-radius: 12px;
background: #fff;
border: 1px solid var(--line);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
display: grid;
gap: 0.2rem;
}
.hint {
margin: 0;
padding: 0.25rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
}
.org-item,
.create {
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: transparent;
border-radius: 9px;
padding: 0.45rem 0.5rem;
cursor: pointer;
}
.org-item strong {
font-size: 0.82rem;
}
.org-item span {
font-size: 0.72rem;
color: var(--muted);
}
.org-item:hover,
.create:hover {
background: color-mix(in srgb, var(--accent-soft) 70%, white);
}
.org-item.on {
background: var(--accent-soft);
}
.create {
margin-top: 0.15rem;
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
}
.err {
margin: 0.2rem 0 0;
color: #b91c1c;
font-size: 0.75rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style>