merge: bring latest origin/main into dev (keep local UI)

Resolved UI conflicts in favor of local Ant Design screens; kept non-UI changes from main.
This commit is contained in:
2026-11-03 16:55:42 +03:30
201 changed files with 19202 additions and 1607 deletions
@@ -0,0 +1,488 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import type { CustomerGroupDto, PersonImportSourceStatDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { groupSwatch } from '@/lib/customer-groups';
import { toFaDigits } from '@/lib/jalali';
import { UiButton } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
export type ContactsFilterValue = {
groups: string[];
sources: string[];
moods: PersonMood[];
disc: string;
};
const props = defineProps<{
open: boolean;
tab: 'people' | 'companies';
value: ContactsFilterValue;
groups: CustomerGroupDto[];
sourceStats: PersonImportSourceStatDto[];
canBehavior: boolean;
/** اکشن‌شیت پایین صفحه (موبایل) به‌جای پاپ‌اور */
sheet?: boolean;
/** اگر داده شود بخش مرتب‌سازی داخل همین پنل نمایش داده می‌شود */
sort?: string;
sortOptions?: Array<{ value: string; label: string }>;
}>();
const emit = defineEmits<{
apply: [value: ContactsFilterValue, sort?: string];
close: [];
}>();
const draft = reactive<ContactsFilterValue>({ groups: [], sources: [], moods: [], disc: '' });
const draftSort = ref('');
const groupQuery = ref('');
const GROUP_SEARCH_MIN = 8;
const DISC_TYPES = ['D', 'I', 'S', 'C'];
const hasSort = computed(() => Boolean(props.sortOptions?.length));
const defaultSort = computed(() => props.sortOptions?.[0]?.value ?? '');
watch(
() => props.open,
(open) => {
if (!open) return;
draft.groups = [...props.value.groups];
draft.sources = [...props.value.sources];
draft.moods = [...props.value.moods];
draft.disc = props.value.disc;
draftSort.value = props.sort ?? defaultSort.value;
groupQuery.value = '';
},
{ immediate: true },
);
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && props.open && props.sheet) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const isPeople = computed(() => props.tab === 'people');
function groupCount(g: CustomerGroupDto) {
return isPeople.value ? g.personCount : g.companyCount;
}
const filteredGroups = computed(() => {
const q = groupQuery.value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک');
const list = props.groups.filter((g) => groupCount(g) > 0 || draft.groups.includes(g.id));
return q ? list.filter((g) => g.name.includes(q)) : list;
});
const draftCount = computed(
() =>
draft.groups.length +
(isPeople.value ? draft.sources.length + draft.moods.length + (draft.disc ? 1 : 0) : 0) +
(hasSort.value && draftSort.value !== defaultSort.value ? 1 : 0),
);
function toggle<T extends string>(list: T[], value: T) {
const idx = list.indexOf(value);
if (idx >= 0) list.splice(idx, 1);
else list.push(value);
}
function clearAll() {
draft.groups = [];
draft.sources = [];
draft.moods = [];
draft.disc = '';
if (hasSort.value) draftSort.value = defaultSort.value;
}
function apply() {
emit(
'apply',
{
groups: [...draft.groups],
sources: [...draft.sources],
moods: [...draft.moods],
disc: draft.disc,
},
hasSort.value ? draftSort.value : undefined,
);
}
</script>
<template>
<Teleport to="body" :disabled="!sheet">
<div v-if="open && sheet" class="sheet-dim" @click="emit('close')" />
<div
v-if="open"
class="panel"
:class="{ sheet }"
role="dialog"
:aria-modal="sheet ? 'true' : undefined"
aria-label="فیلترها"
@click.stop
>
<span v-if="sheet" class="grip" aria-hidden="true" />
<header class="panel-head">
<strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header>
<div class="panel-body">
<section v-if="hasSort" class="sec">
<h4>مرتب‌سازی</h4>
<div class="pills">
<button
v-for="o in sortOptions"
:key="o.value"
type="button"
class="pill"
:class="{ on: draftSort === o.value }"
@click="draftSort = o.value"
>
{{ o.label }}
</button>
</div>
</section>
<section class="sec">
<h4>
گروه
<span v-if="draft.groups.length" class="sel">{{ toFaDigits(draft.groups.length) }} انتخاب</span>
</h4>
<label v-if="groups.length > GROUP_SEARCH_MIN" class="find">
<UiIcon name="search" :size="14" />
<input v-model="groupQuery" type="search" placeholder="جستجوی گروه…" />
</label>
<ul v-if="filteredGroups.length" class="checks">
<li v-for="g in filteredGroups" :key="g.id">
<label class="check">
<input
type="checkbox"
:checked="draft.groups.includes(g.id)"
@change="toggle(draft.groups, g.id)"
/>
<i class="dot" :style="{ background: groupSwatch(g.color).fg }" />
<span class="lbl">{{ g.name }}</span>
<em>{{ toFaDigits(groupCount(g)) }}</em>
</label>
</li>
</ul>
<p v-else class="empty">{{ groupQuery ? 'گروهی با این نام نیست.' : 'گروهی ثبت نشده است.' }}</p>
</section>
<template v-if="isPeople">
<section v-if="sourceStats.length" class="sec">
<h4>
منبع
<span v-if="draft.sources.length" class="sel">{{ toFaDigits(draft.sources.length) }} انتخاب</span>
</h4>
<ul class="checks">
<li v-for="row in sourceStats" :key="row.source">
<label class="check">
<input
type="checkbox"
:checked="draft.sources.includes(row.source)"
@change="toggle(draft.sources, row.source)"
/>
<span class="lbl">{{ row.label }}</span>
<em>{{ toFaDigits(row.count) }}</em>
</label>
</li>
</ul>
</section>
<section class="sec">
<h4>وضعیت مشتری</h4>
<div class="pills">
<button
v-for="m in PERSON_MOODS"
:key="m"
type="button"
class="pill"
:class="{ on: draft.moods.includes(m) }"
@click="toggle(draft.moods, m)"
>
{{ PERSON_MOOD_LABELS[m] }}
</button>
</div>
</section>
<section v-if="canBehavior" class="sec">
<h4>تیپ DISC</h4>
<div class="pills">
<button
v-for="t in DISC_TYPES"
:key="t"
type="button"
class="pill"
:class="{ on: draft.disc === t }"
@click="draft.disc = draft.disc === t ? '' : t"
>
{{ t }}
</button>
</div>
</section>
</template>
</div>
<footer class="panel-foot">
<UiButton variant="ghost" size="sm" @click="emit('close')">انصراف</UiButton>
<UiButton variant="primary" size="sm" @click="apply">
اعمال<template v-if="draftCount"> ({{ toFaDigits(draftCount) }})</template>
</UiButton>
</footer>
</div>
</Teleport>
</template>
<style scoped>
.panel {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 6px);
z-index: 20;
width: min(22rem, calc(100vw - 1.5rem));
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
max-height: min(72vh, 36rem);
}
.panel-head,
.panel-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.7rem 0.9rem;
}
.panel-head {
border-bottom: 1px solid var(--line);
}
.panel-foot {
border-top: 1px solid var(--line);
justify-content: flex-end;
}
.link {
border: 0;
background: none;
color: var(--accent, #0f766e);
font: inherit;
font-size: var(--fs-sm);
cursor: pointer;
padding: 0;
}
.link:disabled {
color: #cbd5e1;
cursor: default;
}
.panel-body {
overflow-y: auto;
padding: 0.35rem 0.9rem 0.6rem;
}
.sec {
padding: 0.55rem 0;
}
.sec + .sec {
border-top: 1px dashed var(--line);
}
.sec h4 {
margin: 0 0 0.45rem;
font-size: var(--fs-sm);
color: var(--ink);
display: flex;
align-items: center;
gap: 0.4rem;
}
.sel {
font-weight: 500;
font-size: 0.72rem;
color: #0f766e;
background: #ccfbf1;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.find {
display: flex;
align-items: center;
gap: 0.35rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0 0.55rem;
height: 2rem;
margin-bottom: 0.4rem;
color: var(--muted);
}
.find input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
}
.checks {
list-style: none;
margin: 0;
padding: 0;
max-height: 13rem;
overflow-y: auto;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.32rem 0.35rem;
border-radius: 6px;
cursor: pointer;
font-size: var(--fs-sm);
}
.check:hover {
background: var(--surface-subtle, #f8fafc);
}
.check input {
accent-color: #14b8a6;
margin: 0;
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.lbl {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check em {
font-style: normal;
font-size: 0.75rem;
color: #94a3b8;
}
.empty {
margin: 0;
font-size: 0.8rem;
color: #94a3b8;
}
.pills {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.pill {
border: 1px solid var(--line);
background: #fff;
border-radius: 999px;
padding: 0.25rem 0.7rem;
font: inherit;
font-size: 0.8rem;
color: var(--ink-secondary);
cursor: pointer;
}
.pill.on {
border-color: #14b8a6;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
/* ===== اکشن‌شیت موبایل ===== */
.sheet-dim {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 23, 42, 0.4);
animation: dim-in 0.18s ease-out;
}
.panel.sheet {
position: fixed;
inset: auto 0 0 0;
top: auto;
z-index: 1001;
width: 100%;
max-height: 85vh;
border: 0;
border-radius: 16px 16px 0 0;
box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.18);
padding-bottom: env(safe-area-inset-bottom, 0);
animation: sheet-up 0.22s ease-out;
}
.grip {
display: block;
width: 2.5rem;
height: 4px;
margin: 0.5rem auto 0;
border-radius: 999px;
background: #cbd5e1;
}
.panel.sheet .panel-head {
padding-top: 0.5rem;
}
.panel.sheet .checks {
max-height: none;
}
.panel.sheet .check {
padding-block: 0.5rem;
}
.panel.sheet .pill {
padding: 0.4rem 0.85rem;
}
.panel.sheet .panel-foot > * {
flex: 1 1 0;
justify-content: center;
}
@keyframes sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes dim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { CustomerGroupSummaryDto } from '@mento/shared';
import { groupSwatch } from '@/lib/customer-groups';
defineProps<{
groups: CustomerGroupSummaryDto[] | undefined;
/** متن وقتی گروهی نیست */
empty?: string;
}>();
</script>
<template>
<span v-if="groups?.length" class="group-chips">
<span
v-for="g in groups"
:key="g.id"
class="group-chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
:title="g.source === 'tahesab' ? `${g.name} (از ته‌حساب)` : g.name"
>
{{ g.name }}
</span>
</span>
<span v-else-if="empty" class="group-empty">{{ empty }}</span>
</template>
<style scoped>
.group-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
vertical-align: middle;
}
.group-chip {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.group-empty {
color: #94a3b8;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
const model = defineModel<string[]>({ default: () => [] });
const { groups, loaded, load } = useCustomerGroups();
function toggle(id: string) {
model.value = model.value.includes(id)
? model.value.filter((x) => x !== id)
: [...model.value, id];
}
onMounted(() => {
void load().catch(() => undefined);
});
</script>
<template>
<div class="picker">
<button
v-for="g in groups"
:key="g.id"
type="button"
class="opt"
:class="{ on: model.includes(g.id) }"
:style="
model.includes(g.id)
? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg, borderColor: groupSwatch(g.color).fg }
: undefined
"
@click="toggle(g.id)"
>
{{ g.name }}
</button>
<span v-if="loaded && !groups.length" class="hint">
هنوز گروهی تعریف نشده؛ از صفحهٔ مخاطبین «مدیریت گروه‌ها» را بزنید.
</span>
</div>
</template>
<style scoped>
.picker {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.opt {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 999px;
padding: 0.25rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.opt.on {
font-weight: 600;
}
.hint {
color: #94a3b8;
font-size: 0.82rem;
}
</style>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch,
useCustomerGroups,
} from '@/lib/customer-groups';
import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const { groups, load } = useCustomerGroups();
const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal');
const editingId = ref<string | null>(null);
const editName = ref('');
const busy = ref(false);
const error = ref('');
function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (msg) return msg;
}
return fallback;
}
async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true;
error.value = '';
try {
await task();
await load(true);
emit('changed');
return true;
} catch (e) {
error.value = errorText(e, fallback);
return false;
} finally {
busy.value = false;
}
}
async function create() {
const name = newName.value.trim();
if (!name) return;
const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }),
'ساخت گروه ناموفق بود.',
);
if (ok) newName.value = '';
}
function startEdit(g: CustomerGroupDto) {
editingId.value = g.id;
editName.value = g.name;
}
async function saveName(g: CustomerGroupDto) {
const name = editName.value.trim();
if (!name || name === g.name) {
editingId.value = null;
return;
}
const ok = await run(
() => api.patch(`/customer-groups/${g.id}`, { name }),
'تغییر نام ناموفق بود.',
);
if (ok) editingId.value = null;
}
async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
if (g.color === color) return;
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
}
async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
if (!confirm(`گروه «${g.name}» حذف شود؟${note}`)) return;
await run(() => api.delete(`/customer-groups/${g.id}`), 'حذف گروه ناموفق بود.');
}
watch(
() => props.open,
(open) => {
if (!open) return;
error.value = '';
editingId.value = null;
void load(true).catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
},
{ immediate: true },
);
</script>
<template>
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager">
<p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب
«ته‌حساب» مشخص شده‌اند.
</p>
<form class="new-row" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list">
<li v-for="g in groups" :key="g.id" class="row">
<div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
<UiButton type="submit" variant="primary" size="sm" :disabled="busy">ذخیره</UiButton>
<UiButton type="button" variant="ghost" size="sm" @click="editingId = null">انصراف</UiButton>
</form>
<template v-else>
<span
class="chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
>
{{ g.name }}
</span>
<span v-if="g.fromTahesab" class="tag">ته‌حساب</span>
<span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span>
</template>
</div>
<div v-if="editingId !== g.id" class="row-actions">
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch small"
:class="{ on: g.color === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
:disabled="busy"
@click="setColor(g, c)"
/>
</div>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="startEdit(g)">تغییر نام</UiButton>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="remove(g)">
<UiIcon name="trash" :size="14" />
</UiButton>
</div>
</li>
</ul>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.85rem;
min-width: min(34rem, 100%);
}
.lead,
.muted {
margin: 0;
color: #64748b;
font-size: 0.85rem;
}
.error {
margin: 0;
color: #dc2626;
font-size: 0.85rem;
}
.new-row,
.edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.new-row > :first-child,
.edit > :first-child {
flex: 1 1 12rem;
}
.swatches {
display: inline-flex;
gap: 0.3rem;
}
.swatch {
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
cursor: pointer;
padding: 0;
}
.swatch.small {
width: 1rem;
height: 1rem;
}
.swatch.on {
box-shadow: 0 0 0 2px #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.name-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
min-width: 0;
flex: 1 1 14rem;
}
.chip {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-weight: 600;
font-size: 0.85rem;
}
.tag {
font-size: 0.7rem;
color: #0369a1;
background: #e0f2fe;
border-radius: 6px;
padding: 0.05rem 0.4rem;
}
.count {
color: #94a3b8;
font-size: 0.78rem;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
</style>
@@ -0,0 +1,559 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
import type {
DataAccessOwnerKind,
DataAccessRule,
DataAccessSection,
PersonMood,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
PERSON_IMPORT_SOURCES,
PERSON_MOOD_LABELS,
PERSON_MOODS,
sectionHasDataAccessLimits,
} from '@mento/shared';
export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string };
const props = withDefaults(
defineProps<{
modelValue: SectionDataAccess;
section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[];
pipelines?: IdOption[];
boards?: IdOption[];
activityTypes?: IdOption[];
/** منبع‌هایی که در مشتریان همین محیط رکورد دارند */
importSources?: Choice[];
importSourcesReady?: boolean;
importSourcesFailed?: boolean;
disabled?: boolean;
compact?: boolean;
}>(),
{
section: 'customers',
groups: () => [],
stages: () => [],
pipelines: () => [],
boards: () => [],
activityTypes: () => [],
importSources: () => [],
importSourcesReady: false,
importSourcesFailed: false,
},
);
const emit = defineEmits<{
'update:modelValue': [SectionDataAccess];
}>();
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets');
const showStages = computed(
() =>
props.section === 'leads' ||
props.section === 'sales' ||
props.section === 'activities' ||
props.section === 'projects',
);
const showPipelines = computed(() => props.section === 'sales');
const showBoards = computed(
() => props.section === 'activities' || props.section === 'projects',
);
const showTypes = computed(
() => props.section === 'activities' || props.section === 'routines',
);
const ownerSelfLabel = computed(() => {
if (
props.section === 'leads' ||
props.section === 'activities' ||
props.section === 'routines' ||
props.section === 'tickets'
) {
return 'مسئول خودش';
}
if (props.section === 'notes') return 'نویسنده خودش';
return 'پرونده‌های خود';
});
const noteTypeOptions = [
{ value: 'general', label: 'عمومی' },
{ value: 'call_result', label: 'نتیجه تماس' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'important', label: 'مهم' },
{ value: 'warning', label: 'هشدار' },
{ value: 'next_action', label: 'اقدام بعدی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'personal', label: 'شخصی' },
];
const ticketStatusOptions = [
{ value: 'open', label: 'باز' },
{ value: 'in_progress', label: 'در حال انجام' },
{ value: 'waiting', label: 'در انتظار' },
{ value: 'resolved', label: 'حل‌شده' },
{ value: 'closed', label: 'بسته' },
];
const typesLegend = computed(() =>
props.section === 'routines' ? 'دسته روتین' : 'نوع فعالیت',
);
const boardsLegend = computed(() =>
props.section === 'projects' ? 'بورد پروژه' : 'بورد فعالیت',
);
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! };
const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>();
const moods = new Set<PersonMood>();
const stageIds = new Set<string>();
const pipelineIds = new Set<string>();
const boardIds = new Set<string>();
const typeIds = new Set<string>();
for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id);
for (const id of r.pipelineIds ?? []) pipelineIds.add(id);
for (const id of r.boardIds ?? []) boardIds.add(id);
for (const id of r.typeIds ?? []) typeIds.add(id);
}
const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds];
if (pipelineIds.size) out.pipelineIds = [...pipelineIds];
if (boardIds.size) out.boardIds = [...boardIds];
if (typeIds.size) out.typeIds = [...typeIds];
return out;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean(
rule.owners?.length ||
rule.groupIds?.length ||
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length,
);
}
const hadMultipleRules = computed(
() => (props.modelValue?.rules?.length ?? 0) > 1,
);
const mode = computed({
get: () => (props.modelValue?.mode === 'rules' ? 'rules' : 'all'),
set: (next: 'all' | 'rules') => {
if (next === 'all') {
emit('update:modelValue', { mode: 'all', rules: [] });
return;
}
const collapsed = collapseRules(props.modelValue?.rules ?? []);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
});
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
if (next.pipelineIds?.length) cleaned.pipelineIds = [...next.pipelineIds];
if (next.boardIds?.length) cleaned.boardIds = [...next.boardIds];
if (next.typeIds?.length) cleaned.typeIds = [...next.typeIds];
emit('update:modelValue', { mode: 'rules', rules: [cleaned] });
}
function patchFilter(patch: Partial<DataAccessRule>) {
emitFilter({ ...filter.value, ...patch });
}
function toggleOwner(kind: DataAccessOwnerKind) {
const current = new Set(filter.value.owners ?? []);
if (current.has(kind)) current.delete(kind);
else current.add(kind);
const owners = [...current] as DataAccessOwnerKind[];
patchFilter({ owners: owners.length ? owners : undefined });
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
| 'pipelineIds'
| 'boardIds'
| 'typeIds';
type PickerMode = 'multiple' | 'tags';
type Picker = {
key: ListKey;
legend: string;
mode: PickerMode;
placeholder: string;
notFound: string;
hint?: string;
options: Choice[];
loading?: boolean;
};
function namedOptions(items: IdOption[]): Choice[] {
return items.map((item) => ({ value: item.id, label: item.name }));
}
function withSelected(options: Choice[], selected: readonly string[] | undefined): Choice[] {
const known = new Set(options.map((option) => option.value));
const extra = (selected ?? [])
.filter((id) => id && !known.has(id))
.map((id) => ({ value: id, label: id }));
return extra.length ? [...options, ...extra] : options;
}
function listValue(key: ListKey): string[] {
return ((filter.value[key] as string[] | undefined) ?? []).map(String);
}
const catalogSources = new Set<string>(PERSON_IMPORT_SOURCES);
/** کلیدهای ثابت کاتالوگ که در این محیط رکورد ندارند را نشان نده؛ متن سفارشی بماند. */
function sourcesInThisEnvironment(selected: readonly string[] | undefined): string[] {
const list = selected ?? [];
if (!props.importSourcesReady) {
return list.filter((source) => !catalogSources.has(source));
}
const present = new Set(props.importSources.map((option) => option.value));
return list.filter((source) => present.has(source) || !catalogSources.has(source));
}
const visibleImportSources = computed(() => sourcesInThisEnvironment(filter.value.sources));
function setList(key: ListKey, value: unknown) {
const raw = Array.isArray(value) ? value : value == null || value === '' ? [] : [value];
let list = [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
if (key === 'sources' && showImportSources.value && props.importSourcesReady) {
list = sourcesInThisEnvironment(list);
}
patchFilter({ [key]: list.length ? list : undefined });
}
function pickerValue(picker: Picker): string[] {
if (picker.key === 'sources' && showImportSources.value && props.importSourcesReady) {
return visibleImportSources.value;
}
return listValue(picker.key);
}
watch(visibleImportSources, (next) => {
if (!showImportSources.value || !props.importSourcesReady) return;
const current = filter.value.sources ?? [];
if (current.length === next.length && current.every((source, index) => source === next[index])) {
return;
}
patchFilter({ sources: next.length ? next : undefined });
});
const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
value: mood,
label: PERSON_MOOD_LABELS[mood],
}));
const pickers = computed((): Picker[] => {
const rule = filter.value;
const items: Picker[] = [];
if (showGroups.value) {
items.push({
key: 'groupIds',
legend: 'گروه‌ها',
mode: 'multiple',
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
options: withSelected(namedOptions(props.groups), rule.groupIds),
});
}
if (showImportSources.value) {
const ready = props.importSourcesReady;
const failed = props.importSourcesFailed;
items.push({
key: 'sources',
legend: 'منبع',
mode: 'tags',
placeholder: failed
? 'فهرست منبع‌ها بارگذاری نشد'
: props.importSources.length
? 'جستجو، انتخاب یا نوشتن منبع جدید…'
: 'منبعی در این محیط نیست',
notFound: 'برای منبع جدید Enter بزنید',
hint: failed
? 'فهرست منبع‌های این محیط خوانده نشد؛ انتخاب ذخیره‌شده دست نخورده است.'
: 'فقط منبع‌هایی که در مشتریان این محیط هستند. نام تازه را بنویسید و Enter بزنید.',
loading: !ready && !failed,
options: withSelected(
ready ? props.importSources : [],
ready ? visibleImportSources.value : (rule.sources ?? []),
),
});
}
if (showLeadSources.value) {
items.push({
key: 'sources',
legend: 'منبع لید',
mode: 'tags',
placeholder: 'مثلاً اینستاگرام، سایت…',
notFound: 'نام را بنویسید و Enter بزنید',
hint: 'هر منبع را بنویسید و با Enter اضافه کنید.',
options: withSelected([], rule.sources),
});
}
if (showNoteTypes.value) {
items.push({
key: 'sources',
legend: 'نوع یادداشت',
mode: 'multiple',
placeholder: 'جستجو و انتخاب نوع…',
notFound: 'موردی پیدا نشد',
options: withSelected(noteTypeOptions, rule.sources),
});
}
if (showTicketStatuses.value) {
items.push({
key: 'sources',
legend: 'وضعیت تیکت',
mode: 'multiple',
placeholder: 'جستجو و انتخاب وضعیت…',
notFound: 'موردی پیدا نشد',
options: withSelected(ticketStatusOptions, rule.sources),
});
}
if (showMoods.value) {
items.push({
key: 'moods',
legend: 'حال مشتری',
mode: 'multiple',
placeholder: 'جستجو و انتخاب حال مشتری…',
notFound: 'موردی پیدا نشد',
options: withSelected(moodOptions, rule.moods),
});
}
if (showPipelines.value) {
items.push({
key: 'pipelineIds',
legend: 'پایپ‌لاین',
mode: 'multiple',
placeholder: props.pipelines.length ? 'جستجو و انتخاب پایپ‌لاین…' : 'پایپ‌لاینی نیست',
notFound: props.pipelines.length ? 'موردی پیدا نشد' : 'پایپ‌لاینی نیست',
options: withSelected(namedOptions(props.pipelines), rule.pipelineIds),
});
}
if (showStages.value) {
items.push({
key: 'stageIds',
legend: 'مرحله',
mode: 'multiple',
placeholder: props.stages.length ? 'جستجو و انتخاب مرحله…' : 'مرحله‌ای نیست',
notFound: props.stages.length ? 'موردی پیدا نشد' : 'مرحله‌ای نیست',
options: withSelected(namedOptions(props.stages), rule.stageIds),
});
}
if (showBoards.value) {
items.push({
key: 'boardIds',
legend: boardsLegend.value,
mode: 'multiple',
placeholder: props.boards.length ? 'جستجو و انتخاب…' : 'بوردی نیست',
notFound: props.boards.length ? 'موردی پیدا نشد' : 'بوردی نیست',
options: withSelected(namedOptions(props.boards), rule.boardIds),
});
}
if (showTypes.value) {
items.push({
key: 'typeIds',
legend: typesLegend.value,
mode: 'multiple',
placeholder: props.activityTypes.length ? 'جستجو و انتخاب…' : 'موردی تعریف نشده',
notFound: props.activityTypes.length ? 'موردی پیدا نشد' : 'موردی تعریف نشده',
options: withSelected(namedOptions(props.activityTypes), rule.typeIds),
});
}
return items;
});
const hasLimits = computed(() => sectionHasDataAccessLimits(props.modelValue));
const filterSummary = computed(() => {
const rule = filter.value;
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
if (rule.stageIds?.length) parts.push(`${rule.stageIds.length} مرحله`);
if (rule.boardIds?.length) parts.push(`${rule.boardIds.length} بورد`);
if (rule.typeIds?.length) parts.push(`${rule.typeIds.length} نوع/دسته`);
return parts.length ? parts.join(' · ') : 'هنوز فیلتری انتخاب نشده';
});
</script>
<template>
<div class="dae" :class="{ compact }">
<div class="dae-mode">
<label>
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
همهٔ {{ sectionLabel }}
</label>
<label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
<fieldset :disabled="disabled">
<legend>مالک / مسئول</legend>
<p class="muted">خالی = بدون محدودیت مالک</p>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('self')"
@change="toggleOwner('self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')"
@change="toggleOwner('team')"
/>
تیم‌های عضو
</label>
</fieldset>
<label v-for="picker in pickers" :key="picker.legend" class="pick">
<span>{{ picker.legend }}</span>
<p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
<a-select
v-else
:value="pickerValue(picker)"
:mode="picker.mode"
allow-clear
show-search
option-filter-prop="label"
:options="picker.options"
:disabled="disabled"
:placeholder="picker.placeholder"
:not-found-content="picker.notFound"
style="width: 100%"
@update:value="setList(picker.key, $event)"
/>
<p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
</label>
</template>
<p v-else-if="!hasLimits" class="hint">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</p>
</div>
</template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.pick {
display: grid;
gap: 0.35rem;
}
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;
}
</style>
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import type {
DataAccessPolicy,
DataAccessSection,
PersonImportSourceStatDto,
RolePermissionKey,
SectionDataAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
}>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */
const SECTION_PERM: Partial<Record<DataAccessSection, RolePermissionKey>> = {
customers: 'customers',
leads: 'leads',
sales: 'sales',
activities: 'activities',
notes: 'notes',
};
const visibleSections = computed(() => {
const perms = props.enabledPermissions;
if (!perms?.length) return DATA_ACCESS_SECTIONS;
return DATA_ACCESS_SECTIONS.filter((s) => {
const key = SECTION_PERM[s];
if (!key) return true;
return perms.includes(key);
});
});
const active = ref<DataAccessSection>('customers');
watch(
visibleSections,
(list) => {
if (!list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
/** بدون parse سخت‌گیرانه تا فیلتر در حال ویرایش پاک نشود */
function sectionValue(key: DataAccessSection): SectionDataAccess {
const raw = props.modelValue?.[key];
if (raw && typeof raw === 'object') {
return {
mode: raw.mode === 'rules' ? 'rules' : 'all',
rules: Array.isArray(raw.rules) ? raw.rules.map((r) => ({ ...r })) : [],
};
}
return emptySectionDataAccess();
}
function setSection(key: DataAccessSection, next: SectionDataAccess) {
emit('update:modelValue', {
...props.modelValue,
[key]: next,
});
}
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false);
const importSourcesFailed = ref(false);
const leadStages = ref<IdOption[]>([]);
const pipelines = ref<IdOption[]>([]);
const dealStages = ref<IdOption[]>([]);
const activityBoards = ref<IdOption[]>([]);
const activityStages = ref<IdOption[]>([]);
const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]);
const routineCategories = ref<IdOption[]>([]);
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
importSourcesReady.value = true;
} catch {
importSourcesFailed.value = true;
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages');
leadStages.value = data.map((s) => ({ id: s.id, name: s.name }));
} catch {
leadStages.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/pipelines');
pipelines.value = data.map((p) => ({ id: p.id, name: p.name }));
dealStages.value = data.flatMap((p) =>
(p.stages ?? []).map((s) => ({ id: s.id, name: `${p.name} · ${s.name}` })),
);
} catch {
pipelines.value = [];
dealStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/boards');
activityBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
} catch {
activityBoards.value = [];
}
try {
const stageLists = await Promise.all(
activityBoards.value.map(async (b) => {
const { data } = await api.get<Array<{ id: string; name: string }>>(
'/activities/stages',
{ params: { boardId: b.id } },
);
return data.map((s) => ({
id: s.id,
name: `${b.name} · ${s.name}`,
}));
}),
);
activityStages.value = stageLists.flat();
} catch {
activityStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/types');
activityTypes.value = data.map((t) => ({ id: t.id, name: t.name }));
} catch {
activityTypes.value = [];
}
try {
const { data } = await api.get<
Array<{
id: string;
name: string;
stages?: Array<{ id: string; name: string }>;
}>
>('/project-boards');
projectBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
projectStages.value = data.flatMap((b) =>
(b.stages ?? []).map((s) => ({ id: s.id, name: `${b.name} · ${s.name}` })),
);
} catch {
projectBoards.value = [];
projectStages.value = [];
}
try {
const { data } = await api.get<Array<{ id: string; name: string }>>('/routines/categories');
routineCategories.value = data.map((c) => ({ id: c.id, name: c.name }));
} catch {
routineCategories.value = [];
}
}
onMounted(() => {
void loadOptions();
});
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script>
<template>
<div class="dap">
<p v-if="!visibleSections.length" class="muted">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</p>
<template v-else>
<div class="tabs" role="tablist">
<button
v-for="s in visibleSections"
:key="s"
type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
>
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
</div>
<DataAccessEditor
:key="active"
:section="active"
:model-value="sectionValue(active)"
:groups="customerGroups"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled"
:compact="compact"
@update:model-value="setSection(active, $event)"
/>
</template>
</div>
</template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
</style>
+13 -6
View File
@@ -212,9 +212,11 @@ onUnmounted(() => {
.org-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
max-width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: #94a3b8;
@@ -223,15 +225,20 @@ onUnmounted(() => {
font-weight: 600;
cursor: pointer;
text-align: right;
padding: 0 0.45rem 0.15rem;
padding: 0 0.15rem;
overflow: hidden;
}
.org-btn :deep(.ico) {
flex-shrink: 0;
margin-top: 0.05rem;
}
.org-btn span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
}
.org-btn:hover {
+61 -5
View File
@@ -39,6 +39,8 @@ const busy = ref(false);
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(false);
/** عکس فیلترهای اعمال‌شده در لحظهٔ باز شدن مودال ذخیره */
const saveFilters = ref<Record<string, unknown>>({});
const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null,
@@ -51,6 +53,41 @@ const selectOptions = computed(() =>
})),
);
const saveFilterCount = computed(() => Object.keys(saveFilters.value).length);
const saveFilterSummary = computed(() => {
const parts: string[] = [];
const f = saveFilters.value;
if (typeof f.q === 'string' && f.q) parts.push(`جستجو: ${f.q}`);
if (typeof f.group === 'string' && f.group) {
parts.push(`گروه (${f.group.split(',').filter(Boolean).length})`);
}
if (typeof f.source === 'string' && f.source) {
parts.push(`منبع (${f.source.split(',').filter(Boolean).length})`);
}
if (typeof f.mood === 'string' && f.mood) {
parts.push(`وضعیت (${f.mood.split(',').filter(Boolean).length})`);
}
if (typeof f.disc === 'string' && f.disc) parts.push(`DISC: ${f.disc}`);
if (typeof f.sort === 'string' && f.sort) parts.push(`مرتب‌سازی: ${f.sort}`);
if (typeof f.viewMode === 'string' && f.viewMode) parts.push(`نمایش: ${f.viewMode}`);
if (typeof f.pipelineId === 'string' && f.pipelineId) parts.push('مسیر فروش');
if (f.amountMin != null && f.amountMin !== '') parts.push(`حداقل مبلغ`);
if (f.amountMax != null && f.amountMax !== '') parts.push(`حداکثر مبلغ`);
return parts.length ? parts.join(' · ') : 'هیچ فیلتری اعمال نشده';
});
/** فقط کلیدهای پر؛ برای JSON و جلوگیری از ذخیرهٔ خالی */
function compactFilters(raw: Record<string, unknown> | null | undefined): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw ?? {})) {
if (value == null || value === '') continue;
if (Array.isArray(value) && value.length === 0) continue;
out[key] = value;
}
return out;
}
async function loadViews() {
loading.value = true;
try {
@@ -71,10 +108,19 @@ function onPick(id: unknown) {
activeId.value = next || undefined;
if (!next) return;
const view = views.value.find((v) => v.id === next);
if (view) emit('apply', view.filters ?? {});
if (!view) return;
// کپی ساده تا مصرف‌کننده همان آبجکت ری‌اکتیو را دستکاری نکند
emit('apply', compactFilters(view.filters ?? {}));
}
function openSave() {
saveFilters.value = compactFilters(props.currentFilters);
if (!Object.keys(saveFilters.value).length) {
message.warning(
'ابتدا فیلترها را از دکمهٔ «فیلتر» اعمال کنید (یا جستجو بزنید)، بعد ذخیره کنید.',
);
return;
}
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? false;
saveOpen.value = true;
@@ -83,12 +129,17 @@ function openSave() {
async function saveView() {
const name = saveName.value.trim();
if (name.length < 2 || busy.value) return;
const filters = compactFilters(saveFilters.value);
if (!Object.keys(filters).length) {
message.warning('چیزی برای ذخیره نیست؛ اول فیلتر را روی صفحه اعمال کنید.');
return;
}
busy.value = true;
try {
const body = {
name,
scope: props.scope,
filters: props.currentFilters,
filters,
isShared: saveShared.value,
};
if (activeId.value) {
@@ -160,11 +211,11 @@ onMounted(() => {
:disabled="loading"
:loading="loading"
:options="selectOptions"
:placeholder="compact ? 'فیلتر ذخیره‌شده' : '— انتخاب —'"
:placeholder="compact ? 'فیلتر ذخیره‌شده' : 'فیلتر ذخیره‌شده را انتخاب کنید'"
:style="{ flex: 1, minWidth: 0, width: '100%' }"
@update:value="onPick"
/>
<a-button :disabled="loading" title="ذخیره فیلتر فعلی" @click="openSave">
<a-button :disabled="loading" title="ذخیره فیلتر فعلی صفحه" @click="openSave">
<template #icon><FlagOutlined /></template>
ذخیره
</a-button>
@@ -193,9 +244,14 @@ onMounted(() => {
<a-input
v-model:value="saveName"
:maxlength="120"
placeholder="مثلاً سرنخ‌های این هفته"
placeholder="مثلاً بنکداران بازار"
/>
</a-form-item>
<a-form-item :label="`محتوای فیلتر (${saveFilterCount} مورد)`">
<p :style="{ margin: 0, color: 'var(--ant-color-text-secondary, #64748b)' }">
{{ saveFilterSummary }}
</p>
</a-form-item>
<a-form-item :style="{ marginBottom: 0 }">
<a-checkbox v-model:checked="saveShared">اشتراک با سازمان</a-checkbox>
</a-form-item>
+95 -76
View File
@@ -5,21 +5,21 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import type {
DataAccessPolicy,
OrganizationDto,
OrgUserDto,
TeamDto,
TeamMemberRole,
UpdatePriorArchiveCutoffRequest,
UpdateUserVisibilityRequest,
} from '@mento/shared';
import {
DATA_SCOPE_LABELS,
DATA_SCOPES,
DATA_VISIBILITY_LABELS,
DATA_VISIBILITIES,
type DataVisibility,
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';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
const props = defineProps<{
@@ -96,7 +96,6 @@ async function setPriorArchive(userId: string, enabled: boolean) {
function cutoffPayload(value: string): UpdatePriorArchiveCutoffRequest {
const parsed = parseLocalInput(value);
if (!parsed) return { cutoffAt: null };
// پایان همان روز محلی تا ایمپورت همان روز هم قبل از تاریخ قطع شمرده شود
parsed.setHours(23, 59, 59, 999);
return { cutoffAt: parsed.toISOString() };
}
@@ -280,25 +279,75 @@ async function removeTeam(id: string) {
}
}
const expandedVisibility = ref<string | null>(null);
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
function scopeOverrides(u: OrgUserDto): number {
return Object.keys(u.dataVisibilityScopes ?? {}).length;
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;
}
async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) {
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(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess);
expandedAccess.value = u.id;
}
async function saveUserAccess(userId: string) {
accessSaving.value = true;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${userId}/visibility`, payload);
ok.value = 'سطح دسترسی به پرونده‌ها به‌روز شد.';
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;
}
}
@@ -448,70 +497,45 @@ onMounted(() => {
</section>
<section class="card">
<h3>سطح دسترسی به پرونده‌ها</h3>
<h3>محدوده داده</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو،
یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛
مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی سطح دسترسی جدا به پرونده‌ها دارند."
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 v-if="scopeOverrides(u)" class="vis-badge">
{{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی
</small>
<small class="vis-badge">{{ accessSummary(u) }}</small>
</span>
<select
:value="u.dataVisibility ?? 'own'"
:disabled="!canEdit"
title="سطح پیش‌فرض"
@change="
setVisibility(u.id, {
dataVisibility: ($event.target as HTMLSelectElement).value as DataVisibility,
})
"
>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
پیش‌فرض: {{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
<UiButton
variant="secondary"
@click="expandedVisibility = expandedVisibility === u.id ? null : u.id"
>
{{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }}
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
</UiButton>
</div>
<div v-if="expandedVisibility === u.id" class="vis-scopes">
<label v-for="s in DATA_SCOPES" :key="s" class="vis-scope">
<span>{{ DATA_SCOPE_LABELS[s] }}</span>
<select
:value="u.dataVisibilityScopes?.[s] ?? ''"
:disabled="!canEdit"
:class="{ custom: Boolean(u.dataVisibilityScopes?.[s]) }"
@change="
setVisibility(u.id, {
scopes: {
[s]: (($event.target as HTMLSelectElement).value || null) as DataVisibility | null,
},
})
"
>
<option value="">
مثل پیش‌فرض ({{ DATA_VISIBILITY_LABELS[u.dataVisibility ?? 'own'] }})
</option>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
{{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
<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>
@@ -642,7 +666,7 @@ onMounted(() => {
}
.vis-row {
display: grid;
grid-template-columns: 1fr 240px auto;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
@@ -657,21 +681,16 @@ onMounted(() => {
}
.vis-scopes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.5rem 1rem;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.vis-scope {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: #334155;
}
select.custom {
border-color: #6366f1;
background: #eef2ff;
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
+16
View File
@@ -68,6 +68,9 @@ defineProps<{
| 'pulse'
| 'sliders'
| 'camera'
| 'clip'
| 'download'
| 'upload'
| 'arrow-right'
| 'arrow-left'
| 'chevron-right'
@@ -211,6 +214,16 @@ defineProps<{
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
@@ -411,6 +424,9 @@ defineProps<{
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
</template>
@@ -42,7 +42,7 @@ const emit = defineEmits<{
stagesChanged: [];
add: [stageId: string];
error: [message: string];
menuDeal: [activity: ActivityDto];
menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string];
bulkDelete: [];
}>();
@@ -218,7 +218,7 @@ function findActivity(id: string) {
function onMenuActivity(id: string) {
const item = findActivity(id);
if (item) emit('menuDeal', item);
if (item) emit('menuDeal', item, findStageOf(id));
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
@@ -391,7 +391,26 @@ async function onAddStage(name: string) {
}
function openActivity(id: string) {
void router.push({ name: 'activity-detail', params: { id } });
const query: Record<string, string> = {
from: 'desk',
nav: crypto.randomUUID(),
view: 'board',
};
const source = props.params;
if (source.filter) query.filter = source.filter;
if (source.boardId) query.board = source.boardId;
if (source.desk) query.desk = source.desk;
if (source.q) query.q = source.q;
if (source.typeDefId) query.typeDefId = source.typeDefId;
else if (source.type) query.type = source.type;
if (source.status) query.status = source.status;
if (source.assigneeId) query.assigneeId = source.assigneeId;
if (source.title) query.title = source.title;
if (source.customer) query.customer = source.customer;
if (source.due) query.due = source.due;
const columnId = findStageOf(id);
if (columnId) query.stage = columnId;
void router.push({ name: 'activity-detail', params: { id }, query });
}
</script>
@@ -6,6 +6,8 @@ import {
formatClockFa,
formatJalaliDay,
} from '@/lib/jalali';
import { prepareActivityAttachment } from '@/lib/activity-attachment';
import { startVoiceRecorder, type VoiceRecorder } from '@/lib/voice-note';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
@@ -14,11 +16,11 @@ import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@me
const props = defineProps<{
activity: ActivityDto;
saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
}>();
const emit = defineEmits<{
close: [];
submit: [text: string];
}>();
const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
@@ -28,8 +30,26 @@ const typeIcon: Record<ActivityType, 'phone' | 'users' | 'check' | 'chat'> = {
note: 'chat',
};
const PHOTO_LIMIT = 8;
const VOICE_LIMIT = 5;
const FILE_LIMIT = 8;
type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File };
type PendingVoice = { localId: string; name: string; url: string; file: File };
type PendingFile = { localId: string; name: string; file: File };
const comment = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const pendingPhotos = ref<PendingPhoto[]>([]);
const pendingVoices = ref<PendingVoice[]>([]);
const pendingFiles = ref<PendingFile[]>([]);
const uploadedCount = ref(0);
const photoBusy = ref(false);
const uploading = ref(false);
const photoError = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const voiceNoteOn = ref(false);
const voiceNoteSeconds = ref(0);
let voiceNote: VoiceRecorder | null = null;
const orgName = ref('سازمان');
const users = ref<OrgUserDto[]>([]);
const mentionOpen = ref(false);
@@ -61,8 +81,29 @@ const nowLabel = computed(
() => `${formatJalaliDay(now.value)} ساعت ${formatClockFa(now.value)}`,
);
function imageCount(kind: 'image' | 'audio' | 'file') {
return (
props.activity.images?.filter((image) => {
const mime = image.mimeType.toLowerCase();
if (kind === 'audio') return mime.startsWith('audio/');
if (kind === 'image') return mime.startsWith('image/');
return !mime.startsWith('audio/') && !mime.startsWith('image/');
}).length ?? 0
);
}
const canSubmit = computed(
() => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value,
() =>
(comment.value.trim().length > 0 ||
pendingPhotos.value.length > 0 ||
pendingVoices.value.length > 0 ||
pendingFiles.value.length > 0 ||
uploadedCount.value > 0) &&
!props.saving &&
!voiceBusy.value &&
!photoBusy.value &&
!uploading.value &&
!voiceNoteOn.value,
);
function onVoiceTranscript(text: string) {
@@ -77,10 +118,164 @@ function onVoiceBusy(active: boolean) {
}
}
function submit() {
function apiMessage(error: unknown, fallback: string) {
const raw = (error as { response?: { data?: { message?: string | string[] } } }).response?.data
?.message;
if (Array.isArray(raw)) return raw.join('، ');
if (typeof raw === 'string' && raw.trim()) return raw;
return fallback;
}
async function onFilesPicked(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
input.value = '';
if (!files.length) return;
photoError.value = '';
for (const file of files) {
photoBusy.value = true;
try {
const prepared = await prepareActivityAttachment(file);
if (prepared.kind === 'image') {
if (imageCount('image') + pendingPhotos.value.length >= PHOTO_LIMIT) {
photoError.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر فعالیت.`;
continue;
}
pendingPhotos.value = [
...pendingPhotos.value,
{ localId: crypto.randomUUID(), name: prepared.name, dataUrl: prepared.previewUrl, file: prepared.file },
];
} else if (prepared.kind === 'audio') {
if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
URL.revokeObjectURL(prepared.previewUrl);
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
continue;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: prepared.name, url: prepared.previewUrl, file: prepared.file },
];
} else if (imageCount('file') + pendingFiles.value.length >= FILE_LIMIT) {
photoError.value = `حداکثر ${FILE_LIMIT} فایل برای هر فعالیت.`;
} else {
pendingFiles.value = [
...pendingFiles.value,
{ localId: crypto.randomUUID(), name: prepared.name, file: prepared.file },
];
}
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'افزودن فایل ناموفق بود.';
} finally {
photoBusy.value = false;
}
}
}
function removePendingPhoto(localId: string) {
pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId);
}
function removePendingVoice(localId: string) {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
}
function removePendingFile(localId: string) {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
}
function voiceClock(total: number) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
}
function addVoiceNote(file: File) {
if (imageCount('audio') + pendingVoices.value.length >= VOICE_LIMIT) {
photoError.value = `حداکثر ${VOICE_LIMIT} صوت برای هر فعالیت.`;
return;
}
pendingVoices.value = [
...pendingVoices.value,
{ localId: crypto.randomUUID(), name: file.name, url: URL.createObjectURL(file), file },
];
}
async function toggleVoiceNote() {
photoError.value = '';
if (voiceNote) {
const handle = voiceNote;
voiceNote = null;
voiceNoteOn.value = false;
try {
addVoiceNote(await handle.stop());
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
return;
}
try {
voiceNoteSeconds.value = 0;
voiceNote = await startVoiceRecorder({
onTick: (seconds) => {
voiceNoteSeconds.value = seconds;
},
onAutoStop: (file) => {
voiceNote = null;
voiceNoteOn.value = false;
addVoiceNote(file);
},
});
voiceNoteOn.value = true;
} catch (error) {
photoError.value = error instanceof Error ? error.message : 'ضبط صوت ناموفق بود.';
}
}
async function uploadQueue(items: Array<{ localId: string; name: string; file: File }>, drop: (localId: string) => void) {
for (const item of items) {
const form = new FormData();
form.append('file', item.file, item.name);
await api.post(`/activities/${props.activity.id}/images`, form);
drop(item.localId);
uploadedCount.value += 1;
}
}
async function uploadPendingPhotos() {
await uploadQueue([...pendingPhotos.value], (localId) => {
pendingPhotos.value = pendingPhotos.value.filter((item) => item.localId !== localId);
});
await uploadQueue([...pendingVoices.value], (localId) => {
const voice = pendingVoices.value.find((item) => item.localId === localId);
if (voice) URL.revokeObjectURL(voice.url);
pendingVoices.value = pendingVoices.value.filter((item) => item.localId !== localId);
});
await uploadQueue([...pendingFiles.value], (localId) => {
pendingFiles.value = pendingFiles.value.filter((item) => item.localId !== localId);
});
}
async function submit() {
const text = comment.value.trim();
if (!text || props.saving || voiceBusy.value) return;
emit('submit', text);
if (!canSubmit.value) return;
photoError.value = '';
uploading.value = true;
const hadPhoto =
pendingPhotos.value.length > 0 ||
pendingVoices.value.length > 0 ||
pendingFiles.value.length > 0 ||
uploadedCount.value > 0;
try {
await uploadPendingPhotos();
await props.save({ text, photoOnly: hadPhoto && !text });
} catch (error) {
photoError.value =
pendingPhotos.value.length > 0
? apiMessage(error, 'افزودن فایل ناموفق بود.')
: apiMessage(error, 'ثبت نظر ناموفق بود.');
} finally {
uploading.value = false;
}
}
function close() {
@@ -150,6 +345,8 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
voiceNote?.cancel();
for (const voice of pendingVoices.value) URL.revokeObjectURL(voice.url);
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
clearInterval(clockTimer);
@@ -215,13 +412,40 @@ onBeforeUnmount(() => {
rows="6"
placeholder="متن خود را بنویسید..."
/>
<p v-if="photoError" class="photo-err">{{ photoError }}</p>
<div v-if="pendingPhotos.length" class="photo-grid">
<figure v-for="photo in pendingPhotos" :key="photo.localId">
<img :src="photo.dataUrl" :alt="photo.name" />
<button type="button" aria-label="حذف عکس" :disabled="uploading" @click="removePendingPhoto(photo.localId)">
×
</button>
</figure>
</div>
<div v-if="pendingVoices.length" class="voice-list">
<div v-for="voice in pendingVoices" :key="voice.localId" class="voice-item">
<audio :src="voice.url" controls preload="metadata" />
<button type="button" aria-label="حذف صوت" :disabled="uploading" @click="removePendingVoice(voice.localId)">
×
</button>
</div>
</div>
<ul v-if="pendingFiles.length" class="file-list">
<li v-for="file in pendingFiles" :key="file.localId">
<UiIcon name="document" :size="15" />
<span>{{ file.name }}</span>
<button type="button" aria-label="حذف فایل" :disabled="uploading" @click="removePendingFile(file.localId)">
×
</button>
</li>
</ul>
<div class="tools">
<div class="tool-cluster">
<div class="tool-wrap">
<button
class="tool"
type="button"
aria-label="منشن"
:disabled="recording"
:disabled="recording || voiceNoteOn"
@click="mentionOpen = !mentionOpen"
>
@
@@ -235,12 +459,36 @@ onBeforeUnmount(() => {
</li>
</ul>
</div>
<button
class="tool"
type="button"
aria-label="پیوست"
title="پیوست"
:disabled="photoBusy || uploading || recording || voiceNoteOn"
@click="fileInput?.click()"
>
<UiIcon name="clip" :size="16" />
</button>
<input ref="fileInput" class="photo-file" type="file" multiple @change="onFilesPicked" />
<button
class="tool"
type="button"
:class="{ 'voice-on': voiceNoteOn }"
:aria-label="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:title="voiceNoteOn ? 'توقف ضبط پیام صوتی' : 'ضبط پیام صوتی'"
:disabled="photoBusy || uploading || recording"
@click="toggleVoiceNote"
>
<UiIcon name="mic" :size="16" />
</button>
<span v-if="voiceNoteOn" class="rec-time">{{ voiceClock(voiceNoteSeconds) }}</span>
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving"
:disabled="saving || voiceNoteOn"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@@ -259,9 +507,9 @@ onBeforeUnmount(() => {
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
{{ recording ? 'در حال ضبط…' : uploading || saving ? 'در حال ثبت…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
<button class="act ghost" type="button" :disabled="saving || voiceBusy || uploading" @click="close">انصراف</button>
</div>
</div>
</section>
@@ -457,6 +705,85 @@ onBeforeUnmount(() => {
color: #94a3b8;
}
.photo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-row small {
color: #64748b;
font-size: 0.75rem;
}
.photo-add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px dashed #99f6e4;
background: #f0fdfa;
color: #0f766e;
border-radius: var(--radius);
padding: 0.35rem 0.7rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.photo-add:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.photo-file {
display: none;
}
.photo-err {
margin: 0;
padding: 0 0.7rem;
color: #b91c1c;
font-size: 0.8rem;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0 0.7rem;
}
.photo-grid figure {
position: relative;
margin: 0;
width: 4.5rem;
height: 4.5rem;
}
.photo-grid img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.photo-grid figure button {
position: absolute;
top: 0.15rem;
inset-inline-end: 0.15rem;
width: 1.2rem;
height: 1.2rem;
border: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #fff;
cursor: pointer;
line-height: 1;
}
.tools {
display: flex;
justify-content: space-between;
@@ -464,6 +791,73 @@ onBeforeUnmount(() => {
padding: 0.35rem 0.5rem 0.5rem;
}
.tool-cluster {
display: flex;
align-items: center;
gap: 0.35rem;
}
.tool.voice-on {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.rec-time {
color: #b91c1c;
font-size: 0.78rem;
font-weight: 700;
}
.voice-list {
display: grid;
gap: 0.35rem;
padding: 0 0.5rem;
}
.voice-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.voice-item audio {
flex: 1;
min-width: 0;
height: 2.1rem;
}
.voice-item button,
.file-list button {
width: 1.4rem;
height: 1.4rem;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
.file-list {
list-style: none;
margin: 0;
padding: 0 0.5rem;
display: grid;
gap: 0.3rem;
}
.file-list li {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.file-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-wrap {
position: relative;
}
@@ -0,0 +1,492 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
export type DeskFilterField = 'title' | 'who' | 'customer' | 'type' | 'when' | 'status';
const props = defineProps<{
/** یک ستون، یا پنل کامل بالای صفحه */
field: DeskFilterField | 'all';
title: string;
customer: string;
due: '' | 'set' | 'unset';
typeKey: string;
statusKey: string;
assigneeId: string;
showAssignee: boolean;
users: Array<{ id: string; fullName: string }>;
types: Array<{ key: string; label: string }>;
statuses: Array<{ key: string; label: string }>;
}>();
const emit = defineEmits<{
title: [value: string];
customer: [value: string];
due: [value: '' | 'set' | 'unset'];
type: [value: string];
status: [value: string];
assignee: [value: string];
clear: [];
done: [];
}>();
const LIST_CAP = 40;
const titleDraft = ref(props.title);
const customerDraft = ref(props.customer);
const whoQuery = ref('');
const typeQuery = ref('');
const openSection = ref<DeskFilterField | null>(null);
watch(
() => props.title,
(value) => {
titleDraft.value = value;
},
);
watch(
() => props.customer,
(value) => {
customerDraft.value = value;
},
);
function norm(value: string) {
return value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک').toLowerCase();
}
const filteredUsers = computed(() => {
const q = norm(whoQuery.value);
const matches = props.users.filter((user) => !q || norm(user.fullName).includes(q));
const selected = props.assigneeId
? props.users.find((user) => user.id === props.assigneeId)
: undefined;
const head = selected && !matches.slice(0, LIST_CAP).some((user) => user.id === selected.id)
? [selected]
: [];
return [...head, ...matches.filter((user) => user.id !== selected?.id)].slice(0, LIST_CAP);
});
const hiddenUsers = computed(() => {
const q = norm(whoQuery.value);
const count = props.users.filter((user) => !q || norm(user.fullName).includes(q)).length;
return Math.max(0, count - filteredUsers.value.length);
});
const showTypeSearch = computed(() => props.types.length > 8);
const visibleTypes = computed(() => {
const q = norm(typeQuery.value);
const all = props.types.filter((item) => item.key === 'all');
const rest = props.types.filter(
(item) => item.key !== 'all' && (!q || norm(item.label).includes(q)),
);
return [...all, ...rest];
});
const hasAny = computed(
() =>
Boolean(props.title.trim() || props.customer.trim() || props.due || props.assigneeId) ||
props.typeKey !== 'all' ||
props.statusKey !== 'all',
);
const dueOptions: Array<{ key: '' | 'set' | 'unset'; label: string }> = [
{ key: '', label: 'همه' },
{ key: 'set', label: 'دارد' },
{ key: 'unset', label: 'ندارد' },
];
const whoLabel = computed(
() => props.users.find((user) => user.id === props.assigneeId)?.fullName || 'همه',
);
const typeLabel = computed(
() => props.types.find((item) => item.key === props.typeKey)?.label || 'همه',
);
const statusLabel = computed(
() => props.statuses.find((item) => item.key === props.statusKey)?.label || 'همه',
);
const dueLabel = computed(() => dueOptions.find((item) => item.key === props.due)?.label ?? 'همه');
function opened(name: DeskFilterField) {
return props.field !== 'all' || openSection.value === name;
}
function toggleSection(name: DeskFilterField) {
openSection.value = openSection.value === name ? null : name;
}
function finish() {
if (props.field === 'all') openSection.value = null;
else emit('done');
}
function shows(name: DeskFilterField) {
if (name === 'who' && !props.showAssignee) return false;
return props.field === 'all' || props.field === name;
}
function applyTitle() {
emit('title', titleDraft.value.trim());
finish();
}
function applyCustomer() {
emit('customer', customerDraft.value.trim());
finish();
}
function pickDue(next: '' | 'set' | 'unset') {
emit('due', props.due === next ? '' : next);
finish();
}
function pickType(next: string) {
emit('type', props.typeKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickStatus(next: string) {
emit('status', props.statusKey === next && next !== 'all' ? 'all' : next);
finish();
}
function pickAssignee(next: string) {
emit('assignee', props.assigneeId === next ? '' : next);
finish();
}
</script>
<template>
<div class="filters" @click.stop>
<section v-if="shows('title')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(title.trim()) }"
@click="toggleSection('title')"
>
<span>عنوان فعالیت</span>
<em>{{ title.trim() || 'همه' }}</em>
</button>
<form v-if="opened('title')" class="draft" @submit.prevent="applyTitle">
<input
v-model="titleDraft"
type="search"
placeholder="بخشی از عنوان"
autocomplete="off"
/>
<button type="submit">اعمال</button>
<button v-if="title" type="button" class="quiet" @click="titleDraft = ''; applyTitle()">
پاک
</button>
</form>
</section>
<section v-if="shows('who')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(assigneeId) }"
@click="toggleSection('who')"
>
<span>کارشناس</span>
<em>{{ whoLabel }}</em>
</button>
<input
v-if="opened('who')"
v-model="whoQuery"
class="find"
type="search"
placeholder="جستجوی نام کارشناس"
autocomplete="off"
/>
<div v-if="opened('who')" class="pick" role="listbox" aria-label="کارشناس">
<button type="button" :class="{ on: !assigneeId }" @click="pickAssignee('')">
همه کارشناسان
</button>
<button
v-for="user in filteredUsers"
:key="user.id"
type="button"
:class="{ on: assigneeId === user.id }"
@click="pickAssignee(user.id)"
>
{{ user.fullName }}
</button>
<p v-if="whoQuery.trim() && !filteredUsers.length" class="empty">کارشناسی با این نام نیست</p>
</div>
<p v-if="opened('who') && hiddenUsers" class="hint">برای دیدن بقیه، نام را دقیق‌تر بنویسید</p>
</section>
<section v-if="shows('customer')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(customer.trim()) }"
@click="toggleSection('customer')"
>
<span>مشتری</span>
<em>{{ customer.trim() || 'همه' }}</em>
</button>
<form v-if="opened('customer')" class="draft" @submit.prevent="applyCustomer">
<input
v-model="customerDraft"
type="search"
placeholder="نام شخص یا شرکت"
autocomplete="off"
/>
<button type="submit">اعمال</button>
<button
v-if="customer"
type="button"
class="quiet"
@click="customerDraft = ''; applyCustomer()"
>
پاک
</button>
</form>
</section>
<section v-if="shows('type')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: typeKey !== 'all' }"
@click="toggleSection('type')"
>
<span>نوع فعالیت</span>
<em>{{ typeLabel }}</em>
</button>
<input
v-if="opened('type') && showTypeSearch"
v-model="typeQuery"
class="find"
type="search"
placeholder="جستجوی نوع"
autocomplete="off"
/>
<div v-if="opened('type')" class="pick" role="listbox" aria-label="نوع فعالیت">
<button
v-for="opt in visibleTypes"
:key="opt.key"
type="button"
:class="{ on: typeKey === opt.key }"
@click="pickType(opt.key)"
>
{{ opt.label }}
</button>
<p v-if="visibleTypes.length <= 1 && typeQuery.trim()" class="empty">این نوع پیدا نشد</p>
</div>
</section>
<section v-if="shows('when')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: Boolean(due) }"
@click="toggleSection('when')"
>
<span>زمان شروع</span>
<em>{{ dueLabel }}</em>
</button>
<p v-else class="hint tight">فقط بودن یا نبودن زمان شروع</p>
<div v-if="opened('when')" class="seg" role="radiogroup" aria-label="زمان شروع">
<button
v-for="opt in dueOptions"
:key="opt.key || 'any'"
type="button"
role="radio"
:aria-checked="due === opt.key"
:class="{ on: due === opt.key }"
@click="pickDue(opt.key)"
>
{{ opt.label }}
</button>
</div>
</section>
<section v-if="shows('status')">
<button
v-if="field === 'all'"
type="button"
class="acc"
:class="{ on: statusKey !== 'all' }"
@click="toggleSection('status')"
>
<span>وضعیت</span>
<em>{{ statusLabel }}</em>
</button>
<div v-if="opened('status')" class="pick" role="listbox" aria-label="وضعیت">
<button
v-for="opt in statuses"
:key="opt.key"
type="button"
:class="{ on: statusKey === opt.key }"
@click="pickStatus(opt.key)"
>
{{ opt.label }}
</button>
</div>
</section>
<button
v-if="field === 'all' && hasAny"
type="button"
class="clear"
@click="openSection = null; emit('clear')"
>
پاک کردن فیلترها
</button>
</div>
</template>
<style scoped>
.filters {
display: grid;
gap: 0.65rem;
}
.acc {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
margin: 0;
padding: 0.28rem 0.15rem;
border: 0;
background: transparent;
font: inherit;
text-align: start;
cursor: pointer;
color: #0f172a;
}
.acc em {
font-style: normal;
color: #94a3b8;
font-size: 0.78rem;
max-width: 9.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acc.on em {
color: #0f766e;
font-weight: 700;
}
.draft {
display: flex;
gap: 0.3rem;
}
.draft input,
.find {
width: 100%;
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
padding: 0.4rem 0.55rem;
font: inherit;
color: #0f172a;
}
.draft input {
flex: 1;
}
.draft button,
.pick button,
.seg button,
.clear {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.draft button {
flex: none;
border-radius: 8px;
background: #0f766e;
color: #fff;
padding: 0.4rem 0.65rem;
}
.draft button.quiet {
background: #f1f5f9;
color: #475569;
}
.pick {
max-height: 11.5rem;
overflow: auto;
display: grid;
align-content: start;
margin-top: 0.35rem;
}
.pick button {
text-align: start;
border-radius: 8px;
padding: 0.42rem 0.5rem;
color: #0f172a;
}
.pick button:hover,
.pick button.on {
background: #f0fdfa;
color: #0f766e;
}
.pick button.on {
font-weight: 700;
}
.seg {
display: flex;
gap: 0.25rem;
padding: 0.2rem;
border-radius: 10px;
background: #f8fafc;
}
.seg button {
flex: 1;
border-radius: 8px;
padding: 0.4rem 0.2rem;
color: #475569;
}
.seg button.on {
background: #fff;
color: #0f766e;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.empty,
.hint {
margin: 0.25rem 0.2rem 0;
color: #94a3b8;
font-size: 0.75rem;
}
.hint.tight {
margin: 0 0 0.35rem;
}
.clear {
border-top: 1px solid #e2e8f0;
padding: 0.55rem 0.2rem 0.15rem;
text-align: start;
color: #b91c1c;
}
</style>
@@ -8,7 +8,7 @@ import {
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
type PostponeKey = 'm15' | 'm30' | 'h1' | 'tomorrow-am' | 'tomorrow-pm' | 'custom';
type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custom';
const props = defineProps<{
dueAt?: string | null;
@@ -21,7 +21,7 @@ const emit = defineEmits<{
}>();
const nowTick = ref(Date.now());
const selected = ref<PostponeKey | null>(null);
const customOpen = ref(false);
const customLocal = ref('');
let tickTimer: number | null = null;
@@ -43,47 +43,50 @@ function tomorrowAt(hour: number, minute = 0) {
return d;
}
/** شنبهٔ بعد، ساعت ۱۰. اگر امروز شنبه باشد، شنبهٔ هفتهٔ بعد. */
function nextSaturdayMorning() {
const d = startOfLocalDay(new Date(nowTick.value));
const untilSaturday = (6 - d.getDay() + 7) % 7;
d.setDate(d.getDate() + (untilSaturday === 0 ? 7 : untilSaturday));
d.setHours(10, 0, 0, 0);
return d;
}
function nextWeekSameTime() {
const d = new Date(base.value.getTime());
d.setDate(d.getDate() + 7);
return d;
}
const options = computed(() => {
const m15 = addMinutes(15);
const m30 = addMinutes(30);
const h1 = addMinutes(60);
const am = tomorrowAt(10);
const pm = tomorrowAt(13);
const saturday = nextSaturdayMorning();
const week = nextWeekSameTime();
return [
{
key: 'm15' as const,
icon: 'refresh' as const,
label: 'یک ربع دیرتر',
time: formatClockFa(m15),
at: m15,
},
{
key: 'm30' as const,
icon: 'refresh' as const,
label: 'نیم ساعت دیرتر',
time: formatClockFa(m30),
at: m30,
},
{
key: 'h1' as const,
icon: 'refresh' as const,
label: 'یک ساعت دیرتر',
time: formatClockFa(h1),
at: h1,
},
{
key: 'tomorrow-am' as const,
icon: 'calendar-sun' as const,
label: 'فردا صبح',
time: `فردا ${formatClockFa(am)}`,
time: formatClockFa(am),
at: am,
},
{
key: 'tomorrow-pm' as const,
icon: 'calendar-sun' as const,
label: 'فردا بعد از ظهر',
time: `فردا ${formatClockFa(pm)}`,
at: pm,
key: 'next-saturday' as const,
label: 'شنبه آینده',
time: formatJalaliDayClock(saturday),
at: saturday,
},
{
key: 'next-week' as const,
label: 'هفته آینده',
time: formatJalaliDayClock(week),
at: week,
},
];
});
@@ -100,31 +103,27 @@ const customDate = computed(() => {
});
const customPreview = computed(() =>
customDate.value ? formatJalaliDayClock(customDate.value) : 'تاریخ و ساعت را انتخاب کنید',
customDate.value ? formatJalaliDayClock(customDate.value) : '',
);
const canSubmit = computed(() => {
if (props.saving || !selected.value) return false;
if (selected.value === 'custom') return Boolean(customDate.value);
return true;
});
function chosenDate(): Date | null {
if (selected.value === 'custom') return customDate.value;
return options.value.find((item) => item.key === selected.value)?.at ?? null;
function apply(at: Date) {
if (props.saving) return;
emit('submit', at.toISOString());
}
function pick(key: PostponeKey) {
selected.value = key;
if (key === 'custom' && !customLocal.value) {
customLocal.value = toLocalInput(addMinutes(60));
if (key === 'custom') {
customOpen.value = true;
if (!customLocal.value) customLocal.value = toLocalInput(addMinutes(60));
return;
}
const at = options.value.find((item) => item.key === key)?.at;
if (at) apply(at);
}
function submit() {
const at = chosenDate();
if (!at || props.saving) return;
emit('submit', at.toISOString());
function submitCustom() {
if (!customDate.value || props.saving) return;
apply(customDate.value);
}
function close() {
@@ -156,73 +155,57 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<div class="backdrop" aria-hidden="true" />
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" />
<header class="head">
<button class="icon" type="button" aria-label="تأیید تعویق" :disabled="!canSubmit" @click="submit">
<UiIcon name="check-plain" :size="22" />
</button>
<div class="titles">
<h2 id="postpone-title">به تعویق انداختن فعالیت</h2>
</div>
<h2 id="postpone-title">به تعویق انداختن</h2>
<button class="icon" type="button" aria-label="بستن" @click="close">
<UiIcon name="x" :size="20" />
</button>
</header>
<p class="lead">زمان جدید برای این فعالیت را انتخاب کنید.</p>
<div class="body">
<button
v-for="item in options"
:key="item.key"
class="card"
type="button"
:class="{ on: selected === item.key }"
:disabled="saving"
@click="pick(item.key)"
>
<span class="label">{{ item.label }}</span>
<span class="aside">
<span class="ico-box">
<UiIcon :name="item.icon" :size="20" />
</span>
<div class="presets">
<button
v-for="item in options"
:key="item.key"
class="card"
type="button"
:disabled="saving"
@click="pick(item.key)"
>
<span class="label">{{ item.label }}</span>
<span class="when">{{ item.time }}</span>
</span>
</button>
</button>
</div>
<button
class="card custom"
type="button"
:class="{ on: selected === 'custom' }"
:class="{ on: customOpen }"
:disabled="saving"
@click="pick('custom')"
>
<span class="copy">
<strong>تنظیم زمان دلخواه</strong>
<small>انتخاب تاریخ و ساعت</small>
</span>
<span class="aside">
<span class="ico-box">
<UiIcon name="calendar-edit" :size="20" />
</span>
</span>
<span class="label">زمان دلخواه</span>
<span class="when">{{ customOpen && customPreview ? customPreview : 'تاریخ و ساعت' }}</span>
</button>
<div v-if="selected === 'custom'" class="custom-box">
<div v-if="customOpen" class="custom-box">
<label class="picker">
<span>تاریخ و ساعت جدید</span>
<JalaliDateInput v-model="customLocal" mode="datetime" />
</label>
<p class="preview">{{ customPreview }}</p>
<button
class="confirm"
type="button"
:disabled="saving || !customDate"
@click="submitCustom"
>
{{ saving ? 'در حال ثبت…' : 'ثبت تعویق' }}
</button>
</div>
<p class="notice">
<UiIcon name="lightbulb" :size="18" />
با تعویق انداختن، این فعالیت در زمان جدید به لیست فعالیت‌های شما منتقل می‌شود.
</p>
</div>
</section>
</div>
@@ -236,20 +219,27 @@ onBeforeUnmount(() => {
z-index: 90;
display: flex;
justify-content: center;
align-items: stretch;
align-items: flex-end;
}
.backdrop {
display: none;
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 23, 42, 0.28);
cursor: pointer;
}
.sheet {
position: relative;
width: 100%;
height: 100dvh;
max-height: 92dvh;
background: #fff;
display: flex;
flex-direction: column;
font-family: IRANSans, Tahoma, sans-serif;
border-radius: 16px 16px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.12);
}
.handle {
@@ -261,75 +251,65 @@ onBeforeUnmount(() => {
}
.head {
display: grid;
grid-template-columns: 40px 1fr 40px;
display: flex;
align-items: center;
padding: 0.1rem 0.7rem 0.15rem;
justify-content: space-between;
gap: 0.5rem;
padding: 0.15rem 0.85rem 0.35rem;
}
.titles h2,
.lead {
.head h2 {
margin: 0;
text-align: center;
}
.titles h2 {
font-size: 16px;
font-weight: 500;
font-weight: 600;
color: #0f172a;
}
.lead {
color: #94a3b8;
font-size: 0.82rem;
padding: 0 1.2rem 0.55rem;
}
.icon {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
border: 0;
background: none;
color: #0f766e;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
}
.icon:disabled {
opacity: 0.35;
cursor: not-allowed;
.body {
overflow: auto;
padding: 0.35rem 1rem calc(1rem + env(safe-area-inset-bottom));
display: grid;
gap: 0.55rem;
}
.body {
flex: 1;
overflow: auto;
padding: 0.15rem 1rem calc(1rem + env(safe-area-inset-bottom));
.presets {
display: grid;
align-content: start;
grid-template-columns: 1fr 1fr;
gap: 0.55rem;
}
.card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
min-height: 72px;
padding: 0.7rem 0.9rem;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0.2rem;
min-height: 64px;
padding: 0.7rem 0.85rem;
border: 1px solid #e8eef3;
border-radius: var(--radius);
border-radius: var(--radius, 10px);
background: #fff;
font: inherit;
text-align: start;
cursor: pointer;
}
.card.on {
.card.on,
.card:hover:not(:disabled) {
border-color: #99f6e4;
background: #f0fdfa;
box-shadow: 0 0 0 1px #99f6e4;
}
.card:disabled {
@@ -337,54 +317,27 @@ onBeforeUnmount(() => {
cursor: not-allowed;
}
.label,
.copy strong {
font-size: 0.95rem;
font-weight: 600;
.label {
font-size: 0.92rem;
font-weight: 700;
color: #0f172a;
}
.copy {
display: grid;
gap: 0.15rem;
}
.copy small {
color: #94a3b8;
font-size: 0.78rem;
}
.aside {
display: grid;
justify-items: center;
gap: 0.28rem;
min-width: 3.6rem;
flex-shrink: 0;
}
.ico-box {
width: 40px;
height: 40px;
border-radius: var(--radius);
background: #e6f7f4;
color: #0d9488;
display: grid;
place-items: center;
}
.when {
color: #0d9488;
font-size: 0.74rem;
color: #0f766e;
font-size: 0.84rem;
font-weight: 600;
white-space: nowrap;
}
.custom {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.custom-box {
border: 1px solid #e8eef3;
border-radius: var(--radius);
padding: 0.85rem 0.9rem;
display: grid;
gap: 0.55rem;
gap: 0.65rem;
}
.picker {
@@ -394,65 +347,31 @@ onBeforeUnmount(() => {
font-size: 0.8rem;
}
.picker input {
width: 100%;
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.45rem 0.7rem;
.confirm {
border: 0;
background: #0f766e;
color: #fff;
border-radius: var(--radius, 10px);
min-height: 42px;
font: inherit;
color: #0f172a;
background: #fff;
}
.preview {
margin: 0;
color: #0f766e;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.notice {
margin: 0.35rem 0 0;
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.85rem 0.9rem;
border-radius: var(--radius);
background: #f8fafc;
color: #475569;
font-size: 0.8rem;
line-height: 1.75;
}
.notice :deep(.ico) {
margin-top: 0.15rem;
color: #0d9488;
flex-shrink: 0;
.confirm:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
align-items: center;
padding: 1.2rem;
}
.backdrop {
display: block;
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.sheet {
position: relative;
max-width: 480px;
height: min(92dvh, 860px);
border-radius: var(--radius, 10px);
max-width: 420px;
border-radius: 16px;
box-shadow: 0 28px 70px rgba(15, 23, 42, 0.22);
}
}
@@ -20,7 +20,7 @@ withDefaults(
<div v-if="showBubble" class="popup">
<span>من دستیار هوشمند</span>
<span>سیستم هستم 👋</span>
<svg class="tail" viewBox="0 0 10 14" aria-hidden="true">
<svg class="tail" viewBox="0 0 10 14" width="10" height="14" aria-hidden="true">
<path d="M10 0 L0 7 L10 14 Z" fill="#fff" />
</svg>
</div>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { NoteCategoryDto, NoteCategoryTone } from '@mento/shared';
const emit = defineEmits<{
close: [];
saved: [];
}>();
const tones: Array<{ value: NoteCategoryTone; label: string }> = [
{ value: 'general', label: 'معمولی' },
{ value: 'follow_up', label: 'پیگیری' },
{ value: 'idea', label: 'ایده' },
{ value: 'suggestion', label: 'پیشنهاد' },
{ value: 'meeting', label: 'جلسه' },
{ value: 'personal', label: 'شخصی' },
];
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const items = ref<NoteCategoryDto[]>([]);
const label = ref('');
async function load() {
loading.value = true;
error.value = '';
try {
items.value = (await api.get<NoteCategoryDto[]>('/notes/categories')).data;
} catch {
error.value = 'بارگذاری دسته‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createCategory() {
if (!label.value.trim()) {
error.value = 'عنوان دسته را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/notes/categories', { label: label.value.trim(), showInBar: true });
label.value = '';
await load();
emit('saved');
} catch (e) {
error.value = axios.isAxiosError(e)
? 'ثبت دسته ناموفق بود.'
: 'ثبت دسته ناموفق بود.';
} finally {
saving.value = false;
}
}
async function patch(item: NoteCategoryDto, data: Partial<NoteCategoryDto>) {
error.value = '';
try {
await api.patch(`/notes/categories/${item.id}`, data);
await load();
emit('saved');
} catch {
error.value = 'ذخیره دسته ناموفق بود.';
}
}
async function move(index: number, dir: -1 | 1) {
const next = index + dir;
if (next < 0 || next >= items.value.length) return;
const ids = items.value.map((item) => item.id);
const [picked] = ids.splice(index, 1);
if (!picked) return;
ids.splice(next, 0, picked);
try {
items.value = (await api.patch<NoteCategoryDto[]>('/notes/categories/reorder', { ids })).data;
emit('saved');
} catch {
error.value = 'جابه‌جایی ناموفق بود.';
}
}
async function remove(item: NoteCategoryDto) {
if (!confirm(`دسته «${item.label}» حذف شود؟`)) return;
try {
await api.delete(`/notes/categories/${item.id}`);
await load();
emit('saved');
} catch {
error.value = 'این دستهٔ پیش‌فرض حذف نمی‌شود. از نوار پنهانش کنید.';
}
}
onMounted(load);
</script>
<template>
<div class="cat-root">
<div class="cat-backdrop" @click="emit('close')" />
<aside class="cat-panel" role="dialog" aria-label="دسته‌های یادداشت">
<header class="cat-head">
<h2>دسته‌های نوار یادداشت</h2>
<button type="button" class="icon-btn" @click="emit('close')">
<UiIcon name="x" :size="20" />
</button>
</header>
<div class="cat-scroll">
<p class="hint">همین عنوان‌ها در نوار بالای یادداشت‌ها دیده می‌شوند. «همه»، «تیمی» و «فقط من» ثابت‌اند.</p>
<p v-if="error" class="banner-error">{{ error }}</p>
<form class="create-form" @submit.prevent="createCategory">
<input v-model="label" type="text" placeholder="دسته جدید، مثلاً قرارداد" />
<button type="submit" class="btn-primary" :disabled="saving">افزودن</button>
</form>
<p v-if="loading" class="hint">در حال بارگذاری…</p>
<ul v-else class="cat-list">
<li v-for="(item, index) in items" :key="item.id">
<input
:value="item.label"
type="text"
@change="patch(item, { label: ($event.target as HTMLInputElement).value })"
/>
<select
:value="item.tone"
@change="patch(item, { tone: ($event.target as HTMLSelectElement).value })"
>
<option v-for="tone in tones" :key="tone.value" :value="tone.value">
{{ tone.label }}
</option>
</select>
<label class="check-line">
<input
type="checkbox"
:checked="item.showInBar && item.isActive"
@change="patch(item, { showInBar: ($event.target as HTMLInputElement).checked, isActive: true })"
/>
<span>در نوار</span>
</label>
<div class="row-actions">
<button type="button" :disabled="index === 0" @click="move(index, -1)">بالا</button>
<button type="button" :disabled="index === items.length - 1" @click="move(index, 1)">پایین</button>
<button v-if="!item.isSystem" type="button" class="danger" @click="remove(item)">حذف</button>
</div>
</li>
</ul>
</div>
</aside>
</div>
</template>
<style scoped>
.cat-root { position: fixed; inset: 0; z-index: 140; }
.cat-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, 0.35); }
.cat-panel {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: min(460px, 100%);
background: #fff;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
}
.cat-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.9rem 1rem;
border-bottom: 1px solid #e2e8f0;
}
.cat-head h2 { margin: 0; font-size: 1.05rem; }
.cat-scroll { overflow: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.hint { margin: 0; color: #64748b; font-size: 0.88rem; }
.banner-error { margin: 0; padding: 0.55rem 0.7rem; border-radius: 10px; background: #fef2f2; color: #b91c1c; }
.create-form { display: flex; gap: 0.45rem; }
.create-form input, .cat-list input, .cat-list select {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
color: #0f172a;
background: #fff;
}
.create-form input { flex: 1; }
.btn-primary, .row-actions button, .icon-btn { border: 0; cursor: pointer; }
.btn-primary { background: #0f766e; color: #fff; border-radius: 10px; padding: 0.45rem 0.8rem; font-weight: 700; }
.icon-btn { background: transparent; color: #64748b; }
.cat-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.55rem; }
.cat-list li {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) auto;
gap: 0.4rem;
align-items: center;
}
.row-actions { grid-column: 1 / -1; display: flex; gap: 0.35rem; }
.row-actions button { background: #f8fafc; color: #334155; border-radius: 8px; padding: 0.3rem 0.5rem; }
.row-actions button:disabled { opacity: 0.4; }
.row-actions .danger { color: #b91c1c; }
.check-line { display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: #334155; white-space: nowrap; }
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { CustomFieldDefinitionDto } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
const props = defineProps<{
fields: CustomFieldDefinitionDto[];
modelValue: Record<string, unknown>;
}>();
const emit = defineEmits<{
'update:modelValue': [value: Record<string, unknown>];
}>();
function setValue(key: string, value: unknown) {
emit('update:modelValue', { ...props.modelValue, [key]: value });
}
function textValue(key: string) {
const value = props.modelValue[key];
if (value == null) return '';
return String(value);
}
function numberValue(key: string) {
const value = props.modelValue[key];
if (value == null || value === '') return '';
const n = Number(value);
return Number.isFinite(n) ? String(n) : '';
}
</script>
<template>
<div v-if="fields.length" class="note-custom-fields">
<p class="note-custom-title">فیلدهای اختصاصی</p>
<div v-for="field in fields" :key="field.id" class="field">
<span>
{{ field.label }}
<em v-if="field.required">*</em>
</span>
<select
v-if="field.fieldType === 'select'"
:value="textValue(field.key)"
@change="setValue(field.key, ($event.target as HTMLSelectElement).value)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="field.fieldType === 'boolean'" class="check-line">
<input
type="checkbox"
:checked="props.modelValue[field.key] === true"
@change="setValue(field.key, ($event.target as HTMLInputElement).checked)"
/>
<span>بله</span>
</label>
<input
v-else-if="field.fieldType === 'number'"
type="number"
:value="numberValue(field.key)"
@input="
setValue(
field.key,
($event.target as HTMLInputElement).value === ''
? null
: Number(($event.target as HTMLInputElement).value),
)
"
/>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:model-value="textValue(field.key)"
@update:model-value="setValue(field.key, $event || null)"
/>
<input
v-else
type="text"
:value="textValue(field.key)"
@input="setValue(field.key, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</template>
<style scoped>
.note-custom-fields {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.note-custom-title {
margin: 0.2rem 0 0;
font-size: 0.82rem;
font-weight: 700;
color: #0f766e;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.82rem;
color: #64748b;
}
.field em {
color: #b91c1c;
font-style: normal;
}
.field select,
.field input[type='text'],
.field input[type='number'] {
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.45rem 0.55rem;
font-size: 0.9rem;
color: #0f172a;
background: #fff;
}
.check-line {
display: flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
}
</style>
@@ -15,12 +15,20 @@ import type {
PersonAccountingLinkDto,
PersonAssetsDto,
TahesabConnectionDto,
TahesabInvoiceSettingsDto,
TahesabSanadType,
} from '@mento/shared';
import { TAHSAB_SANAD_LABELS } from '@/lib/tahesab';
import { formatTahesabBedBes, TAHSAB_SANAD_LABELS } from '@/lib/tahesab';
import type { TahesabInvoiceGroup } from '@/lib/tahesab-invoice-pdf';
import TahesabInvoiceSettingsModal from '@/components/person-assets/TahesabInvoiceSettingsModal.vue';
import TahesabInvoicePreviewModal from '@/components/person-assets/TahesabInvoicePreviewModal.vue';
const props = defineProps<{
personId: string;
/** نام نمایشی مشتری برای فاکتور PDF */
personName?: string;
/** موبایل مشتری برای فاکتور */
personPhone?: string;
}>();
const emit = defineEmits<{
@@ -37,6 +45,10 @@ const assets = ref<PersonAssetsDto | null>(null);
const activeTab = ref<'مانده حساب' | 'جزئیات حساب' | 'ثبت سند'>('مانده حساب');
const asnadLoading = ref(false);
const asnadItems = ref<Record<string, unknown>[]>([]);
const invoiceSettingsOpen = ref(false);
const invoiceSettings = ref<TahesabInvoiceSettingsDto | null>(null);
const invoicePreviewOpen = ref(false);
const invoicePreviewGroup = ref<TahesabInvoiceGroup | null>(null);
/** مدل JalaliDateInput = تاریخ محلی میلادی YYYY-MM-DD */
const fromDate = ref('');
const toDate = ref('');
@@ -174,6 +186,8 @@ const ASNAD_ORDER = Object.keys(ASNAD_FIELD_LABELS);
const ASNAD_HEAD_KEYS = new Set(['Sh_Factor', 'Sh_factor', 'Tarikh', 'NO']);
/** مقادیر کمّی که جداکنندهٔ هزارگان می‌گیرند؛ بقیه (کدها، شناسه‌ها، عیار، تاریخ) خام */
const ASNAD_AMOUNT_KEYS = new Set(['Vazn', 'TabdilVazn', 'Mali', 'Fi', 'Mazaneh', 'Count']);
/** مبالغ علامت‌دار اسناد/مانده — منفی=بد، مثبت=بس (مثل ته‌حساب) */
const ASNAD_SIGNED_KEYS = new Set(['Mali', 'TabdilVazn']);
function asnadFields(item: Record<string, unknown>) {
const keys = Object.keys(item).filter((k) => !ASNAD_HEAD_KEYS.has(k));
@@ -202,6 +216,7 @@ function asnadCell(k: string, v: unknown): string {
if (!s) return '—';
if (k === 'Jens_Felez') return JENS_FELEZ_LABELS[s] ?? showRaw(s);
if (typeof v === 'boolean') return v ? 'بله' : 'خیر';
if (ASNAD_SIGNED_KEYS.has(k)) return formatTahesabBedBes(s);
return ASNAD_AMOUNT_KEYS.has(k) ? showValue(s) : showRaw(s);
}
@@ -383,6 +398,102 @@ async function refreshAssets() {
}
}
async function loadInvoiceSettings() {
try {
const { data } = await api.get<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
);
invoiceSettings.value = data;
} catch {
invoiceSettings.value = null;
}
}
function onInvoiceSettingsSaved(dto: TahesabInvoiceSettingsDto) {
invoiceSettings.value = dto;
}
function parseBalanceNum(v: unknown): number | null {
if (v == null) return null;
const s = String(v).trim().replace(/,/g, '');
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function sumGroupField(g: TahesabInvoiceGroup, key: string): number {
let t = 0;
for (const r of g.rows) {
const n = parseBalanceNum(r[key]);
if (n != null) t += n;
}
return t;
}
function normalizeAsnadDate(d: string): string {
const s = d
.replace(/-/g, '/')
.trim()
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)));
const m = /^(\d{4})\/(\d{1,2})\/(\d{1,2})/.exec(s);
if (!m) return s;
return `${m[1]}/${m[2]!.padStart(2, '0')}/${m[3]!.padStart(2, '0')}`;
}
function asnadDocNoNum(no: string): number {
const s = String(no)
.trim()
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)));
const n = Number(s.replace(/[^\d.-]/g, ''));
return Number.isFinite(n) ? n : 0;
}
/** سندهای قبل از این فاکتور (تاریخ، و در همان روز شماره سند کوچک‌تر) */
function isAsnadBeforeInvoice(
item: Record<string, unknown>,
g: TahesabInvoiceGroup,
): boolean {
const d = normalizeAsnadDate(dash(item.Tarikh, ''));
const gd = normalizeAsnadDate(g.date);
if (!d || d === '—') return false;
if (d < gd) return true;
if (d > gd) return false;
return asnadDocNoNum(sanadNo(item)) < asnadDocNoNum(g.no);
}
function sumAsnadBeforeInvoice(g: TahesabInvoiceGroup, key: string): number {
let t = 0;
for (const item of asnadItems.value) {
if (!isAsnadBeforeInvoice(item, g)) continue;
const n = parseBalanceNum(item[key]);
if (n != null) t += n;
}
return t;
}
const invoicePriorWeight = ref<number | null>(null);
const invoicePriorRial = ref<number | null>(null);
const invoiceFinalWeight = ref<number | null>(null);
const invoiceFinalRial = ref<number | null>(null);
async function exportAsnadPdf(g: TahesabInvoiceGroup) {
error.value = '';
// حساب قبل = جمع اثر اسناد قبل از تاریخ/شماره این سند (نه مانده فعلی ته‌حساب)
const priorW = sumAsnadBeforeInvoice(g, 'TabdilVazn');
const priorR = sumAsnadBeforeInvoice(g, 'Mali');
const invW = sumGroupField(g, 'TabdilVazn');
const invR = sumGroupField(g, 'Mali');
invoicePriorWeight.value = priorW;
invoicePriorRial.value = priorR;
invoiceFinalWeight.value = priorW + invW;
invoiceFinalRial.value = priorR + invR;
invoicePreviewGroup.value = g;
invoicePreviewOpen.value = true;
if (!invoiceSettings.value) {
await loadInvoiceSettings();
}
}
async function loadAsnad() {
if (!connectionId.value || !currentLink.value) return;
const from = toShamsiYmd(fromDate.value);
@@ -394,22 +505,26 @@ async function loadAsnad() {
asnadLoading.value = true;
error.value = '';
try {
const { data } = await api.get<{ items: Record<string, unknown>[] }>(
`/integrations/tahesab/people/${props.personId}/asnad`,
{
params: {
connectionId: connectionId.value,
from,
to,
const [{ data }] = await Promise.all([
api.get<{ items: Record<string, unknown>[] }>(
`/integrations/tahesab/people/${props.personId}/asnad`,
{
params: {
connectionId: connectionId.value,
from,
to,
},
timeout: 60_000,
},
timeout: 60_000,
},
);
),
loadInvoiceSettings(),
]);
asnadItems.value = data.items ?? [];
ok.value = asnadItems.value.length
? `${asnadItems.value.length} سند نمایش داده شد.`
: 'در این بازه سندی یافت نشد.';
} catch (e) {
asnadItems.value = [];
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
@@ -636,17 +751,17 @@ onMounted(async () => {
<div v-if="mandehFields?.length" class="balance-grid">
<article v-for="f in mandehFields" :key="f.key" class="balance-box">
<span>{{ mandehFieldLabel(f.key) }}</span>
<strong dir="ltr">{{ showValue(f.value) }}</strong>
<strong dir="ltr">{{ formatTahesabBedBes(f.value) }}</strong>
</article>
</div>
<div v-else class="balance-grid">
<article class="balance-box">
<span>مانده وزنی (طلا)</span>
<strong dir="ltr">{{ showValue(assets.goldBalance) }}</strong>
<strong dir="ltr">{{ formatTahesabBedBes(assets.goldBalance) }}</strong>
</article>
<article class="balance-box">
<span>مانده مالی</span>
<strong dir="ltr">{{ showValue(assets.rialBalance) }}</strong>
<strong dir="ltr">{{ formatTahesabBedBes(assets.rialBalance) }}</strong>
</article>
</div>
<p class="muted tiny">
@@ -683,7 +798,7 @@ onMounted(async () => {
>
<span role="cell" class="raw-name">{{ d.Name }}</span>
<span v-for="c in detailCols" :key="c.key" role="cell" dir="ltr" class="raw-num">
{{ showValue(d[c.key]) }}
{{ formatTahesabBedBes(d[c.key]) }}
</span>
</div>
</div>
@@ -692,7 +807,7 @@ onMounted(async () => {
<div class="name">{{ item.Name }}:</div>
<div class="vals">
<template v-for="(v, vi) in item.values" :key="vi">
<span dir="ltr">{{ showValue(v.amount) }}</span>
<span dir="ltr">{{ formatTahesabBedBes(v.amount) }}</span>
<span class="unit">{{ v.unit }}</span>
<span v-if="vi < item.values.length - 1" class="sep">|</span>
</template>
@@ -715,6 +830,9 @@ onMounted(async () => {
<button type="button" class="btn" :disabled="asnadLoading" @click="loadAsnad">
{{ asnadLoading ? '…' : 'نمایش اسناد' }}
</button>
<button type="button" class="btn ghost" @click="invoiceSettingsOpen = true">
تنظیمات فاکتور
</button>
</div>
<div v-if="asnadItems.length" class="view-bar">
<span class="muted">
@@ -748,6 +866,14 @@ onMounted(async () => {
<strong>سند شماره {{ showRaw(g.no) }}</strong>
<span v-if="g.rows[0]?.isSanadMovaghat === true" class="inv-badge">موقت</span>
<span class="sg-count">{{ g.rows.length.toLocaleString('fa-IR') }} ردیف</span>
<button
type="button"
class="btn inv-pdf-btn"
title="پیش‌نمایش و چاپ فاکتور"
@click="exportAsnadPdf(g)"
>
پیش‌نمایش فاکتور
</button>
</div>
<dl class="inv-meta">
<div>
@@ -830,7 +956,7 @@ onMounted(async () => {
</div>
<div class="sc-row cash">
<span>مالی</span>
<strong dir="ltr">{{ dash(item.Mali) }}</strong>
<strong dir="ltr">{{ formatTahesabBedBes(item.Mali) }}</strong>
</div>
<div class="sc-row">
<span>شرح</span>
@@ -954,6 +1080,23 @@ onMounted(async () => {
</form>
</template>
</template>
<TahesabInvoiceSettingsModal
v-model:open="invoiceSettingsOpen"
@saved="onInvoiceSettingsSaved"
/>
<TahesabInvoicePreviewModal
v-model:open="invoicePreviewOpen"
:group="invoicePreviewGroup"
:settings="invoiceSettings"
:customer-name="personName?.trim() || 'مشتری'"
:customer-phone="personPhone?.trim() || ''"
:moshtari-code="currentLink?.moshtariCode ?? null"
:prior-weight="invoicePriorWeight"
:prior-rial="invoicePriorRial"
:final-weight="invoiceFinalWeight"
:final-rial="invoiceFinalRial"
/>
</div>
</template>
@@ -1195,9 +1338,15 @@ onMounted(async () => {
.inv-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.95rem;
}
.inv-pdf-btn {
margin-inline-start: auto;
font-size: 0.8rem;
padding: 0.3rem 0.65rem;
}
.inv-badge {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
@@ -0,0 +1,722 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { useEscClose } from '@/lib/modal-stack';
import { formatTahesabBedBes } from '@/lib/tahesab';
import {
aggregateSameNameInvoiceRows,
dashInvoice,
fmtInvoiceNum,
pickInvoiceDarsad,
pickInvoiceMazaneh,
printTahesabInvoicePdf,
rowInvoiceDesc,
formatInvoiceWeightBalance,
invoiceMesghalLabels,
sumInvoiceMali,
tabdilVaznColumnLabel,
type TahesabInvoiceGroup,
} from '@/lib/tahesab-invoice-pdf';
const props = defineProps<{
open: boolean;
group: TahesabInvoiceGroup | null;
settings: TahesabInvoiceSettingsDto | null;
customerName: string;
customerPhone?: string;
moshtariCode?: number | null;
priorWeight?: number | null;
priorRial?: number | null;
finalWeight?: number | null;
finalRial?: number | null;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
useEscClose(
() => props.open,
() => close(),
);
function close() {
emit('update:open', false);
}
const first = computed(() => props.group?.rows[0] ?? {});
const temporary = computed(() => first.value.isSanadMovaghat === true);
const issuerName = computed(
() => dashInvoice(first.value.User, '') || '—',
);
const invoiceRows = computed(() =>
props.group ? aggregateSameNameInvoiceRows(props.group.rows) : [],
);
const total = computed(() => sumInvoiceMali(invoiceRows.value));
const sourceRows = computed(
() => props.group?.rows ?? invoiceRows.value,
);
const totalVaznLabel = computed(() =>
formatInvoiceWeightBalance(sourceRows.value, 'Vazn', false),
);
const totalTabdilLabel = computed(() =>
formatInvoiceWeightBalance(sourceRows.value, 'TabdilVazn', true),
);
const tabdilColLabel = computed(() =>
tabdilVaznColumnLabel(sourceRows.value),
);
const company = computed(
() => props.settings?.companyName?.trim() || 'فاکتور ته‌حساب',
);
const generatedAtLabel = computed(() =>
props.open ? formatJalaliDateTime(new Date()) : '',
);
const mesghalLabels = computed(() =>
invoiceMesghalLabels(sourceRows.value),
);
function printPdf() {
if (!props.group) return;
const ok = printTahesabInvoicePdf({
group: props.group,
settings: props.settings,
customerName: props.customerName,
customerPhone: props.customerPhone,
moshtariCode: props.moshtariCode,
priorWeight: props.priorWeight,
priorRial: props.priorRial,
finalWeight: props.finalWeight,
finalRial: props.finalRial,
});
if (!ok) {
// در مرورگر Cursor پاپ‌آپ کار نمی‌کند؛ پیش‌نمایش همین‌جا کافی است
window.alert(
'چاپ در این مرورگر باز نشد. از Chrome استفاده کنید یا از منوی چاپ سیستم روی همین پیش‌نمایش استفاده کنید.',
);
}
}
</script>
<template>
<div v-if="open && group" class="backdrop" @click.self="close">
<div
class="sheet"
role="dialog"
aria-modal="true"
aria-label="پیش‌نمایش فاکتور ته‌حساب"
>
<header class="sheet-head">
<div>
<h3>پیش‌نمایش فاکتور ته‌حساب</h3>
<p class="muted">
سند {{ toFaDigits(group.no) }} · همین‌جا قابل بررسی است (بدون پاپ‌آپ)
</p>
</div>
<div class="head-actions">
<button type="button" class="btn" @click="printPdf">چاپ / PDF</button>
<button type="button" class="btn ghost" @click="close">بستن</button>
</div>
</header>
<div class="paper">
<div class="top">
<div class="top-side top-start">
<p v-if="settings?.headerText?.trim()" class="header-text">
{{ settings.headerText }}
</p>
<p class="org">{{ company }}</p>
<div v-if="settings?.phone?.trim()" class="dim">
تلفن: <bdi dir="ltr">{{ settings.phone }}</bdi>
</div>
</div>
<div class="top-center">
<img
v-if="settings?.logoDataUrl"
class="logo"
:src="settings.logoDataUrl"
alt=""
/>
</div>
<div class="top-side top-end">
<div class="doc-date">
تاریخ: {{ toFaDigits(group.date) }}
</div>
<div>
شماره سند:
<strong>{{ toFaDigits(group.no) }}</strong>
</div>
<div
v-if="settings?.companySubtitle?.trim()"
class="sub"
dir="ltr"
>
{{ settings.companySubtitle }}
</div>
<span v-if="temporary" class="badge">سند موقت</span>
</div>
</div>
<div class="party">
<span class="party-item">
حساب:
<strong>{{ customerName || '—' }}</strong>
</span>
<span class="party-item">
کد:
<strong class="ltr">{{
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—'
}}</strong>
</span>
<span class="party-item party-phone">
موبایل:
<strong class="ltr">{{
customerPhone?.trim()
? toFaDigits(customerPhone.trim())
: '—'
}}</strong>
</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-radif">ردیف</th>
<th class="col-desc">شرح</th>
<th class="col-darsad">درصد</th>
<th>عیار</th>
<th>وزن</th>
<th class="col-tabdil">{{ tabdilColLabel }}</th>
<th>مظنه/فی</th>
<th class="col-mali">مبلغ (ریال)</th>
</tr>
</thead>
<tbody>
<tr
v-for="(r, i) in invoiceRows"
:key="String(r.ID ?? r.No_Kar ?? i)"
>
<td class="col-radif">
{{ toFaDigits(dashInvoice(r.Radif, String(i + 1))) }}
</td>
<td class="col-desc">{{ rowInvoiceDesc(r) }}</td>
<td class="ltr col-darsad">
{{ fmtInvoiceNum(pickInvoiceDarsad(r)) }}
</td>
<td class="ltr">{{ fmtInvoiceNum(r.Ayar) }}</td>
<td class="ltr">{{ fmtInvoiceNum(r.Vazn) }}</td>
<td class="ltr col-tabdil">
{{ formatTahesabBedBes(r.TabdilVazn) }}
</td>
<td class="ltr">{{ fmtInvoiceNum(pickInvoiceMazaneh(r)) }}</td>
<td class="ltr col-mali">
{{ formatTahesabBedBes(r.Mali) }}
</td>
</tr>
</tbody>
<tfoot>
<tr class="totals-row">
<td colspan="3" class="totals-label">مجموع کل اجناس</td>
<td></td>
<td class="ltr weight-sum">{{ totalVaznLabel }}</td>
<td class="ltr weight-sum col-tabdil">{{ totalTabdilLabel }}</td>
<td></td>
<td class="ltr col-mali">{{ formatTahesabBedBes(total) }}</td>
</tr>
</tfoot>
</table>
</div>
<div class="invoice-balance">
<div class="ib-when">
<span class="ib-k">زمان چاپ:</span>
{{ generatedAtLabel }}
</div>
<div class="ib-label">مانده این فاکتور:</div>
<div class="ib-cell">
<span class="ib-k">مانده وزنی:</span>
<strong class="ib-val" dir="ltr">{{ totalTabdilLabel }}</strong>
</div>
<div class="ib-cell">
<span class="ib-k">مانده ریالی:</span>
<strong class="ib-val" dir="ltr">{{
formatTahesabBedBes(total)
}}</strong>
</div>
</div>
<div class="account-row">
<div class="mesghal-box">
<div
v-for="label in mesghalLabels"
:key="label"
class="mesghal-line"
>
<span class="ib-k">{{ label }}</span>
<span class="mesghal-blank" aria-hidden="true" />
</div>
</div>
<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
<span class="acc-pair">
<span class="ib-k">وزنی</span>
<strong dir="ltr">{{ formatTahesabBedBes(priorWeight) }}</strong>
</span>
<span class="acc-pair">
<span class="ib-k">ریالی</span>
<strong dir="ltr">{{ formatTahesabBedBes(priorRial) }}</strong>
</span>
</div>
<div class="acc-line">
<span class="acc-title">مانده نهایی:</span>
<span class="acc-pair">
<span class="ib-k">وزنی</span>
<strong dir="ltr">{{ formatTahesabBedBes(finalWeight) }}</strong>
</span>
<span class="acc-pair">
<span class="ib-k">ریالی</span>
<strong dir="ltr">{{ formatTahesabBedBes(finalRial) }}</strong>
</span>
</div>
</div>
</div>
<footer class="page-foot">
<p v-if="settings?.footerText?.trim()" class="note foot">
{{ settings.footerText }}
</p>
<div class="sign-row">
<div class="sign-col">
<span class="ib-k">صادر کننده سند:</span>
<strong class="sign-name">{{ issuerName }}</strong>
<div class="sign-space" aria-hidden="true"></div>
</div>
<div class="sign-addr">
{{ settings?.address?.trim() || '—' }}
</div>
<div class="sign-col">
<span class="ib-k">گیرنده سند:</span>
<strong class="sign-name">{{ customerName || '—' }}</strong>
<div class="sign-space" aria-hidden="true"></div>
</div>
</div>
</footer>
</div>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 85;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 12px;
}
.sheet {
width: min(920px, 100%);
max-height: min(92vh, 960px);
overflow: auto;
background: #f3f4f6;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sheet-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
background: #fff;
border-radius: 8px;
padding: 10px 12px;
}
.sheet-head h3 {
margin: 0;
font-size: 1rem;
}
.muted {
margin: 4px 0 0;
color: #6b7280;
font-size: 0.85rem;
}
.head-actions {
display: flex;
gap: 8px;
}
.paper {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px 22px;
color: #111827;
font-size: 13px;
}
.top {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px 16px;
align-items: center;
margin-bottom: 6px;
border: 1px solid #111827;
border-radius: 10px;
padding: 12px 14px;
}
.top-side {
min-width: 0;
}
.top-start {
text-align: right;
}
.top-end {
text-align: left;
}
.top-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.header-text {
margin: 0 0 8px;
white-space: pre-wrap;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
color: #111827;
}
.logo {
max-height: 110px;
max-width: 220px;
object-fit: contain;
}
.org {
font-size: 16px;
font-weight: 800;
margin: 0;
}
.sub {
color: #374151;
margin-top: 8px;
direction: ltr;
unicode-bidi: isolate;
text-align: left;
font-size: 13px;
}
.dim {
color: #6b7280;
margin-top: 2px;
}
.doc-date {
font-weight: 800;
font-size: 15px;
margin-bottom: 4px;
}
.badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border: 1px solid #d97706;
color: #b45309;
border-radius: 999px;
font-size: 11px;
}
.party {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-bottom: 14px;
border: 1px solid #111827;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
}
.party-item {
white-space: nowrap;
}
.party-phone {
margin-inline-start: auto;
}
.page-foot {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.note {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
margin: 0;
white-space: pre-wrap;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
align-items: start;
margin-top: 4px;
}
.sign-col {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.sign-name {
font-size: 13px;
font-weight: 800;
}
.sign-space {
min-height: 56px;
margin-top: 4px;
border-bottom: 1px dashed #d1d5db;
}
.sign-addr {
color: #6b7280;
font-size: 12px;
text-align: center;
line-height: 1.55;
max-width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding-top: 2px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 7px 8px;
border: 1px solid #e5e7eb;
text-align: right;
vertical-align: top;
}
th {
background: #f3f4f6;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.col-radif {
width: 5%;
white-space: nowrap;
padding-inline: 4px;
text-align: center;
vertical-align: middle;
}
.col-desc {
width: 32%;
word-break: break-word;
}
.col-darsad {
width: 6%;
white-space: nowrap;
padding-inline: 4px;
}
.col-tabdil {
width: 14%;
white-space: nowrap;
}
.col-mali {
width: 13%;
white-space: nowrap;
}
.ltr {
direction: ltr;
unicode-bidi: isolate;
text-align: left;
}
.totals-row td {
font-weight: 800;
background: #f9fafb;
vertical-align: middle;
}
.totals-label {
text-align: right;
}
.weight-sum {
font-size: 11px;
white-space: nowrap;
line-height: 1.35;
}
.invoice-balance {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr;
gap: 0;
margin-top: 10px;
border: 1px solid #111827;
border-radius: 10px;
overflow: hidden;
font-size: 13px;
background: #f3f4f6;
}
.invoice-balance > div {
padding: 8px 12px;
}
.invoice-balance > .ib-cell {
border-inline-start: 1px solid #e5e7eb;
}
.ib-when {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
white-space: nowrap;
}
.ib-label {
font-weight: 800;
display: flex;
align-items: center;
justify-content: left;
text-align: left;
}
.ib-cell {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-height: 1.4em;
padding-left: 7.5rem;
box-sizing: border-box;
white-space: nowrap;
}
.ib-k {
color: #6b7280;
font-size: 12px;
}
.ib-val {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
text-align: left;
unicode-bidi: isolate;
font-weight: 800;
}
.account-row {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 10px;
}
.mesghal-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mesghal-blank {
flex: 1;
min-width: 4rem;
border-bottom: 1px dotted #9ca3af;
align-self: flex-end;
margin-bottom: 0.35rem;
margin-inline-start: 0.5rem;
}
.account-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
overflow: hidden;
display: flex;
flex-direction: column;
}
.acc-line {
display: grid;
grid-template-columns: auto 1fr 1fr;
align-items: stretch;
gap: 0;
border-top: 1px solid #e5e7eb;
}
.acc-line:first-child {
border-top: 0;
}
.acc-title {
font-weight: 800;
white-space: nowrap;
padding: 6px 12px;
display: flex;
align-items: center;
}
.acc-pair {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 6px 12px;
border-inline-start: 1px solid #e5e7eb;
}
.acc-pair strong {
font-weight: 800;
unicode-bidi: isolate;
}
.btn {
border: 0;
border-radius: 8px;
padding: 0.45rem 0.8rem;
background: var(--accent, #0d9488);
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: transparent;
color: #111827;
border: 1px solid #e5e7eb;
}
@media (max-width: 720px) {
.party-phone {
margin-inline-start: 0;
}
.top {
grid-template-columns: 1fr;
justify-items: stretch;
}
.top-center {
order: -1;
}
.top-end {
text-align: right;
}
.invoice-balance {
grid-template-columns: 1fr 1fr;
}
.account-row {
grid-template-columns: 1fr;
}
.sign-row {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { useEscClose } from '@/lib/modal-stack';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{
'update:open': [value: boolean];
saved: [value: TahesabInvoiceSettingsDto];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const logoDataUrl = ref<string | null>(null);
const companyName = ref('');
const companySubtitle = ref('');
const address = ref('');
const phone = ref('');
const headerText = ref('');
const footerText = ref('');
useEscClose(
() => props.open,
() => close(),
);
function close() {
emit('update:open', false);
}
function fill(dto: TahesabInvoiceSettingsDto) {
logoDataUrl.value = dto.logoDataUrl;
companyName.value = dto.companyName ?? '';
companySubtitle.value = dto.companySubtitle ?? '';
address.value = dto.address ?? '';
phone.value = dto.phone ?? '';
headerText.value = dto.headerText ?? '';
footerText.value = dto.footerText ?? '';
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
);
fill(data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری تنظیمات ناموفق بود.',
)
: 'بارگذاری تنظیمات ناموفق بود.';
} finally {
loading.value = false;
}
}
function onLogoFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
error.value = 'فقط فایل تصویر مجاز است.';
return;
}
if (file.size > 400_000) {
error.value = 'حجم تصویر حداکثر حدود ۴۰۰ کیلوبایت باشد.';
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
if (!result.startsWith('data:image/')) {
error.value = 'خواندن تصویر ناموفق بود.';
return;
}
logoDataUrl.value = result;
error.value = '';
};
reader.readAsDataURL(file);
}
function clearLogo() {
logoDataUrl.value = null;
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const { data } = await api.patch<TahesabInvoiceSettingsDto>(
'/integrations/tahesab/invoice-settings',
{
logoDataUrl: logoDataUrl.value,
companyName: companyName.value,
companySubtitle: companySubtitle.value,
address: address.value,
phone: phone.value,
headerText: headerText.value,
footerText: footerText.value,
},
);
fill(data);
ok.value = 'تنظیمات فاکتور ذخیره شد.';
emit('saved', data);
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره تنظیمات ناموفق بود.',
)
: 'ذخیره تنظیمات ناموفق بود.';
} finally {
saving.value = false;
}
}
watch(
() => props.open,
(open) => {
if (open) {
ok.value = '';
error.value = '';
void load();
}
},
);
onMounted(() => {
if (props.open) void load();
});
</script>
<template>
<div v-if="open" class="backdrop" @click.self="close">
<div class="sheet" role="dialog" aria-modal="true" aria-label="تنظیمات فاکتور ته‌حساب">
<header class="sheet-head">
<div>
<h3>تنظیمات فاکتور ته‌حساب</h3>
<p class="muted">لوگو و متن‌های ثابت خروجی PDF اسناد — جدا از فاکتور CRM</p>
</div>
<button type="button" class="btn ghost" @click="close">بستن</button>
</header>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else class="grid">
<label class="field logo-field">
<span>لوگو</span>
<div class="logo-row">
<img v-if="logoDataUrl" :src="logoDataUrl" alt="لوگو" class="logo-preview" />
<div class="logo-actions">
<input type="file" accept="image/*" @change="onLogoFile" />
<button v-if="logoDataUrl" type="button" class="btn ghost" @click="clearLogo">
حذف لوگو
</button>
</div>
</div>
</label>
<label class="field">
<span>نام شرکت / فروشگاه</span>
<input v-model="companyName" type="text" maxlength="200" />
</label>
<label class="field">
<span>زیرعنوان</span>
<input v-model="companySubtitle" type="text" maxlength="200" />
</label>
<label class="field">
<span>آدرس</span>
<input v-model="address" type="text" maxlength="500" />
</label>
<label class="field">
<span>تلفن</span>
<input v-model="phone" type="text" maxlength="80" dir="ltr" />
</label>
<label class="field full">
<span>متن بالای فاکتور</span>
<textarea v-model="headerText" rows="2" maxlength="1000" />
</label>
<label class="field full">
<span>متن پایین فاکتور</span>
<textarea v-model="footerText" rows="2" maxlength="1000" />
</label>
</div>
<footer class="sheet-foot">
<button type="button" class="btn" :disabled="saving || loading" @click="save">
{{ saving ? '…' : 'ذخیره تنظیمات' }}
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgba(15, 23, 42, 0.45);
display: grid;
place-items: center;
padding: 16px;
}
.sheet {
width: min(640px, 100%);
max-height: min(90vh, 820px);
overflow: auto;
background: var(--surface, #fff);
border-radius: 12px;
border: 1px solid var(--line, #e5e7eb);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.sheet-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.sheet-head h3 {
margin: 0;
font-size: 1.05rem;
}
.muted {
margin: 4px 0 0;
color: var(--ink-secondary, #6b7280);
font-size: 0.85rem;
}
.err {
color: #b91c1c;
margin: 0;
}
.ok {
color: #047857;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
}
.field.full,
.logo-field {
grid-column: 1 / -1;
}
.field input,
.field textarea {
border: 1px solid var(--line, #e5e7eb);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
background: var(--surface, #fff);
}
.logo-row {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.logo-preview {
max-height: 64px;
max-width: 140px;
object-fit: contain;
border: 1px solid var(--line, #e5e7eb);
border-radius: 8px;
padding: 4px;
background: #fff;
}
.logo-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.sheet-foot {
display: flex;
justify-content: flex-end;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -8,8 +8,9 @@ withDefaults(
defineProps<{
accent: PipelineAccent;
label?: string;
count?: string;
}>(),
{ label: 'افزودن معامله' },
{ label: 'افزودن معامله', count: '' },
);
const emit = defineEmits<{
@@ -21,11 +22,18 @@ const emit = defineEmits<{
<a-button
type="dashed"
block
:style="{ color: PIPELINE_ACCENT[accent].text }"
:style="{
color: PIPELINE_ACCENT[accent].text,
display: 'flex',
justifyContent: 'space-between',
}"
:aria-label="label"
@click="emit('add')"
>
<template #icon><PlusOutlined /></template>
{{ label }}
<span :style="{ display: 'inline-flex', alignItems: 'center', gap: '8px' }">
<PlusOutlined />
{{ label }}
</span>
<span v-if="count">{{ count }}</span>
</a-button>
</template>
+9 -21
View File
@@ -18,11 +18,7 @@ import {
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import {
DEAL_BOARD_COPY,
formatCurrencyParts,
PIPELINE_ACCENT,
} from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -54,7 +50,6 @@ const showAmount = computed(
const money = computed(() =>
formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'ریال'),
);
const colors = computed(() => PIPELINE_ACCENT[props.accent]);
const assigneeTitle = computed(() =>
(props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول',
);
@@ -113,24 +108,17 @@ const cardStyle = computed(() => ({
<CheckOutlined v-if="selected" />
</template>
</a-button>
<a-avatar
v-else
:style="{
background: colors.soft,
color: colors.text,
flexShrink: 0,
fontWeight: 700,
fontSize: `${token.fontSizeSM}px`,
}"
>
{{ deal.initials }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35, cursor: 'pointer' }"
:style="{
display: 'block',
margin: 0,
lineHeight: 1.35,
cursor: 'pointer',
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
}"
:title="deal.title"
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
>
@@ -131,8 +131,8 @@ function submitAdd() {
<div
v-if="canManage"
:style="{
flex: '0 0 280px',
width: '280px',
flex: '0 0 310px',
width: '310px',
alignSelf: 'stretch',
display: 'flex',
alignItems: 'flex-start',
@@ -1,10 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue';
import {
HolderOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Dropdown as ADropdown,
@@ -17,7 +13,7 @@ import {
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, toFaItemCount } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -67,8 +63,8 @@ const columnStyle = computed(() => ({
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
flex: '0 0 280px',
width: '280px',
flex: '0 0 310px',
width: '310px',
alignSelf: 'stretch' as const,
minHeight: 0,
padding: 0,
@@ -154,15 +150,21 @@ function onColumnDragStart(event: DragEvent) {
}
const menuItems = computed(() => [
{ key: 'add', label: 'افزودن' },
{ key: 'rename', label: 'تغییر نام' },
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
{ key: 'delete', label: 'حذف ستون', danger: true },
]);
const itemCountLabel = computed(() =>
new Intl.NumberFormat('fa-IR').format(props.stage.total ?? props.stage.deals.length),
);
function onMenuClick({ key }: { key: string | number }) {
menuOpen.value = false;
if (key === 'rename') startRename();
if (key === 'add') emit('addDeal', props.stage.id);
else if (key === 'rename') startRename();
else if (key === 'right') emit('move', props.stage.id, -1);
else if (key === 'left') emit('move', props.stage.id, 1);
else if (key === 'delete') emit('delete', props.stage.id);
@@ -224,12 +226,13 @@ function onMenuClick({ key }: { key: string | number }) {
/>
<a-typography-text
v-else
strong
ellipsis
:style="{
flex: 1,
minWidth: 0,
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontWeight: 600,
cursor: canManage ? 'text' : 'default',
}"
:title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
@@ -242,10 +245,6 @@ function onMenuClick({ key }: { key: string | number }) {
{{ stage.badge }}
</a-tag>
<a-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
</a-typography-text>
<a-dropdown
v-if="canManage"
v-model:open="menuOpen"
@@ -259,15 +258,6 @@ function onMenuClick({ key }: { key: string | number }) {
<a-menu :items="menuItems" @click="onMenuClick" />
</template>
</a-dropdown>
<a-button
type="text"
:aria-label="copy.addItemAria"
:style="{ color: accent.solid }"
@click="emit('addDeal', stage.id)"
>
<template #icon><PlusOutlined /></template>
</a-button>
</div>
<div role="list" :aria-label="stage.title" :style="bodyStyle">
@@ -311,7 +301,12 @@ function onMenuClick({ key }: { key: string | number }) {
</div>
<div :style="{ padding: '0 12px 12px', flexShrink: 0 }">
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
<AddDealButton
:accent="stage.color"
:label="copy.addItem"
:count="itemCountLabel"
@add="emit('addDeal', stage.id)"
/>
</div>
</section>
</template>
@@ -23,7 +23,6 @@ import {
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
@@ -34,6 +33,7 @@ import {
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
const { Text: ATypographyText } = Typography;
@@ -64,7 +64,6 @@ const form = reactive({
categoryId: '',
assigneeId: '',
isMandatory: false,
entity: null as PickedEntity | null,
});
const saving = ref(false);
const error = ref('');
@@ -110,7 +109,6 @@ watch(
categoryId: '',
assigneeId: props.defaultAssigneeId || auth.user?.id || '',
isMandatory: false,
entity: null,
});
error.value = '';
void ensureContext();
@@ -120,7 +118,7 @@ watch(
const assignees = computed(() => {
const c = ctx.value;
if (!c?.isManager) return [];
if (!c?.isManager || !canAssignToOthers(auth.user, 'routines')) return [];
return c.users;
});
@@ -138,14 +136,14 @@ async function submit() {
error.value = 'عنوان کار را وارد کنید.';
return Promise.reject();
}
const link = props.preset ?? form.entity;
const link = props.preset;
const body: CreateRoutineTaskRequest = {
title: form.title.trim(),
description: form.description.trim() || null,
priority: form.priority,
categoryId: form.categoryId || null,
assigneeId: form.assigneeId || null,
isMandatory: form.isMandatory,
isMandatory: link ? false : form.isMandatory,
source: sourceFor(link?.type),
scheduledFor: form.date || todayKey(),
dueAt: form.time ? localInputToIso(form.date || todayKey(), form.time) : null,
@@ -182,7 +180,7 @@ async function submit() {
<a-form-item label="عنوان" required>
<a-input
v-model:value="form.title"
placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور"
placeholder="مثلاً: آماده‌سازی گزارش هفتگی تیم"
:maxlength="200"
/>
</a-form-item>
@@ -229,16 +227,20 @@ async function submit() {
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="اتصال به پرونده">
<a-space v-if="preset" :size="8">
<a-col v-if="preset" :span="24">
<a-form-item label="پرونده">
<a-space :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
<a-typography-text strong>{{ preset.label }}</a-typography-text>
</a-space>
<EntityLinkPicker v-else v-model="form.entity" />
</a-form-item>
<a-alert
type="info"
show-icon
message="این کار روی همین پرونده می‌ماند و در برنامه روزانه کارهای روتین دیده نمی‌شود."
/>
</a-col>
<a-col :span="24">
<a-col v-else :span="24">
<a-checkbox v-model:checked="form.isMandatory">
کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)
</a-checkbox>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
@@ -19,7 +18,14 @@ import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import {
ROUTINE_TASK_STATUS_LABELS,
apiError,
formatDayNumeric,
formatTime,
hasExplicitTime,
isOpenStatus,
} from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
@@ -38,7 +44,6 @@ const props = withDefaults(
},
);
const router = useRouter();
const auth = useAuthStore();
const enabled = computed(() => {
@@ -51,6 +56,7 @@ const loading = ref(false);
const error = ref('');
const showClosed = ref(false);
const quickOpen = ref(false);
const detail = ref<RoutineTaskDto | null>(null);
async function load() {
if (!enabled.value || !props.entity.id) return;
@@ -73,6 +79,7 @@ watch(() => `${props.entity.type}:${props.entity.id}`, load, { immediate: true }
const actions = useRoutineTaskActions((updated) => {
const i = items.value.findIndex((t) => t.id === updated.id);
if (i >= 0) items.value[i] = updated;
if (detail.value?.id === updated.id) detail.value = updated;
});
function onAction(kind: RoutineTaskRowAction, task: RoutineTaskDto) {
@@ -88,7 +95,7 @@ const open = computed(() => items.value.filter((t) => isOpenStatus(t.status)));
const closed = computed(() => items.value.filter((t) => !isOpenStatus(t.status)));
function openTask(t: RoutineTaskDto) {
void router.push({ path: '/routines', query: { task: t.id } });
detail.value = t;
}
function onCreated(t: RoutineTaskDto) {
@@ -182,5 +189,27 @@ function onCreated(t: RoutineTaskDto) {
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
<a-drawer :open="detail != null" title="جزئیات کار" @close="detail = null">
<template v-if="detail">
<a-typography-paragraph v-if="detail.description">{{ detail.description }}</a-typography-paragraph>
<a-descriptions :column="1">
<a-descriptions-item label="وضعیت">
{{ ROUTINE_TASK_STATUS_LABELS[detail.status] }}
</a-descriptions-item>
<a-descriptions-item label="مسئول">
{{ detail.assignee?.fullName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="روز برنامه">
{{ formatDayNumeric(detail.scheduledFor) }}
</a-descriptions-item>
<a-descriptions-item label="مهلت">
{{ detail.dueAt && hasExplicitTime(detail) ? formatTime(detail.dueAt) : 'تا پایان روز' }}
</a-descriptions-item>
</a-descriptions>
<a-typography-paragraph v-if="detail.resultNote" :style="{ marginTop: '12px' }">
{{ detail.resultNote }}
</a-typography-paragraph>
</template>
</a-drawer>
</a-card>
</template>
@@ -22,6 +22,8 @@ import {
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
@@ -97,8 +99,15 @@ const actions = useRoutineTaskActions((updated) => {
});
const isOpen = computed(() => (task.value ? isOpenStatus(task.value.status) : false));
const customerTask = computed(() => {
const t = task.value;
if (!t) return false;
return (t.source !== 'routine' && t.source !== 'manual') || t.entities.length > 0;
});
const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
function startEdit() {
@@ -190,10 +199,13 @@ function eventText(ev: RoutineTaskEventDto): string {
</div>
<h3 class="td-title" :class="{ done }">{{ task.title }}</h3>
<p v-if="task.description" class="td-desc">{{ task.description }}</p>
<p v-if="customerTask" class="note">
این کار مربوط به پرونده است و در برنامه روزانه کارهای روتین نیست.
</p>
</section>
<!-- اکشن اصلی -->
<section v-if="!editing" class="td-actions">
<section v-if="!editing && !customerTask" class="td-actions">
<UiButton
v-if="isOpen"
variant="success"
@@ -268,7 +280,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<JalaliDateInput v-model="edit.time" mode="time" />
</UiField>
</template>
<UiField v-if="canManage" label="مسئول" class="span-2">
<UiField v-if="canManage && canAssignOthers" label="مسئول" class="span-2">
<UiSelect v-model="edit.assigneeId">
<option v-for="u in shell?.context.value?.users ?? []" :key="u.id" :value="u.id">
{{ u.fullName }}
@@ -370,7 +382,7 @@ function eventText(ev: RoutineTaskEventDto): string {
</section>
</div>
<template v-if="task && !editing" #footer>
<template v-if="task && !editing && !customerTask" #footer>
<UiButton variant="ghost" size="sm" @click="startEdit">
<template #icon><UiIcon name="pencil" :size="14" /></template>
ویرایش
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Col as ACol,
Empty as AEmpty,
List as AList,
Row as ARow,
Statistic as AStatistic,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
const { Paragraph: ATypographyParagraph, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import type {
SalesFactorOverviewDto,
SalesMovementStatsDto,
SalesNamedMovementDto,
TahesabSalesOverviewDto,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
const props = defineProps<{
overview: TahesabSalesOverviewDto | null;
showInternet?: boolean;
}>();
function formatNum(n: number | null | undefined) {
if (n == null || Number.isNaN(n)) return '—';
return toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n));
}
function formatMali(n: number | null | undefined) {
if (n == null) return '—';
return formatTahesabBedBes(n, 0);
}
function formatRial(n: number | null | undefined) {
if (n == null) return '—';
return `${toFaDigits(new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 0 }).format(Math.abs(n)))} ریال`;
}
type GroupRow = {
id: string;
label: string;
netSaleWeight: number;
saleReturnWeight: number;
netSaleMali: number;
children?: GroupRow[];
};
const groupRows = computed<GroupRow[]>(() =>
(props.overview?.groups ?? []).map((group) => ({
id: group.id,
label: group.label,
netSaleWeight: group.netSaleWeight,
saleReturnWeight: group.saleReturnWeight,
netSaleMali: group.netSaleMali,
children: group.children.length
? group.children.map((child) => ({
id: child.id,
label: child.label,
netSaleWeight: child.netSaleWeight,
saleReturnWeight: child.saleReturnWeight,
netSaleMali: child.netSaleMali,
}))
: undefined,
})),
);
const groupColumns: TableColumnsType<GroupRow> = [
{ title: 'گروه', dataIndex: 'label', key: 'label', ellipsis: true },
{
title: 'فروش (گرم)',
key: 'weight',
width: 120,
customRender: ({ record }) => formatNum(record.netSaleWeight),
},
{
title: 'مرجوعی (گرم)',
key: 'return',
width: 120,
customRender: ({ record }) => formatNum(record.saleReturnWeight),
},
{
title: 'مبلغ',
key: 'mali',
width: 160,
customRender: ({ record }) => formatRial(record.netSaleMali),
},
];
function percentText(stats: SalesMovementStatsDto, empty = 'درصدی ثبت نشده') {
const p = stats.percent;
if (!p || p.avg == null) return empty;
return `اول ${formatNum(p.first)} · آخر ${formatNum(p.last)} · میانگین ${formatNum(p.avg)} · کم ${formatNum(p.min)} · بیش ${formatNum(p.max)}`;
}
function percentShort(stats: SalesMovementStatsDto) {
const p = stats.percent;
if (!p || p.avg == null) return '—';
return `میانگین ${formatNum(p.avg)} · از ${formatNum(p.min)} تا ${formatNum(p.max)}`;
}
const factors = computed(() =>
(props.overview?.factors ?? []).filter((f) => f.key !== 'other' || f.sale.grossWeight > 0 || f.purchase.grossWeight > 0),
);
const factorColumns = computed<TableColumnsType<SalesFactorOverviewDto>>(() => {
const cols: TableColumnsType<SalesFactorOverviewDto> = [
{
title: 'بخش کالا',
dataIndex: 'label',
key: 'label',
fixed: 'right',
width: 150,
customRender: ({ record }) => (record.key === 'other' ? 'سایر عنوان‌ها' : record.label),
},
{
title: 'وزن فروش (گرم)',
key: 'saleWeight',
customRender: ({ record }) => formatNum(record.sale.netWeight),
},
{
title: 'مرجوعی فروش (گرم)',
key: 'saleReturn',
customRender: ({ record }) => formatNum(record.sale.returnWeight),
},
{
title: 'مبلغ فروش (ریال)',
key: 'saleMali',
customRender: ({ record }) => formatRial(record.sale.netMali),
},
{
title: 'وزن خرید (گرم)',
key: 'buyWeight',
customRender: ({ record }) => formatNum(record.purchase.netWeight),
},
{
title: 'مرجوعی خرید (گرم)',
key: 'buyReturn',
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
},
{
title: 'مبلغ خرید (ریال)',
key: 'buyMali',
customRender: ({ record }) => formatRial(record.purchase.netMali),
},
{
title: 'تعداد فروش',
key: 'saleCount',
width: 110,
customRender: ({ record }) =>
record.key === 'bullion' ? formatNum(record.sale.netCount) : '—',
},
];
if (props.overview?.percentReady) {
cols.push(
{
title: 'درصد فروش',
key: 'salePercent',
customRender: ({ record }) => percentShort(record.sale),
},
{
title: 'درصد خرید',
key: 'buyPercent',
customRender: ({ record }) => percentShort(record.purchase),
},
);
}
return cols;
});
const karColumns: TableColumnsType<SalesNamedMovementDto> = [
{ title: 'نام کالا', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: 'وزن فروش (گرم)',
key: 'saleNet',
customRender: ({ record }) => formatNum(record.sale.netWeight),
},
{
title: 'مرجوعی فروش (گرم)',
key: 'saleRet',
customRender: ({ record }) => formatNum(record.sale.returnWeight),
},
{
title: 'درصد خروج',
key: 'salePct',
customRender: ({ record }) => percentText(record.sale),
},
{
title: 'وزن خرید (گرم)',
key: 'buyNet',
customRender: ({ record }) => formatNum(record.purchase.netWeight),
},
{
title: 'مرجوعی خرید (گرم)',
key: 'buyRet',
customRender: ({ record }) => formatNum(record.purchase.returnWeight),
},
{
title: 'درصد ورود',
key: 'buyPct',
customRender: ({ record }) => percentText(record.purchase),
},
];
</script>
<template>
<a-row v-if="overview" :gutter="[16, 16]">
<a-col v-if="!overview.percentReady" :span="24">
<a-alert
type="info"
show-icon
message="درصد ورود و خروج بعد از یک به‌روزرسانی تازه از ته‌حساب پر می‌شود. وزن و مرجوعی با سندهای فعلی حساب شده‌اند."
/>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="نتیجهٔ این بازه">
<a-typography-paragraph type="secondary">
هر ردیف یک بخش کالاست. وزن فروش، گرم فروخته‌شده بعد از کم شدن مرجوعی همان بازه است.
مبلغ، ریال همان فروش است. تعداد فقط برای شمش و سکه معنی دارد.
«سایر عنوان‌ها» سندهایی است که در نامشان کارساخته، اتیکت، شمش یا سکه نبوده.
</a-typography-paragraph>
<a-table
:columns="factorColumns"
:data-source="factors"
row-key="key"
:pagination="false"
:scroll="{ x: 1100 }"
/>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="ریز کارساخته، به تفکیک نام کالا">
<a-table
:columns="karColumns"
:data-source="overview.karsakhte"
row-key="name"
:pagination="{ pageSize: 8, showSizeChanger: false }"
:scroll="{ x: 980 }"
>
<template #emptyText>
<a-empty description="در این بازه سند کارساخته نیست." />
</template>
</a-table>
</a-card>
</a-col>
<a-col :span="24">
<a-card :bordered="false" title="فروش به تفکیک گروه">
<a-typography-paragraph type="secondary">
عدد گروه اصلی، فروش خودش و زیرگروه‌ها را با هم نشان می‌دهد.
</a-typography-paragraph>
<a-table
:columns="groupColumns"
:data-source="groupRows"
row-key="id"
default-expand-all-rows
:pagination="groupRows.length > 6 ? { pageSize: 6, showSizeChanger: false } : false"
:scroll="{ x: 560 }"
>
<template #emptyText>
<a-empty description="گروهی در این بازه فروش ندارد." />
</template>
</a-table>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :bordered="false" title="پورسانت کارشناس">
<a-typography-paragraph type="secondary">
{{
showInternet
? 'کارساختهٔ نقره جدا از سرمایه‌گذاری (اتیکت جواهر و فروش اینترنتی) حساب می‌شود.'
: 'کارساختهٔ نقره جدا از سرمایه‌گذاری (اتیکت جواهر) حساب می‌شود.'
}}
</a-typography-paragraph>
<a-list
:data-source="overview.experts"
item-layout="vertical"
:locale="{ emptyText: 'کارشناسی برای این بازه نیست.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.name" :description="`درصد ${formatNum(item.percent)}`" />
<div>
کارساخته نقره: وزن {{ formatNum(item.silverWeight) }} · پورسانت
{{ formatMali(item.silverCommission) }}
</div>
<div>
سرمایه‌گذاری: وزن {{ formatNum(item.investWeight) }} · پورسانت
{{ formatMali(item.investCommission) }}
</div>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<a-col v-if="showInternet" :span="24">
<a-card :bordered="false" title="فروش اینترنتی (مونتیفای)">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="8">
<a-statistic title="سفارش" :value="formatNum(overview.internet.orderCount)" />
</a-col>
<a-col :xs="24" :sm="8">
<a-statistic title="خالص نقره" :value="formatNum(overview.internet.netSilver)" />
</a-col>
<a-col :xs="24" :sm="8">
<a-statistic title="خالص طلا" :value="formatNum(overview.internet.netGold)" />
</a-col>
<a-col :span="24">
<a-typography-text type="secondary">
مبلغ {{ formatMali(overview.internet.netMali) }} · لغوشده
{{ formatNum(overview.internet.returnCount) }} سفارش، نقره
{{ formatNum(overview.internet.returnSilver) }} ·
{{ formatNum(overview.internet.linkedSkipped) }} سفارش چون در ته‌حساب سند دارد اینجا دوباره نیامده
</a-typography-text>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</template>
+10 -1
View File
@@ -20,6 +20,10 @@ const pageTitle = computed(() => {
return 'محیط‌ها';
case 'adm-users':
return 'کاربران';
case 'adm-chat':
return 'گفتگو';
case 'adm-billing':
return 'اشتراک';
case 'adm-admins':
return 'ادمین‌ها';
case 'adm-sms':
@@ -33,6 +37,8 @@ const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
{ name: 'adm-chat', label: 'گفتگو', icon: 'chat' as const },
{ name: 'adm-billing', label: 'اشتراک', icon: 'crown' as const },
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
{ name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const },
];
@@ -372,7 +378,10 @@ function onLogout() {
position: sticky;
bottom: 0;
z-index: 30;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: minmax(4.4rem, 1fr);
overflow-x: auto;
gap: 0.15rem;
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
+69
View File
@@ -0,0 +1,69 @@
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
import { compressVoiceFile } from '@/lib/voice-note';
export const ACTIVITY_FILE_MAX_BYTES = 10 * 1024 * 1024;
const FILE_MIME_BY_EXT: Record<string, string> = {
pdf: 'application/pdf',
txt: 'text/plain',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
zip: 'application/zip',
};
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
const AUDIO_EXT = new Set(['webm', 'ogg', 'mp3', 'm4a', 'aac', 'wav', 'mpeg']);
export type PreparedAttachment = {
kind: 'image' | 'audio' | 'file';
name: string;
file: File;
previewUrl: string;
};
function extensionOf(name: string): string {
return name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') ?? '';
}
function kindOf(file: File): 'image' | 'audio' | 'file' {
const type = file.type.toLowerCase().split(';')[0] ?? '';
const ext = extensionOf(file.name);
if (type.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image';
if (type.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio';
return 'file';
}
export async function prepareActivityAttachment(file: File): Promise<PreparedAttachment> {
const kind = kindOf(file);
if (kind === 'image') {
try {
const prepared = await prepareFormFieldFile(file);
return { kind, name: prepared.name, file: prepared.file, previewUrl: prepared.dataUrl };
} catch (error) {
if (error instanceof FormFileTooLargeError) {
throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
}
throw new Error('فشرده‌سازی عکس ناموفق بود.');
}
}
if (kind === 'audio') {
const compressed = await compressVoiceFile(file);
return {
kind,
name: compressed.name,
file: compressed,
previewUrl: URL.createObjectURL(compressed),
};
}
const ext = extensionOf(file.name);
const mime = FILE_MIME_BY_EXT[ext];
if (!mime) throw new Error('این نوع فایل مجاز نیست.');
if (file.size > ACTIVITY_FILE_MAX_BYTES) throw new Error('حجم فایل باید زیر ۱۰ مگابایت باشد');
const named = file.type === mime ? file : new File([file], file.name, { type: mime });
return { kind: 'file', name: named.name, file: named, previewUrl: '' };
}
+20
View File
@@ -0,0 +1,20 @@
import {
ASSIGN_SECTION_PERMISSION,
type AssignSection,
type RolePermissionKey,
} from '@mento/shared';
export function canAssignToOthers(
user:
| {
role?: string | null;
permissions?: Array<RolePermissionKey | string> | null;
}
| null
| undefined,
section: AssignSection,
): boolean {
if (!user) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
return (user.permissions ?? []).includes(ASSIGN_SECTION_PERMISSION[section]);
}
+53
View File
@@ -0,0 +1,53 @@
import { ref } from 'vue';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { api } from '@/lib/api';
export const CUSTOMER_GROUP_COLOR_LABELS: Record<CustomerGroupColor, string> = {
teal: 'سبزآبی',
sky: 'آبی',
violet: 'بنفش',
rose: 'قرمز',
amber: 'نارنجی',
pink: 'صورتی',
slate: 'خاکستری',
};
/** رنگ متن/پس‌زمینهٔ چیپ گروه (پالت داده، نه رنگ‌های قالب) */
export const CUSTOMER_GROUP_SWATCH: Record<CustomerGroupColor, { fg: string; bg: string }> = {
teal: { fg: '#0f766e', bg: '#ccfbf1' },
sky: { fg: '#0369a1', bg: '#e0f2fe' },
violet: { fg: '#6d28d9', bg: '#ede9fe' },
rose: { fg: '#be123c', bg: '#ffe4e6' },
amber: { fg: '#b45309', bg: '#fef3c7' },
pink: { fg: '#be185d', bg: '#fce7f3' },
slate: { fg: '#334155', bg: '#e2e8f0' },
};
export function groupSwatch(color: string | null | undefined) {
return CUSTOMER_GROUP_SWATCH[(color ?? 'teal') as CustomerGroupColor] ?? CUSTOMER_GROUP_SWATCH.teal;
}
const groups = ref<CustomerGroupDto[]>([]);
const loaded = ref(false);
let pending: Promise<CustomerGroupDto[]> | null = null;
/** فهرست گروه‌های مشتری محیط (مشترک بین صفحه‌ها) */
export function useCustomerGroups() {
async function load(force = false): Promise<CustomerGroupDto[]> {
if (loaded.value && !force) return groups.value;
if (pending && !force) return pending;
pending = api
.get<CustomerGroupDto[]>('/customer-groups')
.then(({ data }) => {
groups.value = data;
loaded.value = true;
return data;
})
.finally(() => {
pending = null;
});
return pending;
}
return { groups, loaded, load };
}
+146
View File
@@ -0,0 +1,146 @@
/** سقف حجم فایل فیلد فرم عمومی پس از فشرده‌سازی */
export const FORM_FILE_MAX_BYTES = 200 * 1024;
export class FormFileTooLargeError extends Error {
constructor(message = 'حجم فایل زیاد است') {
super(message);
this.name = 'FormFileTooLargeError';
}
}
function toBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('فشرده‌سازی تصویر ناموفق بود.'))),
type,
quality,
);
});
}
function fileToDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('خواندن فایل ناموفق بود.'));
reader.readAsDataURL(file);
});
}
async function compressImageUnder(file: File, maxBytes: number): Promise<File> {
if (file.size <= maxBytes) return file;
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
throw new FormFileTooLargeError();
}
let edge = Math.max(bitmap.width, bitmap.height);
edge = Math.min(edge, 1600);
let quality = 0.85;
let best: File | null = null;
try {
for (let attempt = 0; attempt < 14; attempt += 1) {
const ratio = Math.min(edge / bitmap.width, edge / bitmap.height, 1);
const width = Math.max(1, Math.round(bitmap.width * ratio));
const height = Math.max(1, Math.round(bitmap.height * ratio));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new FormFileTooLargeError();
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, 0, 0, width, height);
let blob: Blob;
try {
blob = await toBlob(canvas, 'image/jpeg', quality);
} catch {
blob = await toBlob(canvas, 'image/webp', quality);
}
const base = file.name.replace(/\.[^/.]+$/, '') || 'file';
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
const out = new File([blob], `${base}.${ext}`, {
type: blob.type,
lastModified: Date.now(),
});
if (!best || out.size < best.size) best = out;
if (out.size <= maxBytes) return out;
if (quality > 0.45) {
quality = Math.max(0.4, quality - 0.1);
} else {
quality = 0.72;
edge = Math.round(edge * 0.72);
if (edge < 320) break;
}
}
} finally {
bitmap.close();
}
if (best && best.size <= maxBytes) return best;
throw new FormFileTooLargeError();
}
/**
* فایل را تا سقف ۲۰۰ کیلوبایت فشرده می‌کند (تصاویر با کاهش کیفیت/ابعاد).
* اگر ممکن نباشد، خطای «حجم فایل زیاد است» می‌دهد.
*/
export async function prepareFormFieldFile(file: File): Promise<{
name: string;
mime: string;
size: number;
dataUrl: string;
file: File;
}> {
let prepared = file;
if (file.type.startsWith('image/')) {
prepared = await compressImageUnder(file, FORM_FILE_MAX_BYTES);
} else if (file.size > FORM_FILE_MAX_BYTES) {
throw new FormFileTooLargeError();
}
if (prepared.size > FORM_FILE_MAX_BYTES) {
throw new FormFileTooLargeError();
}
const dataUrl = await fileToDataUrl(prepared);
return {
name: prepared.name || file.name || 'file',
mime: prepared.type || file.type || 'application/octet-stream',
size: prepared.size,
dataUrl,
file: prepared,
};
}
/** مقدار ذخیره‌شده در پاسخ فرم: نام + data URL */
export function encodeFormFileValue(meta: {
name: string;
mime: string;
dataUrl: string;
}): string {
return JSON.stringify({
name: meta.name,
mime: meta.mime,
dataUrl: meta.dataUrl,
});
}
export function formFileDisplayName(raw: string | undefined | null): string {
if (!raw) return '';
try {
const parsed = JSON.parse(raw) as { name?: string };
if (parsed?.name) return parsed.name;
} catch {
/* plain string */
}
if (raw.startsWith('data:')) return 'فایل پیوست';
return raw.length > 48 ? `${raw.slice(0, 48)}…` : raw;
}
+132
View File
@@ -0,0 +1,132 @@
import { formatJalaliNumeric } from '@/lib/jalali';
export type LeadNoteFile = {
name: string;
mime: string;
dataUrl: string | null;
};
export type LeadNoteRow = {
label: string;
text: string;
file: LeadNoteFile | null;
};
export type LeadNoteView = {
mode: 'prose' | 'answers';
prose: string;
rows: LeadNoteRow[];
};
const UTM_LABELS: Record<string, string> = {
utm_source: 'منبع',
utm_medium: 'رسانه',
utm_campaign: 'کمپین',
utm_term: 'عبارت',
utm_content: 'محتوا',
صفحه: 'صفحه',
ارجاع: 'ارجاع',
};
/** یادداشت لید را اگر جواب فرم باشد به ردیف سؤال/جواب تبدیل می‌کند. */
export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView {
const text = (raw ?? '').trim();
if (!text) return { mode: 'prose', prose: '', rows: [] };
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const rows: LeadNoteRow[] = [];
const prose: string[] = [];
for (const line of lines) {
const row = parseAnswerLine(line);
if (row) rows.push(row);
else prose.push(line);
}
const answersAreMain = rows.length >= 2 && rows.length >= prose.length;
if (!answersAreMain) return { mode: 'prose', prose: text, rows: [] };
return { mode: 'answers', prose: prose.join('\n'), rows };
}
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
}
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
export function isBirthDateAnswer(label: string): boolean {
return label.trim() === 'تاریخ تولد';
}
function parseAnswerLine(line: string): LeadNoteRow | null {
const splitAt = line.indexOf(':');
if (splitAt <= 0 || splitAt > 200) return null;
const label = line.slice(0, splitAt).trim();
const value = line.slice(splitAt + 1).trim();
if (!label || !value || looksLikeUrl(label)) return null;
if (label === 'UTM') {
return { label: 'اطلاعات ورود', text: humanizeUtm(value), file: null };
}
const file = readFileValue(value);
if (file) return { label, text: file.name, file };
return { label, text: humanizePlain(value), file: null };
}
function looksLikeUrl(label: string): boolean {
return /^https?:\/\//i.test(label);
}
function humanizePlain(value: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const date = new Date(`${value}T12:00:00`);
if (!Number.isNaN(date.getTime())) return formatJalaliNumeric(date);
}
if (value === 'true') return 'بله';
if (value === 'false') return 'خیر';
return value;
}
function humanizeUtm(value: string): string {
return value
.split('|')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return part;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
return `${UTM_LABELS[key] ?? key}: ${val}`;
})
.join('، ');
}
function readFileValue(value: string): LeadNoteFile | null {
if (!value.startsWith('{') && !value.includes('"dataUrl"') && !value.includes('"name"')) {
return null;
}
try {
return asFile(JSON.parse(value));
} catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
if (name) return { name, mime: '', dataUrl: null };
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null;
}
}
function asFile(value: unknown): LeadNoteFile | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const row = value as { name?: unknown; mime?: unknown; dataUrl?: unknown };
const name = typeof row.name === 'string' ? row.name.trim() : '';
const mime = typeof row.mime === 'string' ? row.mime.trim() : '';
const dataUrl = typeof row.dataUrl === 'string' && row.dataUrl.startsWith('data:') ? row.dataUrl : null;
if (!name && !dataUrl) return null;
return {
name: name || 'فایل پیوست',
mime: mime || (dataUrl?.startsWith('data:image/') ? 'image/' : ''),
dataUrl,
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { RolePermissionKey, UserRole } from '@mento/shared';
/** مسیر صفحه ← مجوز بخش؛ هم‌تراز SectionPermissionGuard در API */
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
['/contacts', 'customers'],
['/leads', 'leads'],
['/pipeline', 'sales'],
['/deals', 'sales'],
['/sales/', 'sales'],
['/settings/pipelines', 'sales'],
['/invoices', 'billing'],
['/quotes', 'billing'],
['/settings/products', 'products'],
['/inventory', 'products'],
['/activities', 'activities'],
['/notes', 'notes'],
['/attachments', 'notes'],
['/reports', 'reports'],
['/settings/audit-logs', 'audit'],
];
export function sectionPermissionForPath(path: string): RolePermissionKey | null {
const clean = path.split(/[?#]/)[0] ?? path;
for (const [prefix, key] of PATH_PERMISSIONS) {
if (clean === prefix || clean.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)) {
return key;
}
}
return null;
}
/** مالک و مدیر همیشه؛ بقیه با تیک همان بخش در نقششان */
export function hasSectionPermission(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
key: RolePermissionKey,
): boolean {
if (role === 'owner' || role === 'admin') return true;
return Boolean(permissions?.includes(key));
}
export function canOpenPath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
path: string,
): boolean {
const key = sectionPermissionForPath(path);
return !key || hasSectionPermission(role, permissions, key);
}
+760
View File
@@ -0,0 +1,760 @@
import type { TahesabInvoiceSettingsDto } from '@mento/shared';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { formatTahesabBedBes } from '@/lib/tahesab';
export type TahesabInvoiceGroup = {
no: string;
date: string;
rows: Record<string, unknown>[];
};
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function dashInvoice(v: unknown, fallback = '—'): string {
if (v == null) return fallback;
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return fallback;
return s;
}
export function fmtInvoiceNum(v: unknown): string {
const s = dashInvoice(v, '');
if (!s) return '—';
const n = Number(String(s).replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
return toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 3 }).format(n),
);
}
function invoicePenCount(r: Record<string, unknown>): number {
const s = dashInvoice(r.Count, '');
if (!s) return 1;
const n = Number(String(s).replace(/,/g, ''));
if (!Number.isFinite(n) || n <= 0) return 1;
return Math.round(n);
}
export function rowInvoiceDesc(r: Record<string, unknown>): string {
const parts = [
dashInvoice(r.NO, ''),
dashInvoice(r.No_Kar, ''),
dashInvoice(r.Sharh1, ''),
].filter((p) => p && p !== '—');
const base = parts.join(' · ') || '—';
const pens = invoicePenCount(r);
return `${base} (${toFaDigits(String(pens))} قلم)`;
}
/** اولین مقدار غیرخالی از کلیدها (رشتهٔ "null" ته‌حساب نادیده گرفته می‌شود؛ صفر معتبر است) */
export function pickInvoiceField(
r: Record<string, unknown>,
...keys: string[]
): unknown {
for (const k of keys) {
const raw = r[k];
if (raw == null) continue;
const s = String(raw).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') continue;
return raw;
}
return null;
}
/** درصد اجرت/سود ردیف سند */
export function pickInvoiceDarsad(r: Record<string, unknown>): unknown {
return pickInvoiceField(r, 'DarsadSood', 'DarsadVazni');
}
export function pickInvoiceMazaneh(r: Record<string, unknown>): unknown {
return pickInvoiceField(r, 'Mazaneh', 'Fi');
}
export function sumInvoiceField(
rows: Record<string, unknown>[],
key: string,
): number {
let t = 0;
for (const r of rows) {
const raw = pickInvoiceField(r, key);
if (raw == null) continue;
const n = Number(String(raw).replace(/,/g, ''));
if (Number.isFinite(n)) t += n;
}
return t;
}
export function sumInvoiceMali(rows: Record<string, unknown>[]): number {
return sumInvoiceField(rows, 'Mali');
}
/** 0=طلا، 2=نقره، null=نامشخص */
export function invoiceRowMetal(
r: Record<string, unknown>,
): 'gold' | 'silver' | null {
const jRaw = r.Jens_Felez;
const j =
jRaw == null || String(jRaw).trim() === '' || String(jRaw) === 'null'
? NaN
: Number(jRaw);
if (j === 0) return 'gold';
if (j === 2) return 'silver';
const no = dashInvoice(r.NO, '');
if (no.includes('نقره')) return 'silver';
if (no.includes('طلا')) return 'gold';
const ayar = Number(dashInvoice(r.Ayar, ''));
if (Number.isFinite(ayar)) {
if (ayar >= 900) return 'silver';
if (ayar >= 700 && ayar < 900) return 'gold';
}
return null;
}
export function invoiceMetalCounts(rows: Record<string, unknown>[]): {
gold: number;
silver: number;
} {
let gold = 0;
let silver = 0;
for (const r of rows) {
const m = invoiceRowMetal(r);
if (m === 'gold') gold += 1;
else if (m === 'silver') silver += 1;
}
return { gold, silver };
}
/**
* فلزهای وزنی سند برای برچسب مثقال — ردیف‌های صرفاً مالی (وزن ۰) نادیده گرفته می‌شوند
* چون Jens_Felez آن‌ها اغلب طلا (۰) است.
*/
export function invoiceMesghalMetals(rows: Record<string, unknown>[]): {
gold: boolean;
silver: boolean;
} {
let gold = false;
let silver = false;
for (const r of rows) {
if (numOrZero(r.Vazn) === 0 && numOrZero(r.TabdilVazn) === 0) continue;
const m = invoiceRowMetal(r);
if (m === 'gold') gold = true;
else if (m === 'silver') silver = true;
}
return { gold, silver };
}
/** برچسب‌های خط خالی مثقال برای نوشتن دستی روی فاکتور */
export function invoiceMesghalLabels(rows: Record<string, unknown>[]): string[] {
const { gold, silver } = invoiceMesghalMetals(rows);
const labels: string[] = [];
if (gold) labels.push('قیمت یک مثقال طلا:');
if (silver) labels.push('قیمت یک مثقال نقره خام:');
return labels;
}
/**
* عنوان ستون وزن تبدیلی — در ته‌حساب معمولاً نقره به ۹۲۵ و طلا به ۷۵۰ تبدیل می‌شود.
* سند مخلوط → «وزن تبدیلی».
*/
export function tabdilVaznColumnLabel(
rows: Record<string, unknown>[],
): string {
const { gold, silver } = invoiceMetalCounts(rows);
if (gold > 0 && silver > 0) return 'وزن تبدیلی';
if (silver > 0) return 'وزن ۹۲۵';
if (gold > 0) return 'وزن ۷۵۰';
return 'وزن تبدیلی';
}
/** جمع یک فیلد وزنی به تفکیک فلز — طلا و نقره با هم جمع نمی‌شوند */
export function sumWeightByMetal(
rows: Record<string, unknown>[],
key: 'Vazn' | 'TabdilVazn',
): { gold: number; silver: number; mixed: boolean } {
let gold = 0;
let silver = 0;
for (const r of rows) {
const m = invoiceRowMetal(r);
const n = numOrZero(r[key]);
if (m === 'gold') gold += n;
else if (m === 'silver') silver += n;
}
const hasGold = gold !== 0;
const hasSilver = silver !== 0;
return { gold, silver, mixed: hasGold && hasSilver };
}
/** نمایش جمع وزنی؛ در سند مخلوط طلا و نقره جدا (با بد/بس برای وزن تبدیلی) */
export function formatInvoiceWeightBalance(
rows: Record<string, unknown>[],
key: 'Vazn' | 'TabdilVazn' = 'TabdilVazn',
signed = true,
): string {
const { gold, silver, mixed } = sumWeightByMetal(rows, key);
const fmt = (n: number) =>
signed ? formatTahesabBedBes(n) : fmtInvoiceNum(n);
if (mixed) {
const parts: string[] = [];
if (gold !== 0) parts.push(`طلا ${fmt(gold)}`);
if (silver !== 0) parts.push(`نقره ${fmt(silver)}`);
return parts.join(' · ') || '—';
}
if (gold !== 0) return fmt(gold);
if (silver !== 0) return fmt(silver);
return fmt(sumInvoiceField(rows, key));
}
function numOrZero(v: unknown): number {
const s = dashInvoice(v, '');
if (!s) return 0;
const n = Number(String(s).replace(/,/g, ''));
return Number.isFinite(n) ? n : 0;
}
/**
* فقط برای خروجی فاکتور PDF/پیش‌نمایش: ردیف‌های هم‌نام (همان شرح کار)
* با عیار و مظنه/فی و درصد یکسان را در یک ردیف جمع می‌زند.
* کارت اسناد دست‌نخورده می‌ماند.
*/
export function aggregateSameNameInvoiceRows(
rows: Record<string, unknown>[],
): Record<string, unknown>[] {
const map = new Map<string, Record<string, unknown>>();
const order: string[] = [];
for (const r of rows) {
const name = dashInvoice(r.No_Kar, '') || dashInvoice(r.NO, '') || '—';
const key = [
dashInvoice(r.NO, ''),
name,
dashInvoice(r.Ayar, ''),
dashInvoice(pickInvoiceMazaneh(r), ''),
dashInvoice(pickInvoiceDarsad(r), ''),
].join('|');
const existing = map.get(key);
if (!existing) {
map.set(key, {
...r,
No_Kar: name === '—' ? r.No_Kar : name,
Count: numOrZero(r.Count) || 1,
Vazn: numOrZero(r.Vazn),
TabdilVazn: numOrZero(r.TabdilVazn),
Mali: numOrZero(r.Mali),
});
order.push(key);
continue;
}
existing.Count = numOrZero(existing.Count) + (numOrZero(r.Count) || 1);
existing.Vazn = numOrZero(existing.Vazn) + numOrZero(r.Vazn);
existing.TabdilVazn =
numOrZero(existing.TabdilVazn) + numOrZero(r.TabdilVazn);
existing.Mali = numOrZero(existing.Mali) + numOrZero(r.Mali);
}
return order.map((k, i) => {
const row = map.get(k)!;
return { ...row, Radif: i + 1 };
});
}
const dash = dashInvoice;
const fmtNum = fmtInvoiceNum;
const rowDesc = rowInvoiceDesc;
const sumMali = sumInvoiceMali;
/** خروجی فاکتور سند ته‌حساب — چاپ / ذخیره PDF از دیالوگ مرورگر */
export function printTahesabInvoicePdf(opts: {
group: TahesabInvoiceGroup;
settings: TahesabInvoiceSettingsDto | null;
customerName: string;
customerPhone?: string | null;
moshtariCode?: number | null;
priorWeight?: number | null;
priorRial?: number | null;
finalWeight?: number | null;
finalRial?: number | null;
/** پنجره‌ای که هم‌زمان با کلیک کاربر باز شده (برای دور زدن بلاک پاپ‌آپ) */
targetWindow?: Window | null;
}): boolean {
const {
group,
settings,
customerName,
customerPhone,
moshtariCode,
priorWeight,
priorRial,
finalWeight,
finalRial,
targetWindow,
} = opts;
const first = group.rows[0] ?? {};
const temporary = first.isSanadMovaghat === true;
const invoiceRows = aggregateSameNameInvoiceRows(group.rows);
const total = sumMali(invoiceRows);
const totalVaznLabel = formatInvoiceWeightBalance(group.rows, 'Vazn', false);
const totalTabdilLabel = formatInvoiceWeightBalance(
group.rows,
'TabdilVazn',
true,
);
const tabdilLabel = tabdilVaznColumnLabel(group.rows);
const generatedAt = formatJalaliDateTime(new Date());
const phoneFa = customerPhone?.trim()
? toFaDigits(customerPhone.trim())
: '—';
const codeFa =
moshtariCode != null ? toFaDigits(String(moshtariCode)) : '—';
// ته‌حساب قیمت مثقال لحظهٔ ثبت را برنمی‌گرداند؛ خط خالی برای نوشتن دستی (بر اساس فلز سند)
const mesghalLabels = invoiceMesghalLabels(group.rows);
const mesghalHtml =
mesghalLabels.length > 0
? `<div class="mesghal-box">${mesghalLabels
.map(
(label) =>
`<div class="mesghal-line"><span class="ib-k">${escapeHtml(label)}</span><span class="mesghal-blank"></span></div>`,
)
.join('')}</div>`
: `<div class="mesghal-box"></div>`;
const accountHtml = `<div class="account-box">
<div class="acc-line">
<span class="acc-title">حساب قبل:</span>
<span class="acc-pair"><span class="ib-k">وزنی</span><strong dir="ltr">${formatTahesabBedBes(priorWeight)}</strong></span>
<span class="acc-pair"><span class="ib-k">ریالی</span><strong dir="ltr">${formatTahesabBedBes(priorRial)}</strong></span>
</div>
<div class="acc-line">
<span class="acc-title">مانده نهایی:</span>
<span class="acc-pair"><span class="ib-k">وزنی</span><strong dir="ltr">${formatTahesabBedBes(finalWeight)}</strong></span>
<span class="acc-pair"><span class="ib-k">ریالی</span><strong dir="ltr">${formatTahesabBedBes(finalRial)}</strong></span>
</div>
</div>`;
const rowsHtml = invoiceRows
.map((r, i) => {
const idx = dash(r.Radif, String(i + 1));
return `<tr>
<td class="col-radif">${toFaDigits(idx)}</td>
<td class="col-desc">${escapeHtml(rowDesc(r))}</td>
<td class="ltr col-darsad">${fmtNum(pickInvoiceDarsad(r))}</td>
<td class="ltr">${fmtNum(r.Ayar)}</td>
<td class="ltr">${fmtNum(r.Vazn)}</td>
<td class="ltr col-tabdil">${formatTahesabBedBes(r.TabdilVazn)}</td>
<td class="ltr">${fmtNum(pickInvoiceMazaneh(r))}</td>
<td class="ltr col-mali">${formatTahesabBedBes(r.Mali)}</td>
</tr>`;
})
.join('');
const logo = settings?.logoDataUrl
? `<img class="logo" src="${escapeHtml(settings.logoDataUrl)}" alt="" />`
: '';
const company = escapeHtml(settings?.companyName?.trim() || 'فاکتور ته‌حساب');
const subtitle = settings?.companySubtitle?.trim()
? `<div class="sub" dir="ltr">${escapeHtml(settings.companySubtitle.trim())}</div>`
: '';
const phone = settings?.phone?.trim()
? `<div class="dim">تلفن: <bdi dir="ltr">${escapeHtml(settings.phone.trim())}</bdi></div>`
: '';
const headerText = settings?.headerText?.trim()
? `<p class="header-text">${escapeHtml(settings.headerText.trim())}</p>`
: '';
const footerText = settings?.footerText?.trim()
? `<p class="note">${escapeHtml(settings.footerText.trim())}</p>`
: '';
const signAddr = escapeHtml(settings?.address?.trim() || '—');
const issuerName = escapeHtml(dash(first.User, '') || '—');
const receiverName = escapeHtml(customerName || '—');
const signRow = `<div class="sign-row">
<div class="sign-col">
<span class="ib-k">صادر کننده سند:</span>
<strong class="sign-name">${issuerName}</strong>
<div class="sign-space"></div>
</div>
<div class="sign-addr">${signAddr}</div>
<div class="sign-col">
<span class="ib-k">گیرنده سند:</span>
<strong class="sign-name">${receiverName}</strong>
<div class="sign-space"></div>
</div>
</div>`;
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<title>فاکتور سند ${escapeHtml(group.no)}</title>
<style>
@page { size: A4; margin: 12mm; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Tahoma, "Segoe UI", sans-serif;
color: #111827;
font-size: 12px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.top {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px 16px;
align-items: center;
margin-bottom: 6px;
border: 1px solid #111827;
border-radius: 10px;
padding: 12px 14px;
}
.top-start { text-align: right; min-width: 0; }
.top-end { text-align: left; min-width: 0; }
.top-center {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.header-text {
margin: 0 0 8px;
white-space: pre-wrap;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.logo { max-height: 110px; max-width: 220px; object-fit: contain; }
.org { font-size: 16px; font-weight: 800; margin: 0; }
.sub {
color: #374151;
margin-top: 8px;
direction: ltr;
unicode-bidi: isolate;
text-align: left;
font-size: 13px;
}
.dim { color: #6b7280; margin-top: 2px; }
.doc-date { font-weight: 800; font-size: 15px; margin-bottom: 4px; }
.badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border: 1px solid #d97706;
color: #b45309;
border-radius: 999px;
font-size: 11px;
}
.party {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-bottom: 14px;
border: 1px solid #111827;
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
}
.party-item { white-space: nowrap; }
.party-phone { margin-inline-start: auto; }
.page-foot { margin-top: 16px; display: flex; flex-direction: column; gap: 10px; }
.note {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
margin: 0;
white-space: pre-wrap;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1.2fr 1fr;
gap: 12px;
align-items: start;
margin-top: 4px;
}
.sign-col {
display: flex;
flex-direction: column;
gap: 4px;
text-align: center;
}
.sign-name { font-size: 13px; font-weight: 800; }
.sign-space {
min-height: 56px;
margin-top: 4px;
border-bottom: 1px dashed #d1d5db;
}
.sign-addr {
color: #6b7280;
font-size: 12px;
text-align: center;
line-height: 1.55;
max-width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding-top: 2px;
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th, td {
padding: 7px 8px;
border: 1px solid #e5e7eb;
text-align: right;
vertical-align: top;
}
th {
background: #f3f4f6;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
.col-radif {
width: 5%;
white-space: nowrap;
padding-inline: 4px;
text-align: center;
vertical-align: middle;
}
.col-desc { width: 32%; word-break: break-word; }
.col-darsad { width: 6%; white-space: nowrap; padding-inline: 4px; }
.col-tabdil { width: 14%; white-space: nowrap; }
.col-mali { width: 13%; white-space: nowrap; }
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
tfoot td {
font-weight: 800;
background: #f9fafb;
vertical-align: middle;
}
.totals-label { text-align: right; }
.weight-sum { font-size: 11px; white-space: nowrap; line-height: 1.35; }
.invoice-balance {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 1fr;
margin-top: 10px;
border: 1px solid #111827;
border-radius: 10px;
overflow: hidden;
font-size: 13px;
background: #f3f4f6;
}
.invoice-balance > div { padding: 8px 12px; }
.invoice-balance > .ib-cell { border-inline-start: 1px solid #e5e7eb; }
.ib-when {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
white-space: nowrap;
}
.ib-label {
font-weight: 800;
display: flex;
align-items: center;
justify-content: left;
text-align: left;
}
.ib-cell {
position: relative;
display: flex;
align-items: center;
width: 100%;
min-height: 1.4em;
padding-left: 7.5rem;
box-sizing: border-box;
white-space: nowrap;
}
.ib-k { color: #6b7280; font-size: 12px; }
.ib-val {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
text-align: left;
unicode-bidi: isolate;
font-weight: 800;
}
.account-row {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 10px;
margin-top: 10px;
}
.mesghal-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
min-height: 2.5rem;
}
.mesghal-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mesghal-blank {
flex: 1;
min-width: 4rem;
border-bottom: 1px dotted #9ca3af;
align-self: flex-end;
margin-bottom: 0.35rem;
margin-inline-start: 0.5rem;
}
.account-box {
border: 1px solid #111827;
border-radius: 10px;
background: transparent;
overflow: hidden;
display: flex;
flex-direction: column;
}
.acc-line {
display: grid;
grid-template-columns: auto 1fr 1fr;
align-items: stretch;
gap: 0;
border-top: 1px solid #e5e7eb;
}
.acc-line:first-child { border-top: 0; }
.acc-title {
font-weight: 800;
white-space: nowrap;
padding: 6px 12px;
display: flex;
align-items: center;
}
.acc-pair {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 6px 12px;
border-inline-start: 1px solid #e5e7eb;
}
.acc-pair strong { font-weight: 800; unicode-bidi: isolate; }
@media print {
body { font-size: 11px; }
}
</style>
</head>
<body>
<div class="top">
<div class="top-start">
${headerText}
<p class="org">${company}</p>
${phone}
</div>
<div class="top-center">${logo}</div>
<div class="top-end">
<div class="doc-date">تاریخ: ${toFaDigits(escapeHtml(group.date))}</div>
<div>شماره سند: <strong>${toFaDigits(escapeHtml(group.no))}</strong></div>
${subtitle}
${temporary ? '<span class="badge">سند موقت</span>' : ''}
</div>
</div>
<div class="party">
<span class="party-item">حساب: <strong>${escapeHtml(customerName || '—')}</strong></span>
<span class="party-item">کد: <strong class="ltr">${codeFa}</strong></span>
<span class="party-item party-phone">موبایل: <strong class="ltr">${escapeHtml(phoneFa)}</strong></span>
</div>
<table>
<thead>
<tr>
<th class="col-radif">ردیف</th>
<th class="col-desc">شرح</th>
<th class="col-darsad">درصد</th>
<th>عیار</th>
<th>وزن</th>
<th class="col-tabdil">${escapeHtml(tabdilLabel)}</th>
<th>مظنه/فی</th>
<th class="col-mali">مبلغ (ریال)</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
<tfoot>
<tr>
<td colspan="3" class="totals-label">مجموع کل اجناس</td>
<td></td>
<td class="ltr weight-sum">${escapeHtml(totalVaznLabel)}</td>
<td class="ltr weight-sum col-tabdil">${escapeHtml(totalTabdilLabel)}</td>
<td></td>
<td class="ltr col-mali">${formatTahesabBedBes(total)}</td>
</tr>
</tfoot>
</table>
<div class="invoice-balance">
<div class="ib-when"><span class="ib-k">زمان چاپ:</span> ${escapeHtml(generatedAt)}</div>
<div class="ib-label">مانده این فاکتور:</div>
<div class="ib-cell">
<span class="ib-k">مانده وزنی:</span>
<strong class="ib-val" dir="ltr">${escapeHtml(totalTabdilLabel)}</strong>
</div>
<div class="ib-cell">
<span class="ib-k">مانده ریالی:</span>
<strong class="ib-val" dir="ltr">${formatTahesabBedBes(total)}</strong>
</div>
</div>
<div class="account-row">
${mesghalHtml}
${accountHtml}
</div>
<footer class="page-foot">
${footerText}
${signRow}
</footer>
</body>
</html>`;
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
let win: Window | null =
targetWindow && !targetWindow.closed ? targetWindow : null;
if (win) {
try {
win.location.href = url;
} catch {
try {
win.document.open();
win.document.write(html);
win.document.close();
} catch {
URL.revokeObjectURL(url);
return false;
}
}
} else {
// بدون noopener تا reference داشته باشیم
win = window.open(url, '_blank', 'width=960,height=1200');
if (!win) {
URL.revokeObjectURL(url);
return false;
}
}
const triggerPrint = () => {
try {
win?.focus();
win?.print();
} catch {
/* ignore */
} finally {
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
};
setTimeout(triggerPrint, 600);
return true;
}
+20
View File
@@ -1,3 +1,5 @@
import { toFaDigits } from '@/lib/jalali';
/** برچسب انواع سند ته‌حساب — کپی runtime برای Vite (بدون named export از shared) */
export const TAHSAB_SANAD_LABELS: Record<string, string> = {
buy_sale_gold: 'خرید/فروش آبشده',
@@ -6,3 +8,21 @@ export const TAHSAB_SANAD_LABELS: Record<string, string> = {
vkh_gold: 'واریز/خروج طلا',
vkh_bank: 'واریز/خروج بانکی',
};
/**
* قالب نمایش ته‌حساب برای مبالغ/مانده‌های علامت‌دار:
* منفی → قدرمطلق + «بد» (بدهکار)، مثبت → قدرمطلق + «بس» (بستانکار).
* صفر بدون پسوند.
*/
export function formatTahesabBedBes(v: unknown, maximumFractionDigits = 3): string {
if (v == null) return '—';
const s = String(v).trim();
if (!s || s === 'null' || s === 'undefined' || s === 'NaN') return '—';
const n = Number(s.replace(/,/g, ''));
if (!Number.isFinite(n)) return toFaDigits(s);
const abs = toFaDigits(
new Intl.NumberFormat('fa-IR', { maximumFractionDigits }).format(Math.abs(n)),
);
if (n === 0) return abs;
return `${abs} ${n < 0 ? 'بد' : 'بس'}`;
}
+19 -1
View File
@@ -1,6 +1,8 @@
import axios from 'axios';
import { computed, ref, shallowRef } from 'vue';
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
import type { ConversationRoomDto, ConversationSessionDto, StorageUsageDto } from '@mento/shared';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
export type ChatDelivery = 'sending' | 'sent' | 'read';
@@ -1344,8 +1346,14 @@ export async function refreshUnreadTotal() {
}
export function useOrgChat() {
const auth = useAuthStore();
const loadingMessages = ref(false);
function rememberStorage(usage: StorageUsageDto) {
if (!auth.user) return;
auth.setUser({ ...auth.user, storage: usage });
}
async function ensureConnected() {
await connectSocket();
}
@@ -1696,10 +1704,15 @@ export function useOrgChat() {
}
}
setProgress(12);
await api.post('/conversations/files/reserve', { bytes: fileForUpload.size });
const uploadedPath = await uploadChatFile(session.uploadUrl, fileForUpload, (p) => {
setProgress(12 + Math.round(p * 0.86));
});
setProgress(99);
const committed = await api.post<StorageUsageDto>('/conversations/files/commit', {
bytes: fileForUpload.size,
});
rememberStorage(committed.data);
await sendMediaPath(roomId, uploadedPath, type, {
reply,
fileName: outboundFileName,
@@ -1715,6 +1728,11 @@ export function useOrgChat() {
// ignore
}
}
if (axios.isAxiosError(e)) {
const raw = (e.response?.data as { message?: string | string[] } | undefined)?.message;
const text = Array.isArray(raw) ? raw.join(' ') : raw;
if (text) throw new Error(text);
}
throw e;
}
}
+166
View File
@@ -0,0 +1,166 @@
/** پیام صوتی به سبک بله و تلگرام: Opus تک‌کاناله، حدود ۳۲ کیلوبیت بر ثانیه. */
export const VOICE_BITRATE = 32_000;
export const VOICE_MAX_SECONDS = 5 * 60;
export const VOICE_MAX_BYTES = Math.floor(1.8 * 1024 * 1024);
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/ogg;codecs=opus',
'audio/webm',
'audio/mp4',
];
export function voiceRecorderMime(): string {
if (typeof MediaRecorder === 'undefined') return '';
return MIME_CANDIDATES.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? '';
}
function extensionFor(mime: string): string {
if (mime.includes('mp4')) return 'm4a';
if (mime.includes('ogg')) return 'ogg';
return 'webm';
}
function fileFromBlob(blob: Blob, mime: string): File {
const type = mime.split(';')[0] || blob.type || 'audio/webm';
return new File([blob], `voice.${extensionFor(mime)}`, { type });
}
export type VoiceRecorder = {
stop: () => Promise<File>;
cancel: () => void;
};
export async function startVoiceRecorder(handlers: {
onTick: (seconds: number) => void;
onAutoStop: (file: File) => void;
}): Promise<VoiceRecorder> {
const mime = voiceRecorderMime();
if (!mime || !navigator.mediaDevices?.getUserMedia) {
throw new Error('این مرورگر ضبط صوت را پشتیبانی نمی‌کند.');
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: { ideal: 1 },
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
} catch {
throw new Error('دسترسی به میکروفون داده نشد.');
}
const rec = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
const chunks: Blob[] = [];
rec.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
const started = Date.now();
let settled = false;
let timer = 0;
let auto = 0;
const stopped = new Promise<File>((resolve, reject) => {
rec.onstop = () => {
const file = fileFromBlob(new Blob(chunks, { type: mime.split(';')[0] }), mime);
if (!file.size) {
reject(new Error('صدایی ضبط نشد.'));
return;
}
if (file.size > VOICE_MAX_BYTES) {
reject(new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.'));
return;
}
resolve(file);
};
rec.onerror = () => reject(new Error('ضبط صوت ناموفق بود.'));
});
const finish = () => {
if (settled) return;
settled = true;
window.clearInterval(timer);
window.clearTimeout(auto);
stream.getTracks().forEach((track) => track.stop());
if (rec.state !== 'inactive') rec.stop();
};
timer = window.setInterval(() => {
handlers.onTick(Math.min(VOICE_MAX_SECONDS, Math.round((Date.now() - started) / 1000)));
}, 250);
auto = window.setTimeout(() => {
finish();
void stopped.then(handlers.onAutoStop).catch(() => undefined);
}, VOICE_MAX_SECONDS * 1000);
rec.start(250);
return {
stop: () => {
finish();
return stopped;
},
cancel: () => {
chunks.length = 0;
finish();
void stopped.catch(() => undefined);
},
};
}
export async function compressVoiceFile(file: File): Promise<File> {
const mime = voiceRecorderMime();
if (!mime) throw new Error('این مرورگر فشرده‌سازی صوت را پشتیبانی نمی‌کند.');
const ctx = new AudioContext();
let audio: AudioBuffer;
try {
audio = await ctx.decodeAudioData(await file.arrayBuffer());
} catch {
await ctx.close();
throw new Error('این فایل صوتی قابل خواندن نیست.');
}
if (audio.duration > VOICE_MAX_SECONDS + 0.5) {
await ctx.close();
throw new Error('صوت حداکثر ۵ دقیقه می‌تواند باشد.');
}
const mono = ctx.createBuffer(1, audio.length, audio.sampleRate);
const out = mono.getChannelData(0);
for (let i = 0; i < audio.length; i += 1) {
let sum = 0;
for (let channel = 0; channel < audio.numberOfChannels; channel += 1) {
sum += audio.getChannelData(channel)[i] ?? 0;
}
out[i] = sum / audio.numberOfChannels;
}
const dest = ctx.createMediaStreamDestination();
const source = ctx.createBufferSource();
source.buffer = mono;
source.connect(dest);
const rec = new MediaRecorder(dest.stream, { mimeType: mime, audioBitsPerSecond: VOICE_BITRATE });
const chunks: Blob[] = [];
rec.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
const encoded = new Promise<Blob>((resolve, reject) => {
rec.onstop = () => resolve(new Blob(chunks, { type: mime.split(';')[0] }));
rec.onerror = () => reject(new Error('فشرده‌سازی صوت ناموفق بود.'));
});
rec.start(250);
source.start();
await new Promise<void>((resolve) => {
source.onended = () => resolve();
});
await new Promise((resolve) => window.setTimeout(resolve, 180));
if (rec.state !== 'inactive') rec.stop();
const blob = await encoded;
source.disconnect();
await ctx.close();
const result = fileFromBlob(blob, mime);
if (!result.size) throw new Error('فشرده‌سازی صوت ناموفق بود.');
if (result.size > VOICE_MAX_BYTES) throw new Error('حجم صوت بعد از فشرده‌سازی هنوز زیاد است.');
return result;
}
+149 -62
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
export const router = createRouter({
history: createWebHistory(),
@@ -56,6 +57,16 @@ export const router = createRouter({
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'chat',
name: 'adm-chat',
component: () => import('@/views/adm/AdmChatSettingsView.vue'),
},
{
path: 'billing',
name: 'adm-billing',
component: () => import('@/views/adm/AdmBillingSettingsView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
@@ -87,6 +98,11 @@ export const router = createRouter({
name: 'reports',
component: () => import('@/views/ReportsView.vue'),
},
{
path: 'reports/tahesab-sales',
name: 'tahesab-sales-report',
component: () => import('@/views/TahesabSalesReportView.vue'),
},
{
path: 'behavior/disc',
name: 'disc-report',
@@ -240,69 +256,124 @@ export const router = createRouter({
},
{
path: 'finance',
name: 'finance',
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts',
name: 'finance-accounts',
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts/:id',
name: 'finance-account',
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/incomes',
name: 'finance-incomes',
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/expenses',
name: 'finance-expenses',
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/receivables',
name: 'finance-receivables',
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/payroll',
name: 'finance-payroll',
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/recurring',
name: 'finance-recurring',
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/reports',
name: 'finance-reports',
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/audit',
name: 'finance-audit',
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/categories',
name: 'finance-categories',
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
component: () => import('@/views/treasury/TreasuryLayout.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
children: [
{
path: '',
name: 'finance',
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'مدیریت مالی',
treasuryDescription:
'دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.',
},
},
{
path: 'accounts',
name: 'finance-accounts',
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'صندوق و حساب‌ها',
treasuryDescription: 'موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.',
},
},
{
path: 'accounts/:id',
name: 'finance-account',
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'جزئیات حساب',
treasuryDescription: 'گردش قطعی این حساب',
},
},
{
path: 'incomes',
name: 'finance-incomes',
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دریافت‌ها و درآمدها',
treasuryDescription:
'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.',
},
},
{
path: 'expenses',
name: 'finance-expenses',
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'پرداخت‌ها و هزینه‌ها',
treasuryDescription:
'ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.',
},
},
{
path: 'receivables',
name: 'finance-receivables',
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'اقساط و مطالبات',
treasuryDescription:
'دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.',
},
},
{
path: 'payroll',
name: 'finance-payroll',
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.',
},
},
{
path: 'recurring',
name: 'finance-recurring',
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'درآمد و هزینه تکرارشونده',
treasuryDescription: 'تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.',
},
},
{
path: 'reports',
name: 'finance-reports',
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'گزارش مالی',
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.',
},
},
{
path: 'audit',
name: 'finance-audit',
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'سوابق تغییرات مالی',
treasuryDescription: 'تسویه سند و نشستن وجه در صندوق، با مقدار ثبت‌شده.',
},
},
{
path: 'categories',
name: 'finance-categories',
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'دسته‌بندی‌های مالی',
treasuryDescription: 'دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.',
},
},
],
},
{
path: 'reservations',
@@ -581,12 +652,28 @@ router.beforeEach((to) => {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('treasury')) return { name: 'home' };
}
if (
to.name === 'tahesab-sales-report' ||
to.name === 'tahesab-settings' ||
String(to.path).startsWith('/settings/tahesab') ||
String(to.path).startsWith('/reports/tahesab')
) {
const modules = auth.user?.enabledModules;
if (modules && !modules.includes('tahesab')) return { name: 'home' };
}
if (!canOpenPath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return { name: 'home' };
}
const roles = to.meta.roles as UserRole[] | undefined;
if (roles?.length) {
const role = auth.user?.role as UserRole | undefined;
if (role && roles.includes(role)) {
return true;
}
// لاگ فعالیت: تیک «لاگ فعالیت کاربران» جایگزین نقش مدیر می‌شود
if (sectionPermissionForPath(String(to.path)) === 'audit') {
return true;
}
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
return true;
}
+249 -33
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import {
@@ -7,6 +7,7 @@ import {
formatClockFa,
formatJalaliDay,
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
@@ -21,20 +22,42 @@ import type {
OrgUserDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
const route = useRoute();
const router = useRouter();
const ACTIVITY_LIST_QUERY = ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
const NAV_API_QUERY = ['q', 'desk', 'type', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const;
function activitiesListLocation() {
const query: Record<string, string> = {};
for (const key of ACTIVITY_LIST_QUERY) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
return { name: 'activities' as const, query };
}
function goBack() {
const back = (window.history.state as { back?: string | null } | null)?.back ?? '';
if (back.split('?')[0] === '/activities') {
router.back();
return;
}
void router.push(activitiesListLocation());
}
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const canReassign = computed(() => canReassignRecords(auth.user?.role));
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const imageUrls = ref<Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }>>([]);
const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
@@ -130,6 +153,7 @@ async function load() {
try {
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
activity.value = data;
await loadActivityImages(data);
assignUserIds.value = data.assignees?.length
? data.assignees.map((u) => u.id)
: data.assignee?.id
@@ -143,6 +167,36 @@ async function load() {
}
}
function clearImageUrls() {
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
imageUrls.value = [];
}
async function loadActivityImages(row: ActivityDto) {
clearImageUrls();
const next: Array<{ id: string; name: string; url: string; kind: 'image' | 'audio' | 'file' }> = [];
for (const image of row.images ?? []) {
const mime = image.mimeType.toLowerCase();
const kind = mime.startsWith('audio/') ? 'audio' : mime.startsWith('image/') ? 'image' : 'file';
try {
const { data } = await api.get<Blob>(`/activities/${row.id}/images/${image.id}`, {
responseType: 'blob',
});
next.push({
id: image.id,
name: image.fileName,
url: URL.createObjectURL(data),
kind,
});
} catch {
/* فایل در دسترس نیست */
}
}
imageUrls.value = next;
}
onUnmounted(clearImageUrls);
async function loadUsers() {
if (!canReassign.value) return;
try {
@@ -217,8 +271,8 @@ async function postponeTo(iso: string) {
}
}
async function submitComment(text: string) {
const body = text.trim();
async function submitComment(payload: { text: string; photoOnly: boolean }) {
const body = payload.text.trim() || (payload.photoOnly ? 'عکس پیوست شد.' : '');
if (!id.value || !activity.value || !body) return;
saving.value = true;
error.value = '';
@@ -232,8 +286,9 @@ async function submitComment(text: string) {
await api.patch(`/activities/${id.value}`, { description: next });
commentOpen.value = false;
await load();
} catch {
} catch (e) {
error.value = 'ثبت نظر ناموفق بود.';
throw e;
} finally {
saving.value = false;
}
@@ -250,34 +305,91 @@ async function remove() {
}
}
const routinesEnabled = computed(() => {
const mods = auth.user?.enabledModules;
return !mods || mods.includes('routines');
});
const converting = ref(false);
const navIds = ref<string[]>([]);
const deskBrowse = computed(
() => route.query.from === 'desk' && typeof route.query.nav === 'string' && route.query.nav.length > 0,
);
const navIndex = computed(() => navIds.value.indexOf(id.value));
const prevNavId = computed(() => (navIndex.value > 0 ? navIds.value[navIndex.value - 1]! : ''));
const nextNavId = computed(() =>
navIndex.value >= 0 && navIndex.value < navIds.value.length - 1
? navIds.value[navIndex.value + 1]!
: '',
);
const showNav = computed(() => deskBrowse.value && navIndex.value >= 0);
/** پیگیری → Task در «کارهای روتین» (تکراری نمی‌سازد) */
async function convertToTask() {
if (!id.value || converting.value) return;
converting.value = true;
error.value = '';
function navStorageKey(token: string) {
return `activity-nav:${token}`;
}
function readNavCache(token: string): string[] | null {
try {
const { data } = await api.post<{ id: string }>(`/routine-tasks/from-activity/${id.value}`);
moreOpen.value = false;
void router.push({ path: '/routines', query: { task: data.id } });
} catch (e) {
const msg = (e as { response?: { data?: { message?: string | string[] } } }).response?.data?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل به Task ممکن نشد.');
} finally {
converting.value = false;
const raw = sessionStorage.getItem(navStorageKey(token));
if (!raw) return null;
const parsed = JSON.parse(raw) as { ids?: unknown };
if (!Array.isArray(parsed.ids) || parsed.ids.some((item) => typeof item !== 'string')) return null;
return parsed.ids as string[];
} catch {
return null;
}
}
let navSeq = 0;
async function loadNav() {
const seq = ++navSeq;
const token = typeof route.query.nav === 'string' ? route.query.nav : '';
const columnMissing =
route.query.view === 'board' &&
(typeof route.query.stage !== 'string' || !route.query.stage || route.query.stage === 'all');
if (!deskBrowse.value || !token || columnMissing) {
navIds.value = [];
return;
}
const cached = readNavCache(token);
if (cached) {
if (seq === navSeq) navIds.value = cached;
return;
}
const params: Record<string, string> = {
filter: typeof route.query.filter === 'string' && route.query.filter ? route.query.filter : 'today',
};
for (const key of NAV_API_QUERY) {
const value = route.query[key];
if (typeof value === 'string' && value) params[key] = value;
}
if (typeof route.query.board === 'string' && route.query.board) params.boardId = route.query.board;
if (typeof route.query.stage === 'string' && route.query.stage && route.query.stage !== 'all') {
params.stageId = route.query.stage;
}
try {
const { data } = await api.get<{ ids: string[] }>('/activities/nav-ids', { params });
if (seq !== navSeq) return;
const ids = Array.isArray(data.ids) ? data.ids : [];
try {
sessionStorage.setItem(navStorageKey(token), JSON.stringify({ ids }));
} catch {
/* حالت خصوصی مرورگر */
}
navIds.value = ids;
} catch {
if (seq === navSeq) navIds.value = [];
}
}
function goNeighbor(targetId: string) {
if (!targetId || targetId === id.value) return;
commentOpen.value = false;
postponeOpen.value = false;
moreOpen.value = false;
void router.push({ name: 'activity-detail', params: { id: targetId }, query: route.query });
}
watch(id, () => {
commentOpen.value = false;
postponeOpen.value = false;
void load();
});
watch(() => route.query.nav, () => void loadNav(), { immediate: true });
onMounted(() => {
void load();
void loadUsers();
@@ -288,9 +400,9 @@ onMounted(() => {
<template>
<section class="page" @click="moreOpen = false">
<header class="nav">
<RouterLink class="icon-btn back" :to="{ name: 'activities' }" aria-label="بازگشت">
<a class="icon-btn back" href="/activities" aria-label="بازگشت" @click.prevent="goBack">
<UiIcon name="chevron" :size="18" />
</RouterLink>
</a>
<span class="brand">{{ brand.name }}</span>
<div class="more" @click.stop>
<button class="icon-btn" type="button" aria-label="منوی بیشتر" @click="moreOpen = !moreOpen">
@@ -298,15 +410,21 @@ onMounted(() => {
</button>
<div v-if="moreOpen && activity" class="menu">
<RouterLink :to="{ name: 'activity-edit', params: { id } }">ویرایش</RouterLink>
<button v-if="routinesEnabled" type="button" :disabled="converting" @click="convertToTask">
تبدیل به Task
</button>
<button type="button" class="danger" @click="remove">حذف</button>
</div>
</div>
</header>
<h1>جزئیات فعالیت</h1>
<nav v-if="showNav" class="neighbors" aria-label="فعالیت‌های همین فهرست">
<button type="button" class="neighbor" :disabled="!prevNavId" @click="goNeighbor(prevNavId)">
قبلی
</button>
<span>{{ toFaDigits(navIndex + 1) }} از {{ toFaDigits(navIds.length) }}</span>
<button type="button" class="neighbor" :disabled="!nextNavId" @click="goNeighbor(nextNavId)">
بعدی
</button>
</nav>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error && !activity" class="error">{{ error }}</p>
@@ -446,9 +564,35 @@ onMounted(() => {
</strong>
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<p v-show="noteOpen" :class="{ empty: !activity.description?.trim() }">
{{ activity.description?.trim() || 'یادداشتی ثبت نشده.' }}
</p>
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
:key="image.id"
:href="image.url"
target="_blank"
rel="noopener"
>
<img :src="image.url" :alt="image.name" />
</a>
</div>
<div v-if="imageUrls.some((image) => image.kind === 'audio')" class="note-voices">
<audio
v-for="image in imageUrls.filter((item) => item.kind === 'audio')"
:key="image.id"
:src="image.url"
controls
preload="metadata"
/>
</div>
<ul v-if="imageUrls.some((image) => image.kind === 'file')" class="note-files">
<li v-for="image in imageUrls.filter((item) => item.kind === 'file')" :key="image.id">
<a :href="image.url" :download="image.name">{{ image.name }}</a>
</li>
</ul>
</div>
</article>
<p v-if="error" class="error">{{ error }}</p>
@@ -457,8 +601,8 @@ onMounted(() => {
v-if="commentOpen"
:activity="activity"
:saving="saving"
:save="submitComment"
@close="commentOpen = false"
@submit="submitComment"
/>
<ActivityPostponeSheet
v-if="postponeOpen"
@@ -583,6 +727,36 @@ h1 {
font-size: 1.35rem;
}
.neighbors {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.neighbors span {
color: #64748b;
font-size: 0.82rem;
font-weight: 600;
}
.neighbor {
border: 0;
background: #fff;
color: #0f766e;
font: inherit;
font-weight: 700;
border-radius: var(--radius);
padding: 0.35rem 0.85rem;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
cursor: pointer;
}
.neighbor:disabled {
color: #94a3b8;
cursor: default;
}
.muted,
.error {
margin: 0;
@@ -800,6 +974,48 @@ dd {
color: #94a3b8;
}
.note-photos {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.note-photos a {
width: 5.5rem;
height: 5.5rem;
}
.note-photos img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
}
.note-voices {
display: grid;
gap: 0.45rem;
margin-top: 0.75rem;
}
.note-voices audio {
width: 100%;
}
.note-files {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.note-files a {
color: #0f766e;
}
.dock {
position: fixed;
inset-inline: 0.85rem;
+7 -1
View File
@@ -64,6 +64,12 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
const router = useRouter();
const auth = useAuthStore();
const isOwner = computed(() => auth.user?.role === 'owner');
/** ورود و خروج فقط برای مالک و مدیر؛ بقیه با مجوز «لاگ فعالیت» فقط لاگ‌ها را می‌بینند */
const visibleTabs = computed(() =>
auth.user?.role === 'owner' || auth.user?.role === 'admin'
? TABS
: TABS.filter((item) => item.key !== 'attendance'),
);
const loading = ref(false);
const savingRetention = ref(false);
@@ -366,7 +372,7 @@ onMounted(() => {
<nav class="tabs" aria-label="نوع رویداد">
<button
v-for="item in TABS"
v-for="item in visibleTabs"
:key="item.key"
type="button"
:class="{ on: tab === item.key }"
+6 -2
View File
@@ -8,6 +8,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import CustomerGroupPicker from '@/components/CustomerGroupPicker.vue';
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
import {
UiButton,
@@ -59,6 +60,7 @@ const city = ref('');
const postalCode = ref('');
const address = ref('');
const groupName = ref('');
const groupIds = ref<string[]>([]);
const registeredAt = ref('');
const notes = ref('');
const tagsRaw = ref('');
@@ -94,6 +96,7 @@ function fill(c: CompanyDto) {
postalCode.value = c.postalCode ?? '';
address.value = c.address ?? '';
groupName.value = c.groupName ?? '';
groupIds.value = (c.groups ?? []).map((g) => g.id);
registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : '';
notes.value = c.notes ?? '';
tagsRaw.value = (c.tags ?? []).join('، ');
@@ -134,6 +137,7 @@ function payload(): CreateCompanyRequest {
postalCode: postalCode.value || null,
address: address.value || undefined,
groupName: groupName.value || null,
groupIds: groupIds.value,
registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null,
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
@@ -254,8 +258,8 @@ onMounted(load);
<UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
</UiField>
<UiField label="گروه بندی شرکت">
<UiInput id="groupName" v-model="groupName" placeholder="مثال: همکار، عمده‌فروش" />
<UiField label="گروه‌های شرکت" class="col-span-2">
<CustomerGroupPicker v-model="groupIds" />
</UiField>
<UiField label="تاریخ ثبت شرکت">
@@ -36,6 +36,7 @@ const entityLabels: Record<CustomFieldEntity, string> = {
person: 'شخص',
company: 'شرکت',
deal: 'معامله',
note: 'یادداشت',
};
const typeLabels: Record<CustomFieldType, string> = {
@@ -126,7 +127,7 @@ onMounted(load);
<section class="crm-page narrow">
<UiPageHeader
title="فیلدهای سفارشی"
lead="فیلدهای اختصاصی سازمان را برای شخص، شرکت یا معامله تعریف کنید."
lead="فیلدهای اختصاصی سازمان را برای شخص، شرکت، معامله یا یادداشت تعریف کنید."
/>
<form class="crm-form" @submit.prevent="createField">
@@ -147,6 +148,7 @@ onMounted(load);
<option value="person">شخص</option>
<option value="company">شرکت</option>
<option value="deal">معامله</option>
<option value="note">یادداشت</option>
</UiSelect>
</UiField>
@@ -197,6 +199,7 @@ onMounted(load);
<option value="person">شخص</option>
<option value="company">شرکت</option>
<option value="deal">معامله</option>
<option value="note">یادداشت</option>
</UiSelect>
<UiButton variant="ghost" type="button" @click="load">
<UiIcon name="refresh" :size="16" />
+4 -1
View File
@@ -38,6 +38,7 @@ import {
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import { canUseBehavior } from '@/lib/behavior-access';
import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
@@ -180,6 +181,7 @@ const personSelectOptions = computed(() =>
const companySelectOptions = computed(() =>
companies.value.map((c) => ({ value: c.id, label: c.name })),
);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'sales'));
const ownerSelectOptions = computed(() =>
users.value.map((u) => ({ value: u.id, label: u.fullName })),
);
@@ -772,13 +774,14 @@ onMounted(load);
v-model:value="ownerId"
show-search
option-filter-prop="label"
:disabled="!canAssignOthers"
:options="ownerSelectOptions"
placeholder="انتخاب مسئول"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-col v-if="canAssignOthers" :xs="24" :md="12">
<a-form-item label="مسئولان مشترک">
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
<a-typography-text type="secondary">علاوه بر مسئول اصلی.</a-typography-text>
+438 -33
View File
@@ -4,11 +4,16 @@ import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Input as AInput,
Menu as AMenu,
MenuItem as AMenuItem,
Modal as AModal,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
@@ -18,25 +23,43 @@ import {
import {
CalendarOutlined,
HolderOutlined,
MoreOutlined,
PhoneOutlined,
PlusOutlined,
ReloadOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
import { canManageSettings } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const router = useRouter();
const auth = useAuthStore();
const canManage = computed(() => canManageSettings(auth.user?.role));
const GAP = 16;
const COL_WIDTH = 280;
const loading = ref(false);
const moving = ref(false);
const stageSaving = ref(false);
const error = ref('');
const stageError = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const stages = ref<LeadStageDto[]>([]);
const renameTarget = ref<LeadStageDto | null>(null);
const renameName = ref('');
const addOpen = ref(false);
const addName = ref('');
const addConverted = ref(false);
const addLost = ref(false);
const addUnqualified = ref(false);
const deleteTarget = ref<LeadStageDto | null>(null);
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
@@ -137,14 +160,6 @@ const cardBodyStyle = computed(() => ({
margin: 0,
}));
function leadInitials(lead: LeadDto) {
const raw = (lead.contactName || lead.title || '?').trim();
const parts = raw.split(/\s+/).filter(Boolean);
if (!parts.length) return '؟';
if (parts.length === 1) return parts[0]!.slice(0, 1);
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
}
function leadSubtitle(lead: LeadDto) {
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
}
@@ -158,22 +173,288 @@ function formatDate(value: string | null) {
}
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (typeof msg === 'string' && msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
function isOpenStage(stage: Pick<LeadStageDto, 'isConverted' | 'isLost' | 'isUnqualified'>) {
return !stage.isConverted && !stage.isLost && !stage.isUnqualified;
}
function entryStage(list: LeadStageDto[]) {
return list.find(isOpenStage) ?? null;
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<LeadBoardColumnDto[]>('/leads/board');
columns.value = data;
const [board, stageRows] = await Promise.all([
api.get<LeadBoardColumnDto[]>('/leads/board'),
api.get<LeadStageDto[]>('/leads/stages'),
]);
columns.value = board.data;
stages.value = stageRows.data;
} catch (e) {
error.value =
axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری مسیر لیدها ناموفق بود.';
error.value = apiMessage(e, 'بارگذاری مسیر لیدها ناموفق بود.');
} finally {
loading.value = false;
}
}
function visibleIndex(stageId: string) {
return stages.value.filter((stage) => stage.showOnBoard).findIndex((stage) => stage.id === stageId);
}
function plannedOrder(stageId: string, dir: -1 | 1) {
const visible = stages.value.filter((stage) => stage.showOnBoard);
const from = visible.findIndex((stage) => stage.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= visible.length) return null;
const nextVisible = visible.slice();
const [item] = nextVisible.splice(from, 1);
if (!item) return null;
nextVisible.splice(to, 0, item);
const queue = nextVisible.slice();
return stages.value.map((stage) => (stage.showOnBoard ? queue.shift()!.id : stage.id));
}
function entryIn(ids: string[]) {
const byId = new Map(stages.value.map((stage) => [stage.id, stage]));
for (const id of ids) {
const stage = byId.get(id);
if (stage && isOpenStage(stage)) return stage;
}
return null;
}
async function persistOrder(stageIds: string[]) {
stageSaving.value = true;
error.value = '';
try {
await api.patch('/leads/stages/reorder', { stageIds });
moveConfirm.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
function requestMove(stageId: string, dir: -1 | 1) {
const next = plannedOrder(stageId, dir);
if (!next) return;
const before = entryStage(stages.value);
const after = entryIn(next);
if (before && after && before.id !== after.id) {
moveConfirm.value = {
stageIds: next,
message: `با این جابه‌جایی، لیدهای تازهٔ فرم و ورود از اکسل به‌جای «${before.name}» در ستون «${after.name}» ثبت می‌شوند. لیدهای فعلی سر جایشان می‌مانند.`,
};
return;
}
void persistOrder(next);
}
function nameTaken(name: string, exceptId?: string) {
const trimmed = name.trim();
return stages.value.some((stage) => stage.id !== exceptId && stage.name === trimmed);
}
function openRename(stage: LeadStageDto) {
stageError.value = '';
renameName.value = stage.name;
renameTarget.value = stage;
}
function openAdd() {
stageError.value = '';
addName.value = '';
addConverted.value = false;
addLost.value = false;
addUnqualified.value = false;
addOpen.value = true;
}
function openDelete(stage: LeadStageDto) {
stageError.value = '';
deleteTarget.value = stage;
}
function onColumnMenu(stageId: string, key: string) {
const stage = stages.value.find((item) => item.id === stageId);
if (!stage) return;
if (key === 'rename') openRename(stage);
else if (key === 'right') requestMove(stageId, -1);
else if (key === 'left') requestMove(stageId, 1);
else if (key === 'delete') openDelete(stage);
}
const renameNotes = computed(() => {
const stage = renameTarget.value;
if (!stage) return [];
const lines = [
'اسم جدید در فهرست لیدها، صفحهٔ جزئیات، فرم‌ساز و فیلتر پیامک هم دیده می‌شود. لیدهای این ستون سر جایشان می‌مانند.',
];
if (entryStage(stages.value)?.id === stage.id) {
lines.push('شمارش لیدهای تازه به اسم ستون وابسته نیست و با این تغییر نام به‌هم نمی‌ریزد.');
}
if (stage.isConverted) lines.push('علامت تبدیل‌شده روی همین ستون می‌ماند.');
if (stage.isLost) lines.push('علامت از دست‌رفته روی همین ستون می‌ماند و آمار همان را می‌شمارد.');
if (stage.isUnqualified) lines.push('علامت نامرتبط روی همین ستون می‌ماند.');
return lines;
});
const addNotes = computed(() => {
const lines = [
'ستون در انتهای مسیر اضافه می‌شود. لیدهای ستون‌های دیگر جابه‌جا نمی‌شوند.',
];
const flags = [addConverted.value, addLost.value, addUnqualified.value].filter(Boolean).length;
if (flags > 1) {
lines.push(
'یک ستون را با یک علامت بسازید. اگر تبدیل‌شده، از دست‌رفته و نامرتبط با هم باشند، فرم و آمار هر کدام رفتار جدا دارند.',
);
} else if (!flags) {
lines.push('بدون علامت، این ستون مرحلهٔ عادی است و مقصد تبدیل به مشتری یا آمار از دست‌رفته نمی‌شود.');
}
if (addConverted.value && stages.value.some((stage) => stage.isConverted)) {
lines.push(
'ستون تبدیل‌شدهٔ دیگری هم هست. تبدیل به مشتری به اولین ستون تبدیل‌شده در ترتیب مسیر می‌رود.',
);
}
if (addLost.value && stages.value.some((stage) => stage.isLost)) {
lines.push('ستون از دست‌رفتهٔ دیگری هم هست. آمار، لیدهای هر دو ستون را می‌شمارد.');
}
return lines;
});
const deleteInfo = computed(() => {
const stage = deleteTarget.value;
if (!stage) return { blocked: '', lines: [] as string[] };
const total = columns.value.find((col) => col.stage.id === stage.id)?.total ?? stage.leadCount ?? 0;
if (total > 0) {
return {
blocked: `ستون «${stage.name}» ${toFaDigits(total)} لید دارد و حذف نمی‌شود. اول لیدها را به ستون دیگری ببرید.`,
lines: [] as string[],
};
}
const others = stages.value.filter((item) => item.id !== stage.id);
const lines = [
'این ستون از فهرست لیدها، جزئیات، فرم‌ساز و فیلتر پیامک هم برداشته می‌شود.',
];
if (stage.isConverted && !others.some((item) => item.isConverted)) {
lines.push(
'این تنها ستون تبدیل‌شده است. بعد از حذف، تبدیل به مشتری هنوز انجام می‌شود ولی لید به ستون تبدیل‌شده منتقل نمی‌شود.',
);
}
if (stage.isLost && !others.some((item) => item.isLost)) {
lines.push('این تنها ستون از دست‌رفته است. بعد از حذف، آمار لیدهای از دست‌رفته دیگر ستونی برای شمردن ندارد.');
}
if (stage.isUnqualified && !others.some((item) => item.isUnqualified)) {
lines.push('این تنها ستون نامرتبط است. بعد از حذف، مسیر دیگر ستون مشخصی برای لید نامرتبط ندارد.');
}
if (entryStage(stages.value)?.id === stage.id) {
const next = others.find(isOpenStage);
lines.push(
next
? `لیدهای تازهٔ فرم و ورود از اکسل به‌جای این ستون در «${next.name}» ثبت می‌شوند.`
: 'ستون باز دیگری نمی‌ماند. لید تازهٔ فرم در اولین ستون باقی‌مانده ثبت می‌شود.',
);
}
lines.push('اگر فرمی این ستون را مرحلهٔ اولیه کرده باشد، لید تازه به اولین ستون باز می‌رود.');
return { blocked: '', lines };
});
async function saveRename() {
const stage = renameTarget.value;
const name = renameName.value.trim();
if (!stage || stageSaving.value) return;
if (name.length < 1) {
stageError.value = 'نام ستون لازم است.';
return;
}
if (name.length > 80) {
stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.';
return;
}
if (nameTaken(name, stage.id)) {
stageError.value = 'ستونی با این نام وجود دارد.';
return;
}
if (name === stage.name) {
renameTarget.value = null;
return;
}
stageSaving.value = true;
stageError.value = '';
try {
await api.patch(`/leads/stages/${stage.id}`, { name });
renameTarget.value = null;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
async function saveAdd() {
const name = addName.value.trim();
if (stageSaving.value) return;
if (!name) {
stageError.value = 'نام ستون لازم است.';
return;
}
if (name.length > 80) {
stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.';
return;
}
if (nameTaken(name)) {
stageError.value = 'ستونی با این نام وجود دارد.';
return;
}
stageSaving.value = true;
stageError.value = '';
try {
await api.post('/leads/stages', {
name,
showOnBoard: true,
isConverted: addConverted.value,
isLost: addLost.value,
isUnqualified: addUnqualified.value,
});
addOpen.value = false;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
async function confirmDelete() {
const stage = deleteTarget.value;
if (!stage || deleteInfo.value.blocked || stageSaving.value) return;
stageSaving.value = true;
stageError.value = '';
try {
await api.delete(`/leads/stages/${stage.id}`);
deleteTarget.value = null;
await load();
} catch (e) {
stageError.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageSaving.value = false;
}
}
function openLead(lead: LeadDto) {
if (suppressCardClick || draggingId.value || moving.value) return;
void router.push({ name: 'lead-detail', params: { id: lead.id } });
@@ -223,11 +504,16 @@ async function onDrop(stageId: string, event: DragEvent) {
columns.value = columns.value.map((c) => {
if (c.stage.id === fromCol.stage.id) {
return { ...c, leads: c.leads.filter((l) => l.id !== leadId) };
return {
...c,
total: Math.max(0, c.total - 1),
leads: c.leads.filter((l) => l.id !== leadId),
};
}
if (c.stage.id === stageId) {
return {
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
}
@@ -257,7 +543,7 @@ onMounted(load);
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن و رها کردن برای تغییر وضعیت
کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید.
</a-typography-text>
</div>
<a-space :size="GAP">
@@ -298,9 +584,14 @@ onMounted(load);
<a-empty
v-if="!loading && !columns.length"
description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
description="مرحله‌ای برای نمایش در مسیر لیدها نیست."
:style="{ margin: 'auto' }"
/>
>
<a-button v-if="canManage" type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</a-empty>
<div
v-else-if="columns.length"
@@ -328,9 +619,31 @@ onMounted(load);
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(col.leads.length) }}
</a-typography-text>
<a-flex align="center" :gap="4">
<a-typography-text type="secondary">
{{ toFaDigits(col.total) }}
</a-typography-text>
<a-dropdown v-if="canManage" :trigger="['click']">
<a-button type="text" aria-label="منوی ستون" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="({ key }) => onColumnMenu(col.stage.id, String(key))">
<a-menu-item key="rename">تغییر نام</a-menu-item>
<a-menu-item key="right" :disabled="visibleIndex(col.stage.id) <= 0">
انتقال به راست
</a-menu-item>
<a-menu-item
key="left"
:disabled="visibleIndex(col.stage.id) < 0 || visibleIndex(col.stage.id) >= stages.filter((stage) => stage.showOnBoard).length - 1"
>
انتقال به چپ
</a-menu-item>
<a-menu-item key="delete" danger>حذف ستون</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-flex>
</div>
<div :style="columnBodyStyle">
@@ -376,16 +689,6 @@ onMounted(load);
>
<HolderOutlined />
</span>
<a-avatar
:style="{
background: token.colorPrimaryBg,
color: token.colorPrimary,
flexShrink: 0,
fontWeight: 700,
}"
>
{{ leadInitials(lead) }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
@@ -501,8 +804,110 @@ onMounted(load);
</a-typography-text>
</div>
</div>
<a-flex
v-if="canManage"
vertical
justify="center"
align="center"
:gap="8"
:style="{
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
border: `1px dashed ${token.colorBorder}`,
borderRadius: `${token.borderRadiusLG}px`,
background: token.colorFillAlter,
}"
>
<a-button type="primary" :disabled="stageSaving" @click="openAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</a-flex>
</div>
</div>
<a-modal
:open="!!renameTarget"
title="تغییر نام ستون"
ok-text="ذخیره"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="saveRename"
@cancel="renameTarget = null"
>
<a-flex vertical :gap="12">
<a-alert
v-for="line in renameNotes"
:key="line"
type="info"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
<a-input v-model:value="renameName" :maxlength="80" placeholder="نام ستون" @press-enter="saveRename" />
</a-flex>
</a-modal>
<a-modal
:open="addOpen"
title="افزودن ستون"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="saveAdd"
@cancel="addOpen = false"
>
<a-flex vertical :gap="12">
<a-alert
v-for="(line, index) in addNotes"
:key="index"
:type="index === 0 ? 'info' : 'warning'"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
<a-input v-model:value="addName" :maxlength="80" placeholder="مثلاً تماس اولیه" @press-enter="saveAdd" />
<a-checkbox v-model:checked="addConverted">علامت تبدیل‌شده</a-checkbox>
<a-checkbox v-model:checked="addLost">علامت از دست‌رفته</a-checkbox>
<a-checkbox v-model:checked="addUnqualified">علامت نامرتبط</a-checkbox>
</a-flex>
</a-modal>
<a-modal
:open="!!deleteTarget"
:title="deleteInfo.blocked ? 'حذف ستون ممکن نیست' : 'حذف ستون'"
:ok-text="deleteInfo.blocked ? 'متوجه شدم' : 'حذف ستون'"
:ok-button-props="deleteInfo.blocked ? undefined : { danger: true }"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="deleteInfo.blocked ? (deleteTarget = null) : confirmDelete()"
@cancel="deleteTarget = null"
>
<a-flex vertical :gap="12">
<a-alert v-if="deleteInfo.blocked" type="error" show-icon :message="deleteInfo.blocked" />
<a-alert
v-for="line in deleteInfo.lines"
:key="line"
type="warning"
show-icon
:message="line"
/>
<a-alert v-if="stageError" type="error" show-icon :message="stageError" />
</a-flex>
</a-modal>
<a-modal
:open="!!moveConfirm"
title="جابه‌جایی ستون"
ok-text="جابه‌جا شود"
cancel-text="انصراف"
:confirm-loading="stageSaving"
@ok="moveConfirm && persistOrder(moveConfirm.stageIds)"
@cancel="moveConfirm = null"
>
<a-alert v-if="moveConfirm" type="warning" show-icon :message="moveConfirm.message" />
</a-modal>
</div>
</template>
+136 -24
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
@@ -7,6 +7,7 @@ import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
@@ -15,6 +16,7 @@ import {
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
Image as AImage,
FormItem as AFormItem,
Input as AInput,
List as AList,
@@ -44,6 +46,7 @@ import {
UserAddOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { isBirthDateAnswer, leadNoteFileIsImage, parseLeadFormNotes } from '@/lib/lead-form-notes';
import {
formatClockFa,
formatJalaliNumeric,
@@ -52,7 +55,7 @@ import {
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
@@ -64,8 +67,9 @@ import type {
LeadStageDto,
OrgUserDto,
PaginatedResponse,
PersonGender,
} from '@mento/shared';
import { ACTIVITY_TYPE_LABELS } from '@mento/shared';
import { ACTIVITY_TYPE_LABELS, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
import { canUseBehavior } from '@/lib/behavior-access';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
@@ -78,7 +82,7 @@ type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
const canBehavior = computed(() => canUseBehavior(auth.user));
const copilotOpen = ref(false);
@@ -111,6 +115,8 @@ let narrowMq: MediaQueryList | null = null;
const editForm = ref({
title: '',
contactName: '',
gender: 'none' as PersonGender,
showGenderInName: false,
mobile: '',
phone: '',
city: '',
@@ -131,6 +137,42 @@ function formatDate(value: string | null | undefined) {
}
}
const noteView = computed(() => parseLeadFormNotes(lead.value?.notes));
const formBirthDate = computed(
() => noteView.value.rows.find((row) => isBirthDateAnswer(row.label) && !row.file)?.text ?? '',
);
const shownBirthDate = computed(() => {
if (lead.value?.birthDate) return formatDate(lead.value.birthDate);
return formBirthDate.value || '—';
});
const answerRows = computed(() =>
noteView.value.rows.filter((row) => !isBirthDateAnswer(row.label)),
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
const genderLabel = computed(() => {
const gender = lead.value?.gender;
const key = gender === 'male' || gender === 'female' ? gender : 'none';
const label = PERSON_GENDER_LABELS[key];
return lead.value?.showGenderInName && key !== 'none' ? `${label} · در نام` : label;
});
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => editForm.value.gender,
(value) => {
if (value === 'none') editForm.value.showGenderInName = false;
},
);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -172,9 +214,13 @@ function startEdit() {
if (!lead.value) return;
editing.value = true;
moreOpen.value = false;
const gender =
lead.value.gender === 'male' || lead.value.gender === 'female' ? lead.value.gender : 'none';
editForm.value = {
title: lead.value.title,
contactName: lead.value.contactName ?? '',
title: bareLeadName(lead.value.title),
contactName: bareLeadName(lead.value.contactName ?? ''),
gender,
showGenderInName: !!lead.value.showGenderInName && gender !== 'none',
mobile: lead.value.mobile,
phone: lead.value.phone ?? '',
city: lead.value.city ?? '',
@@ -200,6 +246,9 @@ async function saveEdit() {
await api.patch(`/leads/${id.value}`, {
title: editForm.value.title.trim(),
contactName: editForm.value.contactName.trim() || null,
gender: editForm.value.gender,
showGenderInName:
editForm.value.gender !== 'none' && editForm.value.showGenderInName,
mobile: editForm.value.mobile.trim(),
phone: editForm.value.phone.trim() || null,
city: editForm.value.city.trim() || null,
@@ -447,7 +496,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-col :span="24">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
<a-space :size="12" align="start">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
<RouterLink :to="{ name: 'leads-board' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><ArrowLeftOutlined /></template>
</a-button>
@@ -539,6 +588,24 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-input v-model:value="editForm.contactName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="editForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="editForm.showGenderInName"
:disabled="editForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="موبایل" required>
<a-input
@@ -652,6 +719,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-descriptions-item label="مالک/مخاطب">
{{ lead.contactName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="جنسیت">{{ genderLabel }}</a-descriptions-item>
<a-descriptions-item label="موبایل">
<span dir="ltr">{{ lead.mobile }}</span>
</a-descriptions-item>
@@ -674,7 +742,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
{{ lead.assigneeName || '—' }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ تولد">
{{ formatDate(lead.birthDate) }}
{{ shownBirthDate }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت">
{{ formatDate(lead.createdAt) }}
@@ -695,8 +763,49 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
>
<div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="lead.notes" label="یادداشت" :span="2">
{{ lead.notes }}
<template v-if="noteView.mode === 'answers'">
<a-descriptions-item
v-for="(row, index) in answerRows"
:key="`${row.label}-${index}`"
:label="row.label"
:span="2"
:label-style="{ whiteSpace: 'normal' }"
:content-style="{ whiteSpace: 'normal' }"
>
<a-space v-if="row.file" direction="vertical" :size="4">
<a-image
v-if="row.file.dataUrl && leadNoteFileIsImage(row.file)"
:src="row.file.dataUrl"
:alt="row.file.name"
:width="160"
/>
<a-typography-text>{{ row.file.name }}</a-typography-text>
</a-space>
<a-typography-paragraph
v-else
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="row.text"
style="margin-bottom: 0"
/>
</a-descriptions-item>
<a-descriptions-item
v-if="noteView.prose"
label="یادداشت"
:span="2"
>
<a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="noteView.prose"
style="margin-bottom: 0"
/>
</a-descriptions-item>
</template>
<a-descriptions-item v-else-if="lead.notes" label="یادداشت" :span="2">
<a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="lead.notes"
style="margin-bottom: 0"
/>
</a-descriptions-item>
</a-descriptions>
</template>
@@ -736,6 +845,16 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</template>
</a-list>
<a-empty v-else description="فعالیت مرتبطی نیست." />
<RouterLink
:to="{ name: 'activity-new', query: { leadId: lead.id } }"
custom
v-slot="{ navigate }"
>
<a-button type="dashed" block @click="navigate">
<template #icon><PlusOutlined /></template>
ایجاد فعالیت
</a-button>
</RouterLink>
<a-divider orientation="right">جدول زمانی</a-divider>
<a-timeline v-if="lead.events.length">
@@ -780,17 +899,6 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
مشاهده همه فعالیت‌ها
</a-button>
</RouterLink>
<RouterLink
v-if="narrow"
:to="{ name: 'activity-new' }"
custom
v-slot="{ navigate }"
>
<a-button type="dashed" block @click="navigate">
<template #icon><PlusOutlined /></template>
ایجاد فعالیت
</a-button>
</RouterLink>
</template>
</a-card>
@@ -804,7 +912,11 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<a-col :xs="24" :lg="7" :xl="6">
<a-affix v-if="!narrow" :offset-top="24">
<a-card title="اقدامات سریع" :bordered="false">
<a-card
title="اقدامات سریع"
:bordered="false"
:body-style="{ maxHeight: 'calc(100vh - 140px)', overflowY: 'auto' }"
>
<a-form layout="vertical">
<a-divider orientation="right">پیامک</a-divider>
<a-button
@@ -828,7 +940,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
اعمال وضعیت
</a-button>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
@@ -972,7 +1084,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</a-form-item>
<a-button block :disabled="busy" @click="changeStage">اعمال وضعیت</a-button>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
+13 -2
View File
@@ -276,7 +276,16 @@ function formatValue(val: unknown): string {
if (Array.isArray(val)) return val.join('، ');
if (val === true) return 'بله';
if (val === false) return 'خیر';
return val == null ? '' : String(val);
if (val == null) return '';
const s = String(val);
try {
const parsed = JSON.parse(s) as { name?: string; dataUrl?: string };
if (parsed?.name && parsed?.dataUrl) return `📎 ${parsed.name}`;
} catch {
/* not a file payload */
}
if (s.startsWith('data:')) return '📎 فایل پیوست';
return s;
}
function readyIcon(type: LeadFormFieldType | string): IconName {
@@ -846,7 +855,7 @@ onMounted(load);
{{ u.fullName }}
</button>
</div>
<p v-if="!assigneeIds.length" class="settings-empty">مسئولی انتخاب نشده؛ لید بدون مسئول ثبت می‌شود.</p>
<p v-if="!assigneeIds.length" class="settings-empty">مسئولی انتخاب نشده. سرنخ جدید مسئولش همان مسئول اولین تماس اتوماسیون می‌شود؛ اگر تماسی ساخته نشود، مالک سازمان مسئول می‌ماند.</p>
</article>
<article class="settings-card optional">
@@ -988,6 +997,7 @@ onMounted(load);
<option value="lead.trade">صنف</option>
<option value="lead.notes">یادداشت لید</option>
<option value="lead.source">منبع لید</option>
<option value="lead.gender">جنسیت</option>
</optgroup>
<optgroup label="شخص">
<option value="person.fullName">نام کامل</option>
@@ -997,6 +1007,7 @@ onMounted(load);
<option value="person.email">ایمیل</option>
<option value="person.phone">تلفن</option>
<option value="person.position">سمت</option>
<option value="person.gender">جنسیت شخص</option>
</optgroup>
<optgroup label="معامله">
<option value="deal.title">عنوان معامله</option>
+126 -4
View File
@@ -4,11 +4,22 @@ import { useRoute } from 'vue-router';
import axios from 'axios';
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
FormFileTooLargeError,
FORM_FILE_MAX_BYTES,
encodeFormFileValue,
formFileDisplayName,
prepareFormFieldFile,
} from '@/lib/form-file-compress';
const route = useRoute();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const fieldErrors = reactive<Record<string, string>>({});
const fileBusy = reactive<Record<string, boolean>>({});
const fileInputKey = reactive<Record<string, number>>({});
const done = ref(false);
const thankYou = ref('');
const started = ref(false);
@@ -113,6 +124,50 @@ function hasMulti(key: string, option: string) {
return (values[key] || '').split(',').map((s) => s.trim()).includes(option);
}
function clearFile(key: string) {
values[key] = '';
fieldErrors[key] = '';
fileInputKey[key] = (fileInputKey[key] ?? 0) + 1;
}
async function onFileChange(key: string, event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
fieldErrors[key] = '';
if (!file) {
values[key] = '';
return;
}
fileBusy[key] = true;
try {
const prepared = await prepareFormFieldFile(file);
values[key] = encodeFormFileValue(prepared);
} catch (e) {
values[key] = '';
input.value = '';
fieldErrors[key] =
e instanceof FormFileTooLargeError
? e.message
: e instanceof Error
? e.message
: 'آپلود فایل ناموفق بود.';
} finally {
fileBusy[key] = false;
}
}
function validateRequiredFiles(): string | null {
if (!form.value) return null;
for (const field of form.value.fields) {
if (field.fieldType !== 'file' || !field.required) continue;
if (!values[field.key]?.trim()) {
fieldErrors[field.key] = 'آپلود فایل الزامی است.';
return `فیلد «${field.label || 'آپلود فایل'}» را کامل کنید.`;
}
}
return null;
}
async function submit() {
if (!form.value) return;
if (isPreview) {
@@ -120,6 +175,15 @@ async function submit() {
done.value = true;
return;
}
const fileErr = validateRequiredFiles();
if (fileErr) {
error.value = fileErr;
return;
}
if (Object.values(fieldErrors).some(Boolean)) {
error.value = 'لطفاً خطاهای فایل را برطرف کنید.';
return;
}
saving.value = true;
error.value = '';
try {
@@ -189,7 +253,7 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
<template v-for="field in form.fields" :key="field.key">
<p v-if="field.fieldType === 'heading'" class="heading">{{ field.label }}</p>
<label v-else @focusin="onFieldFocus">
{{ field.label }}
{{ field.fieldType === 'file' ? (field.label || 'آپلود فایل') : field.label }}
<span v-if="field.required" class="req">*</span>
<textarea
v-if="field.fieldType === 'textarea'"
@@ -233,11 +297,40 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
★
</button>
</div>
<div v-else-if="field.fieldType === 'date'" class="date-field">
<JalaliDateInput
v-model="values[field.key]"
mode="date"
:required="field.required"
:placeholder="field.placeholder || 'انتخاب تاریخ شمسی'"
clearable
/>
</div>
<div v-else-if="field.fieldType === 'file'" class="file-field">
<input
:key="fileInputKey[field.key] ?? 0"
type="file"
:disabled="fileBusy[field.key]"
:aria-label="field.label || 'آپلود فایل'"
@change="onFileChange(field.key, $event)"
/>
<small class="file-hint">
حداکثر {{ Math.round(FORM_FILE_MAX_BYTES / 1024) }} کیلوبایت پس از فشرده‌سازی
</small>
<p v-if="fileBusy[field.key]" class="muted">در حال فشرده‌سازی…</p>
<p v-else-if="values[field.key]" class="file-ok">
{{ formFileDisplayName(values[field.key]) }}
<button type="button" class="file-clear" @click="clearFile(field.key)">
حذف
</button>
</p>
<p v-if="fieldErrors[field.key]" class="error">{{ fieldErrors[field.key] }}</p>
</div>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'date' ? 'date' : field.fieldType === 'number' ? 'number' : field.fieldType === 'file' ? 'text' : 'text'"
:placeholder="field.placeholder || (field.fieldType === 'file' ? 'نام فایل یا لینک' : '')"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'number' ? 'number' : 'text'"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
</label>
@@ -246,7 +339,7 @@ onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
<button
class="btn"
type="submit"
:disabled="saving"
:disabled="saving || Object.values(fileBusy).some(Boolean)"
:style="{ background: form.submitColor || '#14b8a6' }"
>
<UiIcon v-if="!saving" name="send" :size="16" />
@@ -321,6 +414,35 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: #14b8a6
.stars button { border: 0; background: none; font-size: 1.5rem; color: #cbd5e1; cursor: pointer; padding: 0; }
.stars button.on { color: #eab308; }
.heading { margin: 0.2rem 0 0; font-weight: 700; color: #0f172a; }
.date-field { min-width: 0; }
.date-field :deep(.jdi) { width: 100%; }
.file-field { display: grid; gap: 0.35rem; }
.file-field input[type='file'] {
padding: 0.45rem 0.55rem;
background: #fff;
cursor: pointer;
}
.file-hint { color: #94a3b8; font-size: 0.75rem; font-weight: 400; }
.file-ok {
margin: 0;
font-size: 0.82rem;
font-weight: 500;
color: #0f766e;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-clear {
border: 0;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
padding: 0.15rem 0.45rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.hp {
position: absolute;
clip-path: inset(50%);
+54 -5
View File
@@ -58,6 +58,7 @@ import {
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
@@ -77,9 +78,10 @@ import type {
LeadStageDto,
LeadStatsDto,
OrgUserDto,
PersonGender,
PaginatedResponse,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { brand, PERSON_GENDERS, PERSON_GENDER_LABELS, splitPersonHonorific } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
@@ -146,6 +148,7 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
@@ -191,6 +194,8 @@ const editingLeadId = ref<string | null>(null);
const createForm = ref({
title: '',
contactName: '',
gender: 'none' as PersonGender,
showGenderInName: false,
mobile: '',
city: '',
region: '',
@@ -656,12 +661,31 @@ const createModalTitle = computed(() =>
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
function bareLeadName(value: string) {
const split = splitPersonHonorific(value, '');
return [split.firstName, split.lastName].filter(Boolean).join(' ');
}
watch(
() => createForm.value.gender,
(value) => {
if (value === 'none') createForm.value.showGenderInName = false;
},
);
function openCreate() {
editingLeadId.value = null;
createError.value = '';
createForm.value = {
title: '',
contactName: '',
gender: 'none',
showGenderInName: false,
mobile: '',
city: '',
region: '',
@@ -693,8 +717,10 @@ function openEdit(lead: LeadDto) {
editingLeadId.value = lead.id;
createError.value = '';
createForm.value = {
title: lead.title,
contactName: lead.contactName ?? '',
title: bareLeadName(lead.title),
contactName: bareLeadName(lead.contactName ?? ''),
gender: lead.gender && lead.gender !== 'none' ? lead.gender : 'none',
showGenderInName: !!lead.showGenderInName && lead.gender !== 'none',
mobile: lead.mobile,
city: lead.city ?? '',
region: lead.region ?? '',
@@ -857,6 +883,8 @@ async function submitCreate() {
title: company || person || mobile,
mobile,
contactName: person || null,
gender: createForm.value.gender,
showGenderInName: createForm.value.gender !== 'none' && createForm.value.showGenderInName,
city: createForm.value.city.trim() || null,
region: createForm.value.region.trim() || null,
source: createForm.value.source.trim() || null,
@@ -1329,7 +1357,7 @@ onBeforeUnmount(() => {
<a-typography-text>
{{ toFaDigits(selectedIds.length) }} مورد انتخاب‌شده
</a-typography-text>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-select
v-model:value="assignUserId"
placeholder="تخصیص به کارشناس"
@@ -1338,6 +1366,8 @@ onBeforeUnmount(() => {
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
</template>
<template v-if="isAdmin">
<a-select
v-model:value="bulkStageId"
placeholder="تغییر وضعیت"
@@ -1629,6 +1659,24 @@ onBeforeUnmount(() => {
<a-input v-model:value="createForm.contactName" placeholder="مهدی احمدی" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="createForm.gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام مخاطب ذخیره می‌شود. اگر نام مخاطب خالی باشد روی عنوان می‌نشیند و با تبدیل به مشتری به ته‌حساب هم می‌رود."
>
<a-checkbox
v-model:checked="createForm.showGenderInName"
:disabled="createForm.gender === 'none'"
>
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="موبایل">
<template #extra>
@@ -1711,7 +1759,8 @@ onBeforeUnmount(() => {
<a-select
v-model:value="createForm.assigneeId"
allow-clear
placeholder="بدون مسئول"
:disabled="!canAssignOthers"
:placeholder="canAssignOthers ? 'بدون مسئول' : 'خودتان'"
style="width: 100%"
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
+11 -1
View File
@@ -137,7 +137,17 @@ async function submitPasswordLogin() {
const result = await auth.login(identifier.value.trim(), password.value);
await afterAuth(result);
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
const msg = apiError(e, 'ورود ناموفق بود');
error.value = msg;
// اگر مدیر «ورود با کد» را فعال کرده، مستقیم به تب OTP هدایت کن
if (msg.includes('ورود با کد') || msg.includes('رمز عبور برای این حساب غیرفعال')) {
const id = identifier.value.trim();
if (id && !id.includes('@')) mobile.value = id;
mode.value = 'otp';
otpStep.value = 'mobile';
challenge.value = null;
otpCode.value = '';
}
} finally {
loading.value = false;
}
+62 -4
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
@@ -34,10 +35,12 @@ import {
SearchOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonGender, PersonMood } from '@mento/shared';
import { PERSON_GENDER_LABELS, PERSON_GENDERS, splitPersonHonorific } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { useCustomerGroups } from '@/lib/customer-groups';
import {
filterPhoneContacts,
isContactPickerSupported,
@@ -84,6 +87,8 @@ const companies = ref<CompanyDto[]>([]);
const firstName = ref('');
const lastName = ref('');
const gender = ref<PersonGender>('none');
const showGenderInName = ref(false);
const email = ref('');
const phone = ref('');
const mobile = ref('');
@@ -97,7 +102,13 @@ const notes = ref('');
const tagsRaw = ref('');
const companyId = ref('');
const mood = ref<PersonMood>('newcomer');
const groupIds = ref<string[]>([]);
const customFields = ref<Record<string, unknown>>({});
const { groups: customerGroups, loaded: groupsLoaded, load: loadCustomerGroups } =
useCustomerGroups();
const groupOptions = computed(() =>
customerGroups.value.map((g) => ({ value: g.id, label: g.name })),
);
const loyaltyPoints = ref(0);
const loyaltyTierName = ref('');
@@ -118,6 +129,15 @@ const companyOptions = computed(() => [
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const genderOptions = PERSON_GENDERS.map((key) => ({
value: key,
label: PERSON_GENDER_LABELS[key],
}));
watch(gender, (value) => {
if (value === 'none') showGenderInName.value = false;
});
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -242,8 +262,12 @@ function pickFromVcf() {
}
function fill(p: PersonDto) {
firstName.value = p.firstName;
lastName.value = p.lastName;
const bare = splitPersonHonorific(p.firstName, p.lastName);
firstName.value = bare.firstName;
lastName.value = bare.lastName;
gender.value = p.gender && p.gender !== 'none' ? p.gender : (bare.gender ?? 'none');
showGenderInName.value =
gender.value !== 'none' && (p.showGenderInName || bare.gender != null);
email.value = p.email ?? '';
phone.value = p.phone ?? '';
mobile.value = p.mobile ?? '';
@@ -257,6 +281,7 @@ function fill(p: PersonDto) {
tagsRaw.value = (p.tags ?? []).join('، ');
companyId.value = p.company?.id ?? '';
mood.value = p.mood || 'newcomer';
groupIds.value = (p.groups ?? []).map((g) => g.id);
customFields.value = { ...(p.customFields ?? {}) };
loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
@@ -277,6 +302,7 @@ function removeAddress(index: number) {
async function load() {
loading.value = true;
error.value = '';
void loadCustomerGroups().catch(() => undefined);
try {
const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', {
params: { limit: 100 },
@@ -321,6 +347,8 @@ function payload(): CreatePersonRequest {
return {
firstName: firstName.value,
lastName: lastName.value,
gender: gender.value,
showGenderInName: gender.value !== 'none' && showGenderInName.value,
email: email.value || undefined,
phone: phone.value || undefined,
mobile: mobile.value || undefined,
@@ -334,6 +362,7 @@ function payload(): CreatePersonRequest {
tags: parseTags(tagsRaw.value),
companyId: companyId.value || null,
mood: mood.value,
groupIds: groupIds.value,
customFields: customFields.value,
};
}
@@ -452,6 +481,21 @@ onMounted(load);
<a-input v-model:value="lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="جنسیت">
<a-select v-model:value="gender" :options="genderOptions" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="نمایش در نام"
extra="مرد «جناب آقای» و زن «سرکار خانم» ابتدای نام ذخیره می‌شود و به ته‌حساب هم همین‌طور می‌رود."
>
<a-checkbox v-model:checked="showGenderInName" :disabled="gender === 'none'">
نمایش در نام
</a-checkbox>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-space-compact block>
@@ -559,6 +603,20 @@ onMounted(load);
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="گروه‌ها">
<a-select
v-model:value="groupIds"
mode="multiple"
allow-clear
option-filter-prop="label"
placeholder="بدون گروه"
:options="groupOptions"
:not-found-content="groupsLoaded ? 'گروهی تعریف نشده' : undefined"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
+24 -2
View File
@@ -47,9 +47,11 @@ import type {
QuoteDto,
TicketDto,
} from '@mento/shared';
import { PERSON_GENDER_LABELS } from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
@@ -83,7 +85,7 @@ const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'دارایی' },
{ key: 'assets', label: 'ته‌حساب و دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
if (canBehavior.value) {
@@ -166,6 +168,11 @@ const registeredAt = computed(() =>
const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
);
const genderLabel = computed(() => {
const key = person.value?.gender;
if (key === 'male' || key === 'female' || key === 'none') return PERSON_GENDER_LABELS[key];
return '—';
});
const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.customFields ?? {};
@@ -759,6 +766,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
<dl class="facts-grid">
<div class="fact">
<dt>گروه‌ها</dt>
<dd><CustomerGroupChips :groups="person.groups" empty="—" /></dd>
</div>
<div class="fact">
<dt>شرکت</dt>
<dd>{{ person.company?.name || '—' }}</dd>
@@ -767,6 +778,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd>
</div>
<div class="fact">
<dt>جنسیت</dt>
<dd>{{ genderLabel }}</dd>
</div>
<div class="fact">
<dt>تاریخ تولد</dt>
<dd>{{ birthDateLabel }}</dd>
@@ -809,6 +824,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
<CustomerGroupChips :groups="person.groups" />
</div>
</div>
<div class="contact-rows">
@@ -1037,7 +1053,12 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel :person-id="id" @link-changed="onTahesabLinkChanged" />
<PersonAssetsPanel
:person-id="id"
:person-name="fullName"
:person-phone="phoneNumber"
@link-changed="onTahesabLinkChanged"
/>
</div>
<div v-if="canBehavior && tab === 'disc'" class="panel">
@@ -1091,6 +1112,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>جنسیت</dt><dd>{{ genderLabel }}</dd></div>
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
+7 -2
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
@@ -21,6 +23,8 @@ const emit = defineEmits<{
cancel: [];
}>();
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'projects'));
const route = useRoute();
const router = useRouter();
const id = computed(() => {
@@ -231,10 +235,11 @@ onMounted(async () => {
</div>
<div class="field">
<label for="card-assignees">مسئولان کارت</label>
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi">
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi" :disabled="!canAssignOthers">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
<small class="hint">با نگه داشتن Ctrl می‌توانید چند نفر را انتخاب کنید.</small>
<small v-if="canAssignOthers" class="hint">با نگه داشتن Ctrl می‌توانید چند نفر را انتخاب کنید.</small>
<small v-else class="hint">بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت می‌شود.</small>
</div>
<div class="field">
<label for="card-notes">یادداشت</label>
File diff suppressed because it is too large Load Diff
+167
View File
@@ -496,6 +496,128 @@ async function importBulk() {
}
}
/** همهٔ بازه‌های کد خوانده می‌شود و گروه ته‌حساب روی مشتری‌های لینک‌شده نشانده می‌شود */
async function syncGroups() {
error.value = '';
ok.value = '';
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
importOverlay.open = true;
importOverlay.progress = null;
const totals = { rows: 0, linked: 0, updated: 0 };
try {
let page = 1;
let emptyStreak = 0;
while (emptyStreak < LIST_ALL_EMPTY_STOP) {
importOverlay.message = `در حال همگام‌سازی گروه‌ها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`;
const { data } = await api.post<{ rows: number; linked: number; updated: number }>(
'/integrations/tahesab/sync/groups',
{ connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE },
{ timeout: 120_000 },
);
totals.rows += data.rows;
totals.linked += data.linked;
totals.updated += data.updated;
emptyStreak = data.rows ? 0 : emptyStreak + 1;
page += 1;
}
ok.value = `گروه‌ها همگام شد: ${toFaDigits(totals.linked)} مشتری لینک‌شده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر به‌روز شد.`;
} catch (e) {
error.value = apiErr(e, 'همگام‌سازی گروه‌ها ناموفق بود');
} finally {
importOverlay.open = false;
syncBusy.value = false;
}
}
type ExportItem = {
personId: string;
name: string;
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
moshtariCode?: number;
error?: string;
};
const EXPORT_CHUNK = 10;
const exportIssues = ref<ExportItem[]>([]);
/** شخص‌های لینک‌نشده: اول تطبیق، بعد ساخت در ته‌حساب (بدون ساخت تکراری) */
async function exportUnlinked() {
error.value = '';
ok.value = '';
exportIssues.value = [];
if (!sync.connectionId) {
error.value = 'ابتدا اتصال را انتخاب کنید.';
return;
}
syncBusy.value = true;
try {
const { data: unlinked } = await api.get<{ ids: string[]; total: number }>(
'/integrations/tahesab/sync/unlinked-people',
{ params: { connectionId: sync.connectionId } },
);
if (!unlinked.total) {
ok.value = 'همهٔ اشخاص به ته‌حساب لینک هستند.';
return;
}
if (
!confirm(
`${toFaDigits(unlinked.total)} شخص به ته‌حساب لینک نیستند.\n` +
'برای هر نفر اول با موبایل/نام در ته‌حساب جستجو می‌شود: اگر دقیقاً یک نفر پیدا شد فقط لینک می‌شود، ' +
'اگر چند نفر پیدا شد رد می‌شود تا دستی بررسی کنید، و اگر پیدا نشد در ته‌حساب ساخته می‌شود. ادامه؟',
)
) {
return;
}
importOverlay.open = true;
importOverlay.progress = 0;
const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 };
const ids = unlinked.ids;
for (let i = 0; i < ids.length; i += EXPORT_CHUNK) {
const chunk = ids.slice(i, i + EXPORT_CHUNK);
importOverlay.message = `در حال ثبت در ته‌حساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`;
try {
const { data } = await api.post<typeof totals & { items: ExportItem[] }>(
'/integrations/tahesab/sync/export-people',
{ connectionId: sync.connectionId, personIds: chunk },
{ timeout: 180_000 },
);
totals.matched += data.matched;
totals.created += data.created;
totals.ambiguous += data.ambiguous;
totals.skipped += data.skipped;
totals.failed += data.failed;
exportIssues.value.push(
...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'),
);
} catch (e) {
totals.failed += chunk.length;
exportIssues.value.push(
...chunk.map((personId) => ({
personId,
name: '',
status: 'error' as const,
error: apiErr(e, 'خطا در ارسال'),
})),
);
}
importOverlay.progress = ((i + chunk.length) / ids.length) * 100;
}
ok.value =
`ثبت در ته‌حساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` +
`ساخته‌شده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` +
`ناموفق ${toFaDigits(totals.failed)}`;
} catch (e) {
error.value = apiErr(e, 'ثبت گروهی در ته‌حساب ناموفق بود');
} finally {
importOverlay.open = false;
importOverlay.progress = null;
syncBusy.value = false;
}
}
async function searchProducts() {
error.value = '';
ok.value = '';
@@ -807,6 +929,43 @@ onMounted(load);
</div>
</div>
<div class="sync-sub-section">
<h4 class="sync-subhead">۳) گروه‌ها و ثبت مشتری‌های منتکت در ته‌حساب</h4>
<p class="sync-sub-desc">
«همگام‌سازی گروه‌ها» گروه هر مشتری لینک‌شده را از ته‌حساب می‌خواند؛ فقط گروهِ آمده از ته‌حساب عوض
می‌شود و گروه‌هایی که دستی داده‌اید می‌مانند.
«ثبت لینک‌نشده‌ها» برای هر شخصی که به ته‌حساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان
می‌کند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته می‌شود.
</p>
<div class="sync-buttons">
<UiButton
variant="secondary"
:disabled="!sync.connectionId || syncBusy"
@click="syncGroups"
>
همگام‌سازی گروه‌ها از ته‌حساب
</UiButton>
<UiButton :disabled="!sync.connectionId || syncBusy" @click="exportUnlinked">
ثبت مشتری‌های لینک‌نشده در ته‌حساب
</UiButton>
</div>
<div v-if="exportIssues.length" class="export-issues">
<p class="sync-sub-desc">
این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «ته‌حساب و دارایی»
دستی لینک یا ثبت کنید:
</p>
<ul>
<li v-for="it in exportIssues" :key="it.personId">
<RouterLink :to="{ name: 'person-detail', params: { id: it.personId } }">
{{ it.name || 'بدون نام' }}
</RouterLink>
—
{{ it.status === 'ambiguous' ? 'چند مشتری مشابه در ته‌حساب پیدا شد' : it.error || 'خطا' }}
</li>
</ul>
</div>
</div>
<div v-if="syncResults.length" class="sync-results-box">
<h4 class="sync-results-title">
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
@@ -1176,6 +1335,14 @@ onMounted(load);
flex-wrap: wrap;
}
.export-issues ul {
margin: var(--sp-2) 0 0;
padding-inline-start: 1.2rem;
font-size: var(--fs-xs);
max-height: 14rem;
overflow-y: auto;
}
.sync-table-pagination {
margin-top: var(--sp-3);
}
+319 -21
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -10,7 +11,9 @@ import { useAuthStore } from '@/stores/auth';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu, UiModal } from '@/components/ui';
import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
@@ -29,6 +32,7 @@ import {
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
@@ -52,10 +56,18 @@ const router = useRouter();
/** مجوزی که ماژولش در سوپرادمین خاموش است در نقش‌ها نشان داده نمی‌شود. */
const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
customers: ['customers'],
assign_customers: ['customers'],
leads: ['leads'],
assign_leads: ['leads'],
sales: ['pipeline'],
assign_sales: ['pipeline'],
billing: ['invoices', 'quotes'],
products: ['products'],
activities: ['activities'],
assign_activities: ['activities'],
assign_routines: ['routines'],
assign_projects: ['project_boards'],
assign_tickets: ['tickets'],
notes: ['notes'],
reports: ['reports'],
audit: ['audit_logs'],
@@ -87,6 +99,8 @@ const orgName = ref('');
const businessField = ref('');
const orgSaving = ref(false);
const accessSaving = ref(false);
const roleFormDataAccess = ref<DataAccessPolicy>({});
const focusDataAccess = ref<DataAccessPolicy>({});
const tab = ref<TabKey>('users');
const query = ref('');
@@ -503,20 +517,74 @@ async function toggleStatus(user: OrgUserDto) {
}
}
const LOGIN_OTP_ONLY_TIP =
'ورود فقط با کد پیامکی؛ رمز عبور کار نمی‌کند — حتی اگر از قبل رمز داشته باشد.';
async function toggleLoginOtpOnly(user: OrgUserDto) {
menuUserId.value = null;
if (user.role === 'owner') return;
error.value = '';
ok.value = '';
try {
const next = !user.loginOtpOnly;
await api.patch(`/users/${user.id}/login-otp-only`, { loginOtpOnly: next });
ok.value = next
? `«${user.fullName}» فقط با کد پیامکی وارد می‌شود.`
: `ورود با رمز برای «${user.fullName}» دوباره فعال شد.`;
await load();
} catch (e) {
error.value = apiError(e, 'تغییر شیوهٔ ورود ناموفق بود');
}
}
const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null);
const logoutSaving = ref(false);
function openLogoutModal(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
logoutModal.value = { user, time: user.logoutTime || '18:00' };
}
async function changeLogoutTime(user: OrgUserDto, raw: string) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
const next = raw.trim();
if (next === (user.logoutTime ?? '')) return;
if (next === (user.logoutTime ?? '')) {
logoutModal.value = null;
return;
}
error.value = '';
ok.value = '';
logoutSaving.value = true;
try {
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
ok.value = next
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
logoutModal.value = null;
await load();
} catch (e) {
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
} finally {
logoutSaving.value = false;
}
}
async function toggleBlockAfterLogout(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner' || !user.logoutTime) return;
const next = !user.blockLoginAfterLogout;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${user.id}/logout-time`, {
logoutTime: user.logoutTime,
blockLoginAfterLogout: next,
});
ok.value = next
? `«${user.fullName}» بعد از ساعت ${toFaDigits(user.logoutTime)} دیگر نمی‌تواند وارد شود.`
: `«${user.fullName}» بعد از ساعت خروج هم می‌تواند وارد شود.`;
await load();
} catch (e) {
error.value = apiError(e, 'تغییر محدودیت ورود ناموفق بود');
}
}
@@ -547,6 +615,7 @@ function openCreateRole(parentId?: string | null) {
roleFormPerms.value = parent
? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -566,6 +635,7 @@ function openEditRole() {
: 'teal') as RoleColor;
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormOpen.value = true;
}
@@ -595,12 +665,14 @@ async function submitRoleForm() {
description: string;
color: RoleColor;
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
@@ -657,6 +729,50 @@ async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
}
}
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 withOpenSections(policy: DataAccessPolicy): DataAccessPolicy {
const out = clonePolicy(policy);
for (const key of DATA_ACCESS_SECTIONS) {
if (!out[key]) out[key] = { mode: 'all', rules: [] };
}
return out;
}
function syncFocusDataAccess() {
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
}
async function saveFocusDataAccess() {
const role = accessFocusRole.value;
if (!role || !canEditRoles.value) return;
accessSaving.value = true;
error.value = '';
try {
const dataAccessPolicy = withOpenSections(focusDataAccess.value);
focusDataAccess.value = dataAccessPolicy;
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy });
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
await load();
syncFocusDataAccess();
} catch (e) {
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
} finally {
accessSaving.value = false;
}
}
function onDocClick() {
menuUserId.value = null;
}
@@ -692,10 +808,15 @@ watch(
if (!list.some((r) => r.id === accessFocusId.value)) {
accessFocusId.value = list[0].id;
}
syncFocusDataAccess();
},
{ immediate: true },
);
watch(accessFocusId, () => {
syncFocusDataAccess();
});
onMounted(() => {
syncViewport();
window.addEventListener('resize', syncViewport);
@@ -826,6 +947,29 @@ onUnmounted(() => {
</tbody>
</table>
</div>
<div v-if="accessFocusRole" class="data-access-block">
<div class="matrix-head">
<h3>محدوده داده — {{ accessFocusRole.name }}</h3>
<button
v-if="canEditRoles"
type="button"
class="btn primary"
:disabled="accessSaving"
@click="saveFocusDataAccess"
>
{{ accessSaving ? 'در حال ذخیره…' : 'ذخیره محدوده' }}
</button>
</div>
<p class="hint">
برای هر بخش می‌توانید همه را باز بگذارید یا با فیلتر (مالک، گروه، مرحله و…) محدود کنید.
</p>
<DataAccessPolicyPanel
v-model="focusDataAccess"
:enabled-permissions="accessFocusRole.permissions"
:disabled="!canEditRoles"
/>
</div>
</section>
</div>
@@ -1046,19 +1190,45 @@ onUnmounted(() => {
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span>
<span
v-if="user.loginOtpOnly"
class="otp-badge"
:title="LOGIN_OTP_ONLY_TIP"
>
ورود با کد
</span>
</td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" @click.stop>
<span v-if="user.role === 'owner'" class="muted">—</span>
<input
v-else
class="logout-time"
type="time"
dir="ltr"
:value="user.logoutTime || ''"
:aria-label="`ساعت خروج ${user.fullName}`"
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
/>
<span v-else class="logout-cell">
<button
type="button"
class="logout-btn"
:class="{ set: Boolean(user.logoutTime) }"
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
@click="openLogoutModal(user)"
>
<span v-if="user.logoutTime" dir="ltr">{{ toFaDigits(user.logoutTime) }}</span>
<UiIcon v-else name="clock" :size="16" />
</button>
<button
v-if="user.logoutTime"
type="button"
class="block-btn"
:class="{ on: user.blockLoginAfterLogout }"
:aria-pressed="Boolean(user.blockLoginAfterLogout)"
:title="
user.blockLoginAfterLogout
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
"
@click="toggleBlockAfterLogout(user)"
>
<UiIcon :name="user.blockLoginAfterLogout ? 'check' : 'x'" :size="14" />
</button>
</span>
</td>
<td class="ops" @click.stop>
<UiDropdownMenu
@@ -1078,6 +1248,15 @@ onUnmounted(() => {
>
ویرایش مشخصات
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
:class="{ on: user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
@@ -1130,6 +1309,16 @@ onUnmounted(() => {
>
ویرایش
</button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
class="btn ghost"
:class="{ 'otp-on': user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
</div>
</article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
@@ -1204,6 +1393,43 @@ onUnmounted(() => {
</template>
</UiModal>
<UiModal
:open="Boolean(logoutModal)"
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
@close="logoutModal = null"
>
<form v-if="logoutModal" class="form" @submit.prevent="changeLogoutTime(logoutModal.user, logoutModal.time)">
<label>
ساعت خروج روزانه
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
</label>
<p class="hint">
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید
ورود دوباره تا پایان همان روز را هم ببندید.
</p>
</form>
<template #footer>
<button
v-if="logoutModal?.user.logoutTime"
type="button"
class="btn ghost"
:disabled="logoutSaving"
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
>
حذف ساعت خروج
</button>
<button type="button" class="btn ghost" @click="logoutModal = null">انصراف</button>
<button
type="button"
class="btn primary"
:disabled="logoutSaving || !logoutModal?.time"
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
>
{{ logoutSaving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
</template>
</UiModal>
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
<form class="form" @submit.prevent="submitEditUser">
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
@@ -1270,6 +1496,14 @@ onUnmounted(() => {
{{ perm.label }}
</label>
</fieldset>
<fieldset class="perm-set">
<legend>محدوده داده</legend>
<DataAccessPolicyPanel
v-model="roleFormDataAccess"
:enabled-permissions="roleFormPerms"
compact
/>
</fieldset>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
@@ -1298,6 +1532,20 @@ onUnmounted(() => {
min-width: 0;
}
.data-access-block {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.75rem;
}
.data-access-block .hint {
color: #64748b;
font-size: 0.85rem;
margin: 0;
}
.page-head {
display: flex;
justify-content: flex-start;
@@ -1621,6 +1869,25 @@ tbody tr + tr td {
color: #94a3b8;
}
.otp-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.45rem;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
cursor: help;
}
.btn.otp-on {
color: #0f766e;
border-color: #99f6e4;
background: #ecfdf5;
}
.ghost-ico {
width: 32px;
height: 32px;
@@ -1874,22 +2141,53 @@ tbody tr + tr td {
color: #94a3b8;
}
.logout-time {
width: 7.2rem;
min-height: 2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0 0.45rem;
font: inherit;
background: #fff;
box-sizing: border-box;
.logout-cell {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.logout-time:disabled {
background: #f8fafc;
.logout-btn,
.block-btn {
display: inline-grid;
place-items: center;
height: 1.9rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
font: inherit;
cursor: pointer;
}
.logout-btn {
min-width: 1.9rem;
padding: 0 0.4rem;
}
.logout-btn.set {
color: #0f172a;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.logout-btn:hover,
.block-btn:hover {
border-color: #0d9488;
color: #0f766e;
}
.block-btn {
width: 1.9rem;
color: #94a3b8;
}
.block-btn.on {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.roles-grid,
.roles-tree {
display: grid;
+2 -2
View File
@@ -4,7 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import { canViewAllRecords } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import {
UiBadge,
@@ -61,7 +61,7 @@ const faqHints = ref<FaqArticleDto[]>([]);
const csatScore = ref<number | null>(null);
const csatComment = ref('');
const canAssignOthers = computed(() => canViewAllRecords(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'tickets'));
const showCsat = computed(() => {
if (!ticket.value || isNew.value) return false;
+168 -2
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
import type {
CreatePlatformAdminRequest,
PlatformAdminDto,
PlatformMasterPasswordStatusDto,
UpdatePlatformAdminRequest,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
@@ -17,6 +22,13 @@ const creating = ref(false);
const resetId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
const editId = ref<string | null>(null);
const editing = ref(false);
const deleteId = ref<string | null>(null);
const editForm = reactive({ fullName: '', mobile: '', email: '' });
const masterEnabled = ref(false);
const masterPassword = ref('');
const masterBusy = ref(false);
const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '',
@@ -39,6 +51,12 @@ async function load() {
try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data;
try {
const master = await platformApi.get<PlatformMasterPasswordStatusDto>('/platform/master-password');
masterEnabled.value = master.data.enabled;
} catch {
masterEnabled.value = false;
}
} catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally {
@@ -46,6 +64,81 @@ async function load() {
}
}
async function saveMasterPassword(clear = false) {
masterBusy.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.put<PlatformMasterPasswordStatusDto>(
'/platform/master-password',
{ password: clear ? null : masterPassword.value },
);
masterEnabled.value = data.enabled;
masterPassword.value = '';
success.value = clear ? 'رمز کلی برداشته شد' : 'رمز کلی ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره رمز کلی ناموفق بود');
} finally {
masterBusy.value = false;
}
}
function startEdit(admin: PlatformAdminDto) {
editId.value = editId.value === admin.id ? null : admin.id;
deleteId.value = null;
resetId.value = null;
editForm.fullName = admin.fullName;
editForm.mobile = admin.mobile || '';
editForm.email = admin.email || '';
error.value = '';
success.value = '';
}
async function submitEdit(id: string) {
editing.value = true;
error.value = '';
success.value = '';
try {
const body: UpdatePlatformAdminRequest = {
fullName: editForm.fullName.trim(),
mobile: editForm.mobile.trim(),
email: editForm.email.trim() || null,
};
const { data } = await platformApi.patch<PlatformAdminDto>(`/platform/admins/${id}/profile`, body);
items.value = items.value.map((row) => (row.id === id ? data : row));
editId.value = null;
success.value = 'مشخصات ادمین به‌روز شد';
} catch (e) {
error.value = apiError(e, 'ویرایش ادمین ناموفق بود');
} finally {
editing.value = false;
}
}
function askDelete(id: string) {
deleteId.value = deleteId.value === id ? null : id;
editId.value = null;
resetId.value = null;
error.value = '';
success.value = '';
}
async function submitDelete(id: string) {
busyId.value = id;
error.value = '';
success.value = '';
try {
await platformApi.delete(`/platform/admins/${id}`);
items.value = items.value.filter((row) => row.id !== id);
deleteId.value = null;
success.value = 'ادمین حذف شد';
} catch (e) {
error.value = apiError(e, 'حذف ادمین ناموفق بود');
} finally {
busyId.value = '';
}
}
function resetCreateForm() {
createForm.fullName = '';
createForm.mobile = '';
@@ -125,12 +218,38 @@ onMounted(() => {
<template>
<div class="page">
<header class="head">
<p class="lead">افزودن سوپرادمین با موبایل، فعال/غیرفعال و ریست رمز.</p>
<p class="lead">افزودن، ویرایش و حذف سوپرادمین. رمز کلی با موبایل هر کاربر وارد حساب CRM او می‌شود.</p>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button>
</header>
<form class="panel" @submit.prevent="saveMasterPassword(false)">
<h2>رمز کلی ورود به حساب کاربران</h2>
<p class="muted">
{{ masterEnabled ? 'رمز کلی فعال است.' : 'رمز کلی تنظیم نشده.' }}
با موبایل خود کاربر و این رمز، وارد حساب او در CRM می‌شوید. ورود با کد و محدودیت ساعت خروج مانع این رمز نیست.
</p>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input v-model="masterPassword" type="password" minlength="8" autocomplete="new-password" />
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="masterBusy || masterPassword.trim().length < 8">
{{ masterBusy ? 'در حال ذخیره…' : 'ذخیره رمز کلی' }}
</button>
<button
v-if="masterEnabled"
class="btn btn-ghost"
type="button"
:disabled="masterBusy"
@click="saveMasterPassword(true)"
>
برداشتن رمز
</button>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
@@ -188,6 +307,9 @@ onMounted(() => {
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">
ویرایش
</button>
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز
</button>
@@ -199,6 +321,49 @@ onMounted(() => {
>
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button
class="btn btn-ghost"
type="button"
:disabled="admin.id === platform.admin?.id"
@click="askDelete(admin.id)"
>
حذف
</button>
</div>
<form
v-if="editId === admin.id"
class="reset-panel"
@submit.prevent="submitEdit(admin.id)"
>
<div class="grid">
<label>
<span>نام</span>
<input v-model="editForm.fullName" required minlength="2" />
</label>
<label>
<span>موبایل</span>
<input v-model="editForm.mobile" type="tel" required dir="ltr" />
</label>
<label>
<span>ایمیل (اختیاری)</span>
<input v-model="editForm.email" type="email" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="editing">ذخیره</button>
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">انصراف</button>
</div>
</form>
<div v-if="deleteId === admin.id" class="reset-panel">
<p class="muted">ادمین «{{ admin.fullName }}» حذف شود؟</p>
<div class="panel-actions">
<button class="btn" type="button" :disabled="busyId === admin.id" @click="submitDelete(admin.id)">
حذف
</button>
<button class="btn btn-ghost" type="button" @click="askDelete(admin.id)">انصراف</button>
</div>
</div>
<form
@@ -353,6 +518,7 @@ input {
display: flex;
gap: 0.45rem;
align-items: flex-start;
flex-wrap: wrap;
}
.on,
+245 -2
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type { BillingAccountDto, BillingPlanCode, OwnerSeatUsageDto, StorageUsageDto } from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
BILLING_USER_CAPS,
isPermanentBillingPlan,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { platformApi } from '@/lib/platform-api';
defineProps<{
const props = defineProps<{
billing?: BillingAccountDto | null;
busyId: string;
userId: string;
@@ -16,6 +20,140 @@ defineProps<{
const emit = defineEmits<{
apply: [planCode: BillingPlanCode];
}>();
const usage = ref<StorageUsageDto | null>(null);
const seats = ref<OwnerSeatUsageDto | null>(null);
const customUsers = ref('');
const seatBusy = ref(false);
const seatError = ref('');
const quotaGb = ref('');
const storageBusy = ref(false);
const storageError = ref('');
function formatBytes(bytes: number): string {
const n = Math.max(0, bytes);
const units: Array<[number, string]> = [
[1024 ** 3, 'گیگابایت'],
[1024 ** 2, 'مگابایت'],
[1024, 'کیلوبایت'],
];
for (const [size, label] of units) {
if (n >= size) {
const value = n / size;
const text = value >= 10 ? value.toFixed(0) : value.toFixed(1);
return `${toFaDigits(text)} ${label}`;
}
}
return `${toFaDigits(Math.round(n))} بایت`;
}
function fillQuota(next: StorageUsageDto) {
usage.value = next;
quotaGb.value =
next.quotaBytes == null ? '' : String(Math.round((next.quotaBytes / 1024 ** 3) * 10) / 10);
}
function errorText(err: unknown, fallback: string) {
if (axios.isAxiosError(err)) {
const raw = (err.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(raw)) return raw.join(' ');
if (raw) return raw;
}
return fallback;
}
async function loadStorage(refresh = false) {
storageError.value = '';
try {
const { data } = await platformApi.get<StorageUsageDto>(
`/platform/users/${props.userId}/storage`,
{ params: refresh ? { refresh: '1' } : undefined },
);
fillQuota(data);
} catch (err) {
storageError.value = errorText(err, 'حجم فایل خوانده نشد');
}
}
async function saveQuota(quota: number | null) {
storageBusy.value = true;
storageError.value = '';
try {
const { data } = await platformApi.patch<StorageUsageDto>(
`/platform/users/${props.userId}/billing/storage`,
{ quotaGb: quota },
);
fillQuota(data);
} catch (err) {
storageError.value = errorText(err, 'سقف حجم ذخیره نشد');
} finally {
storageBusy.value = false;
}
}
function saveTypedQuota() {
const raw = quotaGb.value.trim().replace(/,/g, '');
if (!raw) {
storageError.value = 'سقف را به گیگابایت وارد کنید، یا «بدون سقف» را بزنید';
return;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
storageError.value = 'سقف حجم نامعتبر است';
return;
}
void saveQuota(value);
}
async function loadSeats() {
seatError.value = '';
try {
const { data } = await platformApi.get<OwnerSeatUsageDto>(`/platform/users/${props.userId}/seats`);
seats.value = data;
customUsers.value =
data.maxUsers != null && !(BILLING_USER_CAPS as readonly number[]).includes(data.maxUsers)
? String(data.maxUsers)
: '';
} catch (err) {
seatError.value = errorText(err, 'تعداد کاربر خوانده نشد');
}
}
async function saveUserCap(maxUsers: number) {
seatBusy.value = true;
seatError.value = '';
try {
const { data } = await platformApi.patch<OwnerSeatUsageDto>(
`/platform/users/${props.userId}/billing/users`,
{ maxUsers },
);
seats.value = data;
customUsers.value =
data.maxUsers != null && !(BILLING_USER_CAPS as readonly number[]).includes(data.maxUsers)
? String(data.maxUsers)
: '';
} catch (err) {
seatError.value = errorText(err, 'سقف کاربر ذخیره نشد');
} finally {
seatBusy.value = false;
}
}
function saveCustomUsers() {
const value = Number(customUsers.value.trim());
if (!Number.isInteger(value) || value < 21) {
seatError.value = 'برای بالای ۲۰ کاربر، عدد توافقی را از ۲۱ به بالا وارد کنید';
return;
}
void saveUserCap(value);
}
onMounted(() => {
if (props.billing) {
void loadStorage();
void loadSeats();
}
});
</script>
<template>
@@ -40,6 +178,78 @@ const emit = defineEmits<{
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
<div v-if="billing" class="storage">
<strong>تعداد کاربر</strong>
<p v-if="seats" class="muted">
فعال {{ toFaDigits(seats.usedUsers) }}
<template v-if="seats.maxUsers == null"> · سقف تعیین نشده</template>
<template v-else> از {{ toFaDigits(seats.maxUsers) }}</template>
</p>
<p v-else-if="!seatError" class="muted">در حال خواندن کاربران…</p>
<p v-if="seats?.maxUsers != null && seats.usedUsers > seats.maxUsers" class="error">
تعداد فعلی از سقف بیشتر است. تا کم شدن کاربران، افزودن کاربر جدید بسته است.
</p>
<div class="plan-row">
<button
v-for="cap in BILLING_USER_CAPS"
:key="cap"
class="btn"
:class="{ 'btn-ghost': seats?.maxUsers !== cap }"
type="button"
:disabled="seatBusy"
@click="saveUserCap(cap)"
>
تا {{ toFaDigits(cap) }} کاربر
</button>
</div>
<label class="quota">
بالای ۲۰، توافقی
<input v-model="customUsers" type="number" min="21" step="1" inputmode="numeric" />
</label>
<div class="plan-row">
<button class="btn" type="button" :disabled="seatBusy" @click="saveCustomUsers">
ذخیره سقف توافقی
</button>
</div>
<p v-if="seatError" class="error">{{ seatError }}</p>
</div>
<div v-if="billing" class="storage">
<strong>حجم فایل</strong>
<p v-if="usage" class="muted">
مصرف {{ formatBytes(usage.usedBytes) }}
<template v-if="usage.quotaBytes == null"> · بدون سقف</template>
<template v-else>
از {{ formatBytes(usage.quotaBytes) }}
· {{ toFaDigits(usage.percent ?? 0) }}٪
</template>
</p>
<p v-else-if="!storageError" class="muted">در حال خواندن مصرف…</p>
<ul v-if="usage?.byOrganization?.length" class="orgs">
<li v-for="org in usage.byOrganization" :key="org.id">
{{ org.name }} · {{ formatBytes(org.usedBytes) }}
</li>
</ul>
<p class="muted">
عکس و صوت فعالیت، پیوست یادداشت، لوگو، کاور فرم، تصویر پروفایل، پیوست مالی و فایل گفتگو حساب می‌شود.
متن مشتری، فعالیت و فاکتور حساب نمی‌شود. فایل‌های قدیمی گفتگو که قبلاً روی سرور خارجی رفته‌اند داخل این عدد نیستند.
</p>
<label class="quota">
سقف حجم (گیگابایت)
<input v-model="quotaGb" type="number" min="0" step="0.5" inputmode="decimal" />
</label>
<div class="plan-row">
<button class="btn" type="button" :disabled="storageBusy" @click="saveTypedQuota">
ذخیره سقف
</button>
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="saveQuota(null)">
بدون سقف
</button>
<button class="btn btn-ghost" type="button" :disabled="storageBusy" @click="loadStorage(true)">
محاسبه دوباره
</button>
</div>
<p v-if="storageError" class="error">{{ storageError }}</p>
</div>
</div>
</template>
@@ -65,4 +275,37 @@ const emit = defineEmits<{
flex-wrap: wrap;
gap: 0.4rem;
}
.storage {
display: grid;
gap: 0.45rem;
padding-top: 0.35rem;
border-top: 1px solid var(--line);
}
.orgs {
margin: 0;
padding: 0 1.1rem 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.quota {
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
}
.quota input {
width: 8rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--line);
border-radius: 8px;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformOrganizationDto,
PlatformOrgMemberDto,
} from '@mento/shared';
import { BILLING_PLAN_LABELS } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import AdmBillingPanel from './AdmBillingPanel.vue';
const orgs = ref<PlatformOrganizationDto[]>([]);
const orgId = ref('');
const owners = ref<PlatformOrgMemberDto[]>([]);
const loading = ref(false);
const error = ref('');
const success = ref('');
const busyId = ref('');
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() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function loadOwners() {
owners.value = [];
success.value = '';
error.value = '';
if (!orgId.value) return;
loading.value = true;
try {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${orgId.value}`,
);
owners.value = (data.members ?? []).filter((member) => member.role === 'owner');
} catch (e) {
error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود');
} finally {
loading.value = false;
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
await platformApi.post(`/platform/users/${userId}/billing/apply-plan`, body);
await loadOwners();
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void loadOrgs();
});
</script>
<template>
<div class="page">
<p class="lead">پلن، تعداد کاربر و حجم فایل اشتراک را برای محیط انتخاب‌شده تنظیم کنید.</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
<div v-else class="panel">
<label>
<span>محیط</span>
<select v-model="orgId" @change="loadOwners">
<option value="">انتخاب محیط</option>
<option v-for="org in orgs" :key="org.id" :value="org.id">
{{ org.name }}
</option>
</select>
</label>
<p v-if="orgId && loading" class="muted">در حال خواندن اشتراک…</p>
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
<AdmBillingPanel
v-for="owner in owners"
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}`"
:user-id="owner.userId"
:billing="owner.billing"
:busy-id="busyId"
@apply="applyPlan(owner.userId, $event)"
/>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
label {
display: grid;
gap: 0.35rem;
max-width: 24rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
ConversationSettingsDto,
PlatformOrganizationDto,
UpdateConversationSettingsRequest,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
const orgs = ref<PlatformOrganizationDto[]>([]);
const orgId = ref('');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const success = ref('');
const apiKeySet = ref(false);
const loaded = ref(false);
const form = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
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() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function loadSettings() {
loaded.value = false;
success.value = '';
error.value = '';
form.chatHost = '';
form.apiKey = '';
form.uploadUrl = '';
form.downloadUrl = '';
apiKeySet.value = false;
if (!orgId.value) return;
loading.value = true;
try {
const { data } = await platformApi.get<ConversationSettingsDto>(
`/platform/organizations/${orgId.value}/conversations/settings`,
);
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
apiKeySet.value = data.apiKeySet;
loaded.value = true;
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
} finally {
loading.value = false;
}
}
async function save() {
if (!orgId.value) return;
saving.value = true;
error.value = '';
success.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: form.chatHost.trim(),
uploadUrl: form.uploadUrl.trim() || undefined,
downloadUrl: form.downloadUrl.trim() || undefined,
};
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
const { data } = await platformApi.patch<ConversationSettingsDto>(
`/platform/organizations/${orgId.value}/conversations/settings`,
payload,
);
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
loaded.value = true;
success.value = 'تنظیمات گفتگو ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
} finally {
saving.value = false;
}
}
onMounted(() => {
void loadOrgs();
});
</script>
<template>
<div class="page">
<p class="lead">Host، API Key و آدرس آپلود/دانلود را برای محیط انتخاب‌شده تنظیم کنید.</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
<form v-else class="panel" @submit.prevent="save">
<label>
<span>محیط</span>
<select v-model="orgId" @change="loadSettings">
<option value="">انتخاب محیط</option>
<option v-for="org in orgs" :key="org.id" :value="org.id">
{{ org.name }}
</option>
</select>
</label>
<template v-if="orgId && loaded">
<div class="grid">
<label>
<span>Host سرویس چت</span>
<input v-model="form.chatHost" dir="ltr" placeholder="chat.example.ir" autocomplete="off" />
</label>
<label>
<span>{{ apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
<input v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
</label>
<label>
<span>آدرس آپلود فایل</span>
<input v-model="form.uploadUrl" dir="ltr" placeholder="https://chat.example.ir/upload" />
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input v-model="form.downloadUrl" dir="ltr" placeholder="https://chat.example.ir/files" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره گفتگو' }}
</button>
</div>
</template>
<p v-else-if="orgId && loading" class="muted">در حال خواندن تنظیمات…</p>
</form>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
input,
select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+223 -14
View File
@@ -1,17 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { PlatformConversationStatsDto, PlatformStatsDto } from '@mento/shared';
import type {
PlatformConversationStatsDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import { BILLING_PLAN_LABELS, USER_ROLE_LABELS } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
type FilterKey = 'organizations' | 'users' | 'memberships' | 'expiring' | 'expired' | 'chat';
const platform = usePlatformAuthStore();
const loading = ref(true);
const error = ref('');
const listError = ref('');
const listLoading = ref(false);
const stats = ref<PlatformStatsDto | null>(null);
const chatStats = ref<PlatformConversationStatsDto | null>(null);
const filter = ref<FilterKey | null>(null);
const orgs = ref<PlatformOrganizationDto[] | null>(null);
const users = ref<PlatformUserDto[] | null>(null);
const filterTitle: Record<FilterKey, string> = {
organizations: 'محیط‌ها',
users: 'کاربران',
memberships: 'عضویت‌ها',
expiring: 'نزدیک انقضا',
expired: 'منقضی',
chat: 'گفتگوی پیکربندی‌شده',
};
async function load() {
error.value = '';
@@ -30,6 +53,105 @@ async function load() {
}
}
function isExpiring(sub: SubscriptionSummaryDto | null | undefined) {
return Boolean(sub && sub.status === 'active' && sub.daysRemaining > 0 && sub.daysRemaining <= 3);
}
function isExpired(sub: SubscriptionSummaryDto | null | undefined) {
if (!sub) return false;
return sub.status === 'expired' || (sub.status === 'active' && sub.daysRemaining <= 0);
}
async function ensureLists() {
listLoading.value = true;
listError.value = '';
try {
if (!orgs.value) {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
}
if (!users.value && (filter.value === 'users' || filter.value === 'memberships')) {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users');
users.value = data;
}
} catch {
listError.value = 'بارگذاری فهرست ناموفق بود';
} finally {
listLoading.value = false;
}
}
function billingRows(mode: 'expiring' | 'expired') {
const map = new Map<string, { id: string; title: string; orgs: string[]; plan: string; when: string }>();
for (const org of orgs.value ?? []) {
const sub = org.subscription;
const match = mode === 'expiring' ? isExpiring(sub) : isExpired(sub);
if (!match || !sub) continue;
const key = org.ownerUserId || org.id;
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
const when =
mode === 'expiring'
? `${toFaDigits(sub.daysRemaining)} روز مانده`
: 'نیاز به تمدید';
const current = map.get(key);
if (current) {
current.orgs.push(org.name);
continue;
}
map.set(key, {
id: key,
title: org.ownerName || org.name,
orgs: [org.name],
plan,
when,
});
}
return [...map.values()].map((row) => ({
id: row.id,
title: row.title,
meta: `${row.orgs.join('، ')} · ${row.plan} · ${row.when}`,
}));
}
const rows = computed(() => {
if (filter.value === 'organizations') {
return (orgs.value ?? []).map((org) => ({
id: org.id,
title: org.name,
meta: org.isActive ? 'فعال' : 'غیرفعال',
}));
}
if (filter.value === 'users') {
return (users.value ?? []).map((user) => ({
id: user.id,
title: user.fullName,
meta: `${user.mobile || user.email} · ${user.isActive ? 'فعال' : 'غیرفعال'}`,
}));
}
if (filter.value === 'memberships') {
return (users.value ?? []).flatMap((user) =>
user.memberships.map((mem) => ({
id: mem.membershipId,
title: user.fullName,
meta: `${mem.organizationName} · ${USER_ROLE_LABELS[mem.role]} · ${mem.isActive ? 'فعال' : 'غیرفعال'}`,
})),
);
}
if (filter.value === 'expiring') return billingRows('expiring');
if (filter.value === 'expired') return billingRows('expired');
if (filter.value === 'chat') {
return (orgs.value ?? [])
.filter((org) => org.chatConfigured)
.map((org) => ({ id: org.id, title: org.name, meta: 'گفتگو پیکربندی شده' }));
}
return [];
});
async function toggleFilter(next: FilterKey) {
filter.value = filter.value === next ? null : next;
if (filter.value) await ensureLists();
}
onMounted(() => {
void load();
});
@@ -50,56 +172,100 @@ onMounted(() => {
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="stats" class="kpis">
<article class="kpi">
<button
type="button"
class="kpi"
:class="{ on: filter === 'organizations' }"
@click="toggleFilter('organizations')"
>
<span class="ico"><UiIcon name="building" :size="18" /></span>
<div>
<p>محیط‌ها</p>
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
</div>
</article>
<article class="kpi">
</button>
<button
type="button"
class="kpi"
:class="{ on: filter === 'users' }"
@click="toggleFilter('users')"
>
<span class="ico"><UiIcon name="users" :size="18" /></span>
<div>
<p>کاربران</p>
<strong>{{ toFaDigits(stats.userCount) }}</strong>
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
</div>
</article>
<article class="kpi">
</button>
<button
type="button"
class="kpi"
:class="{ on: filter === 'memberships' }"
@click="toggleFilter('memberships')"
>
<span class="ico"><UiIcon name="layers" :size="18" /></span>
<div>
<p>عضویت‌ها</p>
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small>
</div>
</article>
<article class="kpi warn">
</button>
<button
type="button"
class="kpi warn"
:class="{ on: filter === 'expiring' }"
@click="toggleFilter('expiring')"
>
<span class="ico"><UiIcon name="clock" :size="18" /></span>
<div>
<p>نزدیک انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small>
</div>
</article>
<article class="kpi danger">
</button>
<button
type="button"
class="kpi danger"
:class="{ on: filter === 'expired' }"
@click="toggleFilter('expired')"
>
<span class="ico"><UiIcon name="warning" :size="18" /></span>
<div>
<p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small>
</div>
</article>
<article v-if="chatStats" class="kpi">
</button>
<button
v-if="chatStats"
type="button"
class="kpi"
:class="{ on: filter === 'chat' }"
@click="toggleFilter('chat')"
>
<span class="ico"><UiIcon name="chat" :size="18" /></span>
<div>
<p>گفتگو (پیکربندی‌شده)</p>
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبت‌شده</small>
</div>
</article>
</button>
</div>
<section v-if="filter" class="results">
<h3>{{ filterTitle[filter] }}</h3>
<p v-if="listLoading" class="muted">در حال خواندن…</p>
<p v-else-if="listError" class="error">{{ listError }}</p>
<p v-else-if="!rows.length" class="muted">موردی نیست.</p>
<ul v-else>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.title }}</strong>
<span>{{ row.meta }}</span>
</li>
</ul>
</section>
<section class="actions">
<h3>میانبرها</h3>
<div class="shortcuts">
@@ -202,6 +368,11 @@ onMounted(() => {
display: flex;
gap: 0.75rem;
align-items: flex-start;
width: 100%;
text-align: start;
font: inherit;
color: inherit;
cursor: pointer;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
@@ -209,6 +380,11 @@ onMounted(() => {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.kpi.on {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
}
.kpi p,
.kpi small {
margin: 0;
@@ -245,6 +421,39 @@ onMounted(() => {
color: var(--danger);
}
.results {
display: grid;
gap: 0.55rem;
}
.results h3 {
margin: 0;
font-size: 0.95rem;
}
.results ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.results li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.75rem 0.9rem;
}
.results span {
color: var(--muted);
font-size: 0.82rem;
}
.actions h3 {
margin: 0 0 0.65rem;
font-size: 0.95rem;
@@ -2,14 +2,9 @@
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
ConversationSettingsDto,
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
PlatformUserDto,
UpdateConversationSettingsRequest,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
@@ -25,7 +20,6 @@ import {
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref('');
const loading = ref(false);
@@ -58,16 +52,6 @@ const editForm = reactive({
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
const chatForm = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
const chatApiKeySet = ref(false);
const savingChat = ref(false);
const chatLoadedFor = ref<string | null>(null);
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -132,7 +116,6 @@ async function toggleOpen(org: PlatformOrganizationDto) {
openId.value = null;
detail.value = null;
editingId.value = null;
chatLoadedFor.value = null;
return;
}
openId.value = org.id;
@@ -143,56 +126,11 @@ async function toggleOpen(org: PlatformOrganizationDto) {
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
await loadChatSettings(org.id);
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
}
async function loadChatSettings(orgId: string) {
try {
const { data } = await platformApi.get<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
);
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatLoadedFor.value = orgId;
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
}
}
async function saveChatSettings(orgId: string) {
savingChat.value = true;
error.value = '';
success.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: chatForm.chatHost.trim(),
uploadUrl: chatForm.uploadUrl.trim() || undefined,
downloadUrl: chatForm.downloadUrl.trim() || undefined,
};
if (chatForm.apiKey.trim()) payload.apiKey = chatForm.apiKey.trim();
const { data } = await platformApi.patch<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
payload,
);
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
success.value = 'تنظیمات گفتگو ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
} finally {
savingChat.value = false;
}
}
function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id;
editForm.name = org.name;
@@ -368,35 +306,6 @@ async function saveModules(orgId: string) {
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
}
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = list;
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
@@ -599,59 +508,6 @@ onMounted(() => {
</div>
</div>
<div class="chat-settings">
<div class="modules-head">
<strong>تنظیمات گفتگو</strong>
<button
class="btn"
type="button"
:disabled="savingChat || chatLoadedFor !== org.id"
@click="saveChatSettings(org.id)"
>
{{ savingChat ? 'ذخیره…' : 'ذخیره گفتگو' }}
</button>
</div>
<p class="chat-hint">
Host، API Key و آدرس آپلود/دانلود — فقط از سوپرادمین و برای همین محیط.
</p>
<div class="chat-grid">
<label>
<span>Host سرویس چت</span>
<input
v-model="chatForm.chatHost"
dir="ltr"
placeholder="chat.example.ir"
autocomplete="off"
/>
</label>
<label>
<span>{{ chatApiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
<input
v-model="chatForm.apiKey"
type="password"
dir="ltr"
autocomplete="new-password"
/>
</label>
<label>
<span>آدرس آپلود فایل</span>
<input
v-model="chatForm.uploadUrl"
dir="ltr"
placeholder="https://chat.example.ir/upload"
/>
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input
v-model="chatForm.downloadUrl"
dir="ltr"
placeholder="https://chat.example.ir/files"
/>
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
@@ -688,13 +544,6 @@ onMounted(() => {
</button>
</div>
</div>
<AdmBillingPanel
v-if="member.role === 'owner'"
:user-id="member.userId"
:billing="member.billing"
:busy-id="busyId"
@apply="applyPlan(member.userId, $event)"
/>
</li>
</ul>
</div>
@@ -933,33 +782,6 @@ label span {
border: 1px solid var(--line);
}
.chat-settings {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
background: #f5f3ff;
border: 1px solid #e9e5ff;
}
.chat-hint {
margin: 0;
font-size: 0.82rem;
color: var(--muted);
}
.chat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 720px) {
.chat-grid {
grid-template-columns: 1fr;
}
}
.modules-head,
.mod-group-head {
display: flex;
+3 -43
View File
@@ -1,20 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import type { PlatformUserDto, UserRole } from '@mento/shared';
import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref('');
const ownersOnly = ref(false);
@@ -135,25 +125,6 @@ async function setMembershipRole(userId: string, membershipId: string, role: Use
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
const { data } = await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return;
ownersOnly.value = value;
@@ -168,7 +139,7 @@ onMounted(() => {
<template>
<div class="page">
<header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
<p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم می‌شود.</p>
<div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان">
<button
@@ -264,17 +235,6 @@ onMounted(() => {
</form>
<div v-if="openId === user.id" class="detail">
<AdmBillingPanel
v-if="user.isBillingOwner"
:user-id="user.id"
:billing="user.billing"
:busy-id="busyId"
@apply="applyPlan(user.id, $event)"
/>
<p v-else class="muted billing-skip">
این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است.
</p>
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
@@ -72,7 +72,9 @@ const f = reactive({
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
const sources = Object.entries(ROUTINE_TASK_SOURCE_LABELS) as Array<[RoutineTaskSource, string]>;
const sources = (['routine', 'manual'] as const).map(
(key) => [key, ROUTINE_TASK_SOURCE_LABELS[key]] as [RoutineTaskSource, string],
);
const statuses = Object.entries(ROUTINE_TASK_STATUS_LABELS) as Array<[RoutineTaskStatus, string]>;
const userOptions = computed(() => [
@@ -1,121 +1,203 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Space as ASpace,
Statistic as AStatistic,
Table as ATable,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryPageChrome } from './treasuryPageChrome';
const route = useRoute();
const router = useRouter();
const pageChrome = useTreasuryPageChrome();
const account = ref<FinancialAccountDto | null>(null);
const items = ref<FinancialMovementDto[]>([]);
const error = ref('');
const stated = ref('');
const stated = ref<string | number | null>('');
const reason = ref('');
const note = ref('');
const busy = ref(false);
const columns: TableColumnsType<FinancialMovementDto> = [
{
title: 'تاریخ',
key: 'occurredAt',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
},
{
title: 'شرح',
key: 'memo',
customRender: ({ record }) => record.memo || '—',
},
{ title: 'نوع', dataIndex: 'kindLabel', key: 'kindLabel' },
{
title: 'ورودی',
key: 'in',
customRender: ({ record }) => (record.direction === 'in' ? toman(record.amount) : '—'),
},
{
title: 'خروجی',
key: 'out',
customRender: ({ record }) => (record.direction === 'out' ? toman(record.amount) : '—'),
},
{
title: 'مانده',
key: 'balanceAfter',
customRender: ({ record }) => (record.balanceAfter ? toman(record.balanceAfter) : '—'),
},
];
async function load() {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
pageChrome?.set({
title: data.account.name || 'جزئیات حساب',
description: 'گردش قطعی این حساب',
});
}
async function reconcile() {
error.value = '';
note.value = '';
busy.value = true;
try {
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
accountId: route.params.id,
statedBalance: stated.value,
});
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
const { data } = await api.post<{ difference: string; systemBalance: string }>(
'/treasury/reconciliations',
{
accountId: route.params.id,
statedBalance: amount ?? 0,
},
);
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
} catch {
error.value = 'ثبت مغایرت ناموفق بود.';
} finally {
busy.value = false;
}
}
async function adjust(direction: 'in' | 'out') {
error.value = '';
busy.value = true;
try {
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
await api.post('/treasury/adjustments', {
accountId: route.params.id,
amount: stated.value,
amount: amount ?? 0,
direction,
reason: reason.value,
});
window.location.reload();
await load();
note.value = 'اصلاح موجودی ثبت شد.';
} catch {
error.value = 'اصلاح موجودی ناموفق بود.';
} finally {
busy.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
} catch {
onMounted(() => {
void load().catch(() => {
error.value = 'گردش حساب بارگذاری نشد.';
}
});
});
onUnmounted(() => {
pageChrome?.clear();
});
</script>
<template>
<section class="page">
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="account" class="stats">
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div>
<div v-if="account" class="reconcile">
<UiField label="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></UiField>
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
<p v-if="note" class="note">{{ note }}</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>تاریخ</th>
<th>شرح</th>
<th>نوع</th>
<th>ورودی</th>
<th>خروجی</th>
<th>مانده</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
<td>{{ row.memo || '—' }}</td>
<td>{{ row.kindLabel }}</td>
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
</tbody>
</table>
</div>
</section>
</template>
<a-flex vertical :gap="16">
<a-space>
<a-button type="link" @click="router.push('/finance/accounts')">بازگشت به حساب‌ها</a-button>
</a-space>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
.stats span { color: #64748b; display: block; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
</style>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="note" type="success" show-icon :message="note" />
<a-row v-if="account" :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="موجودی فعلی" :value="toman(account.balance)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مانده افتتاحیه" :value="toman(account.openingBalance)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="ورودی" :value="toman(account.periodIn)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="خروجی" :value="toman(account.periodOut)" />
</a-card>
</a-col>
</a-row>
<a-card v-if="account" :bordered="false">
<a-divider orientation="right">مغایرت و اصلاح</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="موجودی واقعی">
<MoneyInput v-model="stated" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="علت اصلاح">
<a-input v-model:value="reason" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label=" ">
<a-space wrap>
<a-button :loading="busy" @click="reconcile">ثبت مغایرت</a-button>
<a-button :loading="busy" @click="adjust('in')">اصلاح ورود</a-button>
<a-button :loading="busy" @click="adjust('out')">اصلاح خروج</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">گردش حساب</a-divider>
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
<a-table
v-else
:columns="columns"
:data-source="items"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
/>
</a-card>
</a-flex>
</template>
@@ -20,13 +20,16 @@ import {
Statistic as AStatistic,
Tag as ATag,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -82,14 +85,19 @@ async function load() {
accounts.value = data;
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createAccount() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
await api.post('/treasury/accounts', {
...form.value,
openingBalance: form.value.openingBalance || 0,
openingBalance,
openingDate: form.value.openingDate || undefined,
});
openForm.value = false;
@@ -117,11 +125,20 @@ async function sendTransfer() {
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(transfer.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ انتقال معتبر نیست.';
return;
}
await api.post('/treasury/transfers', {
...transfer.value,
fromAccountId: transfer.value.fromAccountId,
toAccountId: transfer.value.toAccountId,
amount,
memo: transfer.value.memo || undefined,
clientKey: crypto.randomUUID(),
});
transferOpen.value = false;
transfer.value = { fromAccountId: '', toAccountId: '', amount: '', memo: '' };
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
await load();
} catch (e) {
@@ -139,17 +156,7 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حساب‌ها</a-typography-title>
<a-typography-text type="secondary">
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
@@ -192,15 +199,24 @@ onMounted(() => {
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-form layout="vertical" @finish="createAccount">
<a-form
layout="vertical"
:model="form"
@finish="createAccount"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام حساب" name="name">
<a-form-item
label="نام حساب"
name="name"
:rules="[{ required: true, message: 'نام حساب الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type">
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
@@ -225,12 +241,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance">
<a-input v-model:value="form.openingBalance" />
<MoneyInput v-model="form.openingBalance" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع" name="openingDate">
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="form.openingDate" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
@@ -245,12 +261,25 @@ onMounted(() => {
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<a-form layout="vertical" @finish="sendTransfer">
<a-form
layout="vertical"
:model="transfer"
@finish="sendTransfer"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="از حساب" name="fromAccountId">
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-form-item
label="از حساب"
name="fromAccountId"
:rules="[{ required: true, message: 'حساب مبدأ الزامی است' }]"
>
<a-select
v-model:value="transfer.fromAccountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -258,18 +287,34 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="به حساب" name="toAccountId">
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
<a-form-item
label="به حساب"
name="toAccountId"
:rules="[{ required: true, message: 'حساب مقصد الزامی است' }]"
>
<a-select
v-model:value="transfer.toAccountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option
v-for="account in active"
:key="`to-${account.id}`"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="transfer.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="transfer.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
@@ -309,6 +354,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -1,10 +1,20 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Paragraph: ATypographyParagraph } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type Row = {
id: string;
action: string;
@@ -32,26 +42,31 @@ onMounted(async () => {
</script>
<template>
<section class="page">
<UiPageHeader title="سوابق تغییرات مالی" description="تسویه سند و نشستن وجه در صندوق، با مقدار ثبت‌شده." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="!error && !rows.length" class="muted">سابقه‌ای نیست.</p>
<article v-for="row in rows" :key="row.id">
<header>
<strong>{{ actionLabel[row.action] ?? row.action }}</strong>
<time>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</time>
</header>
<pre>{{ JSON.stringify(row.after ?? row.before, null, 2) }}</pre>
</article>
</section>
</template>
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<style scoped>
.page { display: grid; gap: 0.8rem; }
article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
header { display: flex; justify-content: space-between; gap: 0.75rem; }
time, .muted { color: #64748b; }
pre { margin: 0.45rem 0 0; white-space: pre-wrap; font: inherit; color: #334155; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<a-card :bordered="false">
<a-empty v-if="!error && !rows.length" description="سابقه‌ای نیست." />
<a-list v-else item-layout="vertical" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
{{ actionLabel[item.action] ?? item.action }}
</template>
<template #description>
{{ formatJalaliNumeric(new Date(item.createdAt)) }}
</template>
</a-list-item-meta>
<a-typography-paragraph
:copyable="true"
:style="{ marginBottom: 0, whiteSpace: 'pre-wrap', fontFamily: 'inherit' }"
>
{{ JSON.stringify(item.after ?? item.before, null, 2) }}
</a-typography-paragraph>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>
@@ -16,20 +16,16 @@ import {
Row as ARow,
Select as ASelect,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]);
const name = ref('');
const kind = ref<'income' | 'expense'>('expense');
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
const error = ref('');
async function load() {
@@ -40,8 +36,11 @@ async function load() {
async function add() {
error.value = '';
try {
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
name.value = '';
await api.post('/treasury/categories', {
name: form.value.name,
kind: form.value.kind,
});
form.value.name = '';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
@@ -56,31 +55,25 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">دسته‌بندی‌های مالی</a-typography-title>
<a-typography-text type="secondary">
دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-form layout="vertical" @finish="add">
<a-form layout="vertical" :model="form" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام" name="name">
<a-input v-model:value="name" />
<a-form-item
label="نام"
name="name"
:rules="[{ required: true, message: 'نام الزامی است' }]"
>
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="kind" style="width: 100%">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
@@ -111,6 +104,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -16,13 +16,15 @@ import {
Row as ARow,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -47,7 +49,6 @@ const form = ref({
notes: '',
});
const title = 'پرداخت‌ها و هزینه‌ها';
const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
@@ -81,19 +82,29 @@ async function load() {
accounts.value = acc.data.filter((row) => row.isActive);
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
@@ -136,43 +147,50 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-form
layout="vertical"
:model="form"
@finish="createDoc"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
<MoneyInput v-model="form.settleAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
<a-select-option value="">بدون دسته</a-select-option>
<a-select
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
@@ -186,8 +204,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
<a-select-option value="">انتخاب حساب</a-select-option>
<a-select
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -256,6 +278,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -22,12 +22,12 @@ import {
Space as ASpace,
Textarea as ATextarea,
Typography,
message,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
ArrowLeftOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
@@ -48,10 +48,11 @@ import type {
TreasuryDashboardDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -91,7 +92,6 @@ function emptyForm() {
const form = ref(emptyForm());
const pageTitle = 'دریافت‌ها و درآمدها';
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده',
@@ -122,11 +122,15 @@ const formTabOptions = [
];
const remainingAmount = computed(() => {
const total = Number(form.value.amount) || 0;
const settled = Number(form.value.settleAmount) || 0;
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -158,14 +162,20 @@ async function createDoc() {
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
@@ -243,18 +253,6 @@ onMounted(() => {
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<div>
<a-flex align="center" :gap="token.marginXS">
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
ثبت دریافت جدید
</a-typography-title>
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
</a-flex>
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
</div>
<TreasuryNav />
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
@@ -418,14 +416,7 @@ onMounted(() => {
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<a-input
v-model:value="form.amount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
@@ -434,14 +425,7 @@ onMounted(() => {
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<a-input
v-model:value="form.settleAmount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
</a-row>
@@ -596,35 +580,39 @@ onMounted(() => {
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-form
layout="vertical"
:model="form"
@finish="createDoc"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
<MoneyInput v-model="form.settleAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { Flex as AFlex, Typography, theme } from 'ant-design-vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { provideTreasuryPageChrome } from './treasuryPageChrome';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const route = useRoute();
const chrome = provideTreasuryPageChrome();
watch(
() => route.fullPath,
() => {
chrome.clear();
const scroller = document.querySelector('main.content');
if (scroller instanceof HTMLElement) scroller.scrollTop = 0;
},
);
const title = computed(
() =>
chrome.override.value.title ??
(typeof route.meta.treasuryTitle === 'string' ? route.meta.treasuryTitle : 'مدیریت مالی'),
);
const description = computed(
() =>
chrome.override.value.description ??
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
);
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */
const headerStyle = computed(() => ({
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
}));
</script>
<template>
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }">
<div :style="headerStyle">
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text
type="secondary"
:ellipsis="description ? { tooltip: description } : false"
:style="{ display: 'block', margin: 0 }"
>
{{ description || '\u00a0' }}
</a-typography-text>
</div>
<TreasuryNav />
<RouterView />
</a-flex>
</template>
@@ -16,13 +16,15 @@ import {
Row as ARow,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -41,8 +43,16 @@ const users = ref<OrgUserDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
const pay = ref({ id: '', amount: '', accountId: '' });
const saving = ref(false);
const form = ref({
userId: undefined as string | undefined,
period: '',
baseAmount: '',
benefits: '',
bonus: '',
deductions: '',
});
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
@@ -50,6 +60,10 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function load() {
const [payroll, people, acc] = await Promise.all([
api.get<PayrollRow[]>('/treasury/payroll'),
@@ -63,33 +77,66 @@ async function load() {
async function createRow() {
error.value = '';
ok.value = '';
saving.value = true;
try {
await api.post('/treasury/payroll', form.value);
const baseAmount = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
await api.post('/treasury/payroll', {
userId: form.value.userId,
period: form.value.period,
baseAmount,
benefits: parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0,
bonus: parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0,
deductions: parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0,
});
form.value = {
userId: undefined,
period: '',
baseAmount: '',
benefits: '',
bonus: '',
deductions: '',
};
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function payRow() {
error.value = '';
ok.value = '';
saving.value = true;
try {
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ پرداخت معتبر نیست.';
return;
}
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
amount: pay.value.amount,
amount,
accountId: pay.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
pay.value = { id: '', amount: '', accountId: '' };
pay.value = { id: '', amount: '', accountId: undefined };
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
function startPay(row: PayrollRow) {
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
pay.value = {
id: row.id,
amount: row.remaining,
accountId: accounts.value[0]?.id,
};
}
onMounted(() => {
@@ -100,26 +147,31 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را می‌بینند.</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">ثبت دوره</a-divider>
<a-form layout="vertical" @finish="createRow">
<a-form
layout="vertical"
:model="form"
@finish="createRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="پرسنل" name="userId">
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-form-item
label="پرسنل"
name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select
v-model:value="form.userId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
{{ user.fullName }}
</a-select-option>
@@ -127,54 +179,84 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="period">
<a-form-item
label="دوره"
name="period"
:rules="[{ required: true, message: 'دوره الزامی است' }]"
>
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پایه" name="baseAmount">
<a-input v-model:value="form.baseAmount" />
<a-form-item
label="پایه"
name="baseAmount"
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
>
<MoneyInput v-model="form.baseAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مزایا" name="benefits">
<a-input v-model:value="form.benefits" />
<MoneyInput v-model="form.benefits" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="bonus">
<a-input v-model:value="form.bonus" />
<MoneyInput v-model="form.bonus" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کسورات" name="deductions">
<a-input v-model:value="form.deductions" />
<MoneyInput v-model="form.deductions" :decimals="0" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
</a-form>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<a-form layout="vertical" @finish="payRow">
<a-form
layout="vertical"
:model="pay"
@finish="payRow"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="pay.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="pay.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="pay.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-form-item
label="حساب"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="pay.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
</a-form>
</template>
@@ -203,6 +285,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -19,15 +19,18 @@ import {
Table as ATable,
Tag as ATag,
Typography,
message,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
@@ -109,19 +112,35 @@ async function downloadExcel() {
URL.revokeObjectURL(url);
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createContract() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const totalAmount = parseMoneyNumber(String(form.value.totalAmount ?? ''), 0);
if (totalAmount == null || totalAmount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
return;
}
const prepaidAmount = parseMoneyNumber(String(form.value.prepaidAmount ?? ''), 0) ?? 0;
const installments = custom.value
? customLines.value.map((line) => ({
amount: parseMoneyNumber(String(line.amount ?? ''), 0) ?? 0,
dueDate: line.dueDate,
}))
: undefined;
await api.post('/treasury/receivables', {
title: form.value.title,
partyName: form.value.partyName || null,
totalAmount: form.value.totalAmount,
prepaidAmount: form.value.prepaidAmount || 0,
totalAmount,
prepaidAmount,
accountId: form.value.accountId || null,
...(custom.value
? { installments: customLines.value }
? { installments }
: {
count: Number(form.value.count),
interval: form.value.interval,
@@ -145,8 +164,13 @@ async function collectInstallment() {
saving.value = true;
error.value = '';
try {
const amount = parseMoneyNumber(String(collect.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ دریافت معتبر نیست.';
return;
}
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
amount: collect.value.amount,
amount,
accountId: collect.value.accountId,
clientKey: crypto.randomUUID(),
});
@@ -177,26 +201,25 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
<a-typography-text type="secondary">
دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">قرارداد جدید</a-divider>
<a-form layout="vertical" @finish="createContract">
<a-form
layout="vertical"
:model="form"
@finish="createContract"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
@@ -206,19 +229,27 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="totalAmount">
<a-input v-model:value="form.totalAmount" />
<a-form-item
label="مبلغ کل"
name="totalAmount"
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
>
<MoneyInput v-model="form.totalAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<a-input v-model:value="form.prepaidAmount" />
<MoneyInput v-model="form.prepaidAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
@@ -240,7 +271,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
<JalaliDateInput v-model="form.startDate" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24">
@@ -254,12 +285,12 @@ onMounted(() => {
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`">
<a-input v-model:value="line.amount" />
<MoneyInput v-model="line.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سررسید">
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="line.dueDate" />
</a-form-item>
</a-col>
</a-row>
@@ -276,17 +307,39 @@ onMounted(() => {
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form layout="vertical" @finish="collectInstallment">
<a-form
layout="vertical"
:model="collect"
@finish="collectInstallment"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="collect.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="collect.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب مقصد" name="accountId">
<a-select v-model:value="collect.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-form-item
label="حساب مقصد"
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
v-model:value="collect.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
@@ -337,6 +390,5 @@ onMounted(() => {
</div>
</template>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -16,21 +16,33 @@ import {
Row as ARow,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
type Row = {
id: string;
title: string;
kind: string;
amount: string;
interval: string;
nextDue: string;
isActive: boolean;
};
const rows = ref<Row[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
async function load() {
@@ -38,16 +50,34 @@ async function load() {
rows.value = data;
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createItem() {
error.value = '';
ok.value = '';
saving.value = true;
try {
await api.post('/treasury/recurring', form.value);
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ معتبر نیست.';
return;
}
await api.post('/treasury/recurring', {
...form.value,
amount,
startDate: form.value.startDate || undefined,
});
form.value = { title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' };
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته می‌شود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
} finally {
saving.value = false;
}
}
@@ -57,31 +87,30 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
<a-typography-text type="secondary">
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-flex vertical :gap="16">
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">الگوی جدید</a-divider>
<a-form layout="vertical" @finish="createItem">
<a-form
layout="vertical"
:model="form"
@finish="createItem"
@finishFailed="onFinishFailed"
>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-form-item
label="عنوان"
name="title"
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
>
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
@@ -89,12 +118,16 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="form.amount" />
<a-form-item
label="مبلغ"
name="amount"
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
@@ -104,11 +137,11 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
<JalaliDateInput v-model="form.startDate" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
<a-button type="primary" html-type="submit" :loading="saving">ثبت الگو</a-button>
</a-form>
<a-divider orientation="right">فهرست الگوها</a-divider>
@@ -131,6 +164,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -14,10 +14,9 @@ import {
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
@@ -100,23 +99,14 @@ onMounted(() => {
</script>
<template>
<div>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
<a-typography-text type="secondary">
سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.
</a-typography-text>
</div>
<a-flex vertical :gap="16">
<a-flex justify="flex-end">
<a-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
@@ -179,6 +169,5 @@ onMounted(() => {
</a-list>
</template>
</a-card>
</a-flex>
</div>
</a-flex>
</template>
@@ -0,0 +1,33 @@
import { inject, provide, ref, type InjectionKey, type Ref } from 'vue';
export type TreasuryChromeOverride = {
title: string | null;
description: string | null;
};
type TreasuryChromeApi = {
override: Ref<TreasuryChromeOverride>;
set: (next: TreasuryChromeOverride) => void;
clear: () => void;
};
const KEY: InjectionKey<TreasuryChromeApi> = Symbol('treasuryPageChrome');
export function provideTreasuryPageChrome(): TreasuryChromeApi {
const override = ref<TreasuryChromeOverride>({ title: null, description: null });
const api: TreasuryChromeApi = {
override,
set(next) {
override.value = next;
},
clear() {
override.value = { title: null, description: null };
},
};
provide(KEY, api);
return api;
}
export function useTreasuryPageChrome(): TreasuryChromeApi | null {
return inject(KEY, null);
}