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

718 lines
21 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
2026-10-17 17:20:28 +03:30
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
DataAccessPolicy,
2026-10-17 17:20:28 +03:30
OrganizationDto,
OrgUserDto,
TeamDto,
TeamMemberRole,
2026-10-17 17:20:28 +03:30
UpdatePriorArchiveCutoffRequest,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
emptySectionDataAccess,
sectionHasDataAccessLimits,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
2026-10-17 17:20:28 +03:30
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
const props = defineProps<{
users: OrgUserDto[];
canEdit: boolean;
}>();
const auth = useAuthStore();
const showPriorArchive = computed(
() =>
auth.user?.role === 'owner' &&
Boolean(auth.user.enabledModules?.includes('prior_data_archive')),
);
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
2026-10-17 17:20:28 +03:30
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref('');
const priorPick = ref('');
const priorSavingId = ref('');
2026-10-17 17:20:28 +03:30
const cutoffLocal = ref('');
const cutoffSaving = ref(false);
const cutoffLoading = ref(false);
const availablePriorUsers = computed(() => {
const q = priorSearch.value.trim().toLowerCase();
return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false;
if (!q) return true;
return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
});
});
const emit = defineEmits<{
refreshUsers: [];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const teams = ref<TeamDto[]>([]);
const newName = ref('');
const newDesc = ref('');
const editingId = ref<string | null>(null);
const editingMetaId = ref<string | null>(null);
const editName = ref('');
const editDesc = ref('');
const memberDraft = ref<Record<string, TeamMemberRole>>({});
const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId;
2026-10-17 17:20:28 +03:30
priorPick.value = '';
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${userId}/prior-archive`, { enabled });
ok.value = enabled
? 'بایگانی اطلاعات قبل برای این کاربر روشن شد.'
: 'بایگانی اطلاعات قبل برای این کاربر خاموش شد.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره ناموفق بود.')
: 'ذخیره ناموفق بود.';
} finally {
priorSavingId.value = '';
}
}
2026-10-17 17:20:28 +03:30
function cutoffPayload(value: string): UpdatePriorArchiveCutoffRequest {
const parsed = parseLocalInput(value);
if (!parsed) return { cutoffAt: null };
parsed.setHours(23, 59, 59, 999);
return { cutoffAt: parsed.toISOString() };
}
function applyCutoffFromOrg(org: Pick<OrganizationDto, 'priorArchiveCutoffAt'>) {
cutoffLocal.value = org.priorArchiveCutoffAt
? formatLocalDateInput(new Date(org.priorArchiveCutoffAt))
: '';
2026-10-17 17:20:28 +03:30
}
async function loadPriorArchiveCutoff() {
cutoffLoading.value = true;
try {
const { data } = await api.get<OrganizationDto>('/organizations/current');
applyCutoffFromOrg(data);
2026-10-17 17:20:28 +03:30
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
: 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally {
cutoffLoading.value = false;
}
}
async function onCutoffChange(value: string) {
cutoffLocal.value = value;
cutoffSaving.value = true;
error.value = '';
ok.value = '';
try {
const { data } = await api.patch<OrganizationDto>(
'/organizations/current/prior-archive-cutoff',
cutoffPayload(value),
);
applyCutoffFromOrg(data);
2026-10-17 17:20:28 +03:30
ok.value = value ? 'تاریخ بایگانی اطلاعات قبل ذخیره شد.' : 'تاریخ بایگانی اطلاعات قبل پاک شد.';
await auth.hydrate();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
: 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff();
} finally {
cutoffSaving.value = false;
}
}
function pickPriorUser(userId: string) {
if (!userId) return;
void setPriorArchive(userId, true);
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TeamDto[]>('/teams');
teams.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تیم‌ها و سطح دسترسی به پرونده‌ها برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تیم‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createTeam() {
if (!newName.value.trim()) {
error.value = 'نام تیم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/teams', {
name: newName.value.trim(),
description: newDesc.value.trim(),
});
newName.value = '';
newDesc.value = '';
ok.value = 'تیم ساخته شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ساخت تیم ناموفق بود.')
: 'ساخت تیم ناموفق بود.';
} finally {
saving.value = false;
}
}
function startEdit(team: TeamDto) {
editingId.value = team.id;
editingMetaId.value = null;
const draft: Record<string, TeamMemberRole> = {};
for (const m of team.members) {
draft[m.userId] = m.role;
}
memberDraft.value = draft;
}
function startMetaEdit(team: TeamDto) {
editingMetaId.value = team.id;
editingId.value = null;
editName.value = team.name;
editDesc.value = team.description || '';
}
async function saveMeta() {
if (!editingMetaId.value) return;
if (!editName.value.trim()) {
error.value = 'نام تیم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.patch(`/teams/${editingMetaId.value}`, {
name: editName.value.trim(),
description: editDesc.value.trim(),
});
ok.value = 'اطلاعات تیم ذخیره شد.';
editingMetaId.value = null;
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تیم ناموفق بود.')
: 'ذخیره تیم ناموفق بود.';
} finally {
saving.value = false;
}
}
function toggleMember(userId: string) {
if (memberDraft.value[userId]) {
const next = { ...memberDraft.value };
delete next[userId];
memberDraft.value = next;
} else {
memberDraft.value = { ...memberDraft.value, [userId]: 'member' };
}
}
function setRole(userId: string, role: TeamMemberRole) {
if (!memberDraft.value[userId]) return;
memberDraft.value = { ...memberDraft.value, [userId]: role };
}
async function saveMembers() {
if (!editingId.value) return;
saving.value = true;
error.value = '';
try {
await api.put(`/teams/${editingId.value}/members`, {
members: Object.entries(memberDraft.value).map(([userId, role]) => ({
userId,
role,
})),
});
ok.value = 'اعضای تیم ذخیره شد.';
editingId.value = null;
await load();
} catch {
error.value = 'ذخیره اعضا ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeTeam(id: string) {
if (!confirm('این تیم حذف شود؟')) return;
try {
await api.delete(`/teams/${id}`);
if (editingId.value === id) editingId.value = null;
await load();
} catch {
error.value = 'حذف تیم ناموفق بود.';
}
}
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
const src = policy ?? {};
const out: DataAccessPolicy = {};
for (const [key, section] of Object.entries(src)) {
if (!section) continue;
out[key as keyof DataAccessPolicy] = {
mode: section.mode,
rules: (section.rules ?? []).map((r) => ({ ...r })),
};
}
return out;
}
function accessSummary(u: OrgUserDto): string {
const override = u.dataAccessOverride ?? {};
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
const effective = u.effectiveDataAccess ?? {};
const limited = DATA_ACCESS_SECTIONS.filter((s) =>
sectionHasDataAccessLimits(effective[s] ?? emptySectionDataAccess()),
);
if (!hasOverride && !limited.length) return 'مثل نقش · بدون محدودیت';
if (!hasOverride) {
return `مثل نقش · ${toFaDigits(limited.length)} بخش محدود`;
}
const labels = limited.map((s) => DATA_ACCESS_SECTION_LABELS[s]).slice(0, 2);
return `سفارشی · ${labels.join('، ') || 'بدون محدودیت'}${limited.length > 2 ? '…' : ''}`;
}
function openAccessEditor(u: OrgUserDto) {
if (expandedAccess.value === u.id) {
expandedAccess.value = null;
return;
}
const override = u.dataAccessOverride ?? {};
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
accessMode.value = hasOverride ? 'custom' : 'inherit';
draftAccess.value = hasOverride
? clonePolicy(override)
: clonePolicy(u.effectiveDataAccess);
expandedAccess.value = u.id;
}
async function saveUserAccess(userId: string) {
accessSaving.value = true;
error.value = '';
ok.value = '';
try {
const payload =
accessMode.value === 'inherit'
? { dataAccessOverride: null }
: { dataAccessOverride: draftAccess.value };
await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null;
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره محدوده داده ناموفق بود.',
)
: 'ذخیره محدوده داده ناموفق بود.';
} finally {
accessSaving.value = false;
}
}
onMounted(() => {
void load();
2026-10-17 17:20:28 +03:30
if (showPriorArchive.value) void loadPriorArchiveCutoff();
});
</script>
<template>
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<section v-if="canEdit" class="card">
<h3>تیم جدید</h3>
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="newDesc" placeholder="اختیاری" />
</UiField>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
</div>
</section>
<section class="card">
<h3>تیم‌ها</h3>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
<p class="muted">
{{ t.description || '—' }}
· {{ toFaDigits(t.memberCount) }} عضو
</p>
</div>
<div v-if="canEdit" class="actions">
<UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
</div>
</header>
<p class="members">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<div v-if="editingMetaId === t.id" class="editor">
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="editName" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="editDesc" />
</UiField>
</div>
<div class="actions">
<UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
<UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
</div>
</div>
<div v-if="editingId === t.id" class="editor">
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
<input
type="checkbox"
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
>
<option value="member">عضو</option>
<option value="manager">سرپرست</option>
</select>
</label>
<div class="actions">
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
</div>
</div>
</article>
</div>
</section>
<section v-if="showPriorArchive" class="card">
2026-10-17 17:20:28 +03:30
<header class="prior-head">
<h3>بایگانی اطلاعات قبل</h3>
<JalaliDateInput
:model-value="cutoffLocal"
mode="date"
placeholder="تاریخ برش"
:disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange"
/>
</header>
<p class="hint">
فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
</p>
2026-10-17 17:20:28 +03:30
<div class="prior-picker">
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
<select
v-model="priorPick"
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
>
<option value="">
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }}
</option>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div v-if="selectedPriorUsers.length" class="prior-chips">
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
{{ u.fullName }}
<button
type="button"
:disabled="priorSavingId === u.id"
:aria-label="`حذف ${u.fullName}`"
@click="setPriorArchive(u.id, false)"
>
×
</button>
</span>
</div>
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
</section>
<section class="card">
<h3>محدوده داده</h3>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
<small class="vis-badge">{{ accessSummary(u) }}</small>
</span>
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
</UiButton>
</div>
<div v-if="expandedAccess === u.id" class="vis-scopes">
<label class="chip-row">
<input v-model="accessMode" type="radio" value="inherit" />
مثل نقش (بدون override)
</label>
<label class="chip-row">
<input v-model="accessMode" type="radio" value="custom" />
سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
compact
/>
<div class="actions">
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
2026-10-17 17:20:28 +03:30
.prior-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.prior-head h3 {
margin: 0;
}
.prior-head :deep(.trigger) {
min-width: 12rem;
}
.prior-picker {
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
gap: 0.6rem;
margin-top: 0.75rem;
}
.prior-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.75rem;
}
.prior-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #ccfbf1;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
font-weight: 600;
}
.prior-chip button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.prior-chip button:disabled {
opacity: 0.45;
cursor: wait;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-user {
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.45rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
}
.vis-badge {
margin-inline-start: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.72rem;
}
.vis-scopes {
display: grid;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
2026-10-17 17:20:28 +03:30
.vis-row,
.prior-picker {
grid-template-columns: 1fr;
}
}
</style>