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:
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '' };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 ? 'بد' : 'بس'}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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 }))"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user