merge: bring Ant Design UI from dev into main

Keep the design refresh together with DISC, the sales copilot, and treasury behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 21:33:56 +03:30
co-authored by Cursor
50 changed files with 9568 additions and 12306 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403 -164
View File
@@ -1,18 +1,153 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
HolderOutlined,
PhoneOutlined,
ReloadOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const router = useRouter();
const GAP = 16;
const COL_WIDTH = 280;
const loading = ref(false);
const moving = ref(false);
const error = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false;
/**
* ارتفاع کامل زیر topbar شل.
* height:0 + flex:1 روی فرزند، زنجیرهٔ کشش را اجباری می‌کند.
*/
const pageStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
height: 'calc(100dvh - 120px)',
maxHeight: 'calc(100dvh - 120px)',
minHeight: '420px',
minWidth: 0,
}));
const boardShellStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: `${GAP}px`,
background: token.value.colorBgContainer,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
position: 'relative' as const,
overflow: 'hidden',
}));
const boardTrackStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch',
gap: `${GAP}px`,
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
paddingBottom: '4px',
}));
function columnStyle(stageId: string) {
const active = dropStageId.value === stageId;
return {
boxSizing: 'border-box' as const,
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column' as const,
minHeight: 0,
background: token.value.colorFillAlter,
border: `1px solid ${
active ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: active ? `0 0 0 2px ${token.value.colorPrimaryBg}` : undefined,
overflow: 'hidden',
};
}
const columnHeadStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${GAP}px`,
padding: `${GAP}px`,
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
}));
const columnBodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
const cardBodyStyle = computed(() => ({
padding: '12px 14px',
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(' · ') || '';
}
function formatDate(value: string | null) {
if (!value) return '';
@@ -39,7 +174,13 @@ async function load() {
}
}
function openLead(lead: LeadDto) {
if (suppressCardClick || draggingId.value || moving.value) return;
void router.push({ name: 'lead-detail', params: { id: lead.id } });
}
function onDragStart(lead: LeadDto, event: DragEvent) {
suppressCardClick = true;
draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
@@ -48,6 +189,9 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -106,183 +250,278 @@ onMounted(load);
</script>
<template>
<section class="page">
<UiPageHeader title="مسیر لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
<template #actions>
<RouterLink :to="{ name: 'leads' }" class="link-btn">
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
<div class="lead-board-page" :style="pageStyle">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن و رها کردن برای تغییر وضعیت
</a-typography-text>
</div>
<a-space :size="GAP">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><UnorderedListOutlined /></template>
فهرست لیدها
</a-button>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
<a-button
:disabled="loading || moving"
:loading="loading"
@click="load"
>
<template #icon><ReloadOutlined /></template>
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
</a-button>
</a-space>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div v-else-if="columns.length" class="board">
<section
v-for="col in columns"
:key="col.stage.id"
class="column"
:class="{ drop: dropStageId === col.stage.id }"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
<div :style="boardShellStyle">
<div
v-if="loading || moving"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<header class="col-head">
<span
class="dot"
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
</header>
<a-spin />
</div>
<div class="cards">
<article
v-for="lead in col.leads"
:key="lead.id"
class="card"
:class="{ dragging: draggingId === lead.id }"
draggable="true"
@dragstart="onDragStart(lead, $event)"
@dragend="onDragEnd"
>
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
{{ lead.title }}
</RouterLink>
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
<p v-if="lead.city" class="line">{{ lead.city }}</p>
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
<p v-if="lead.nextFollowUpAt" class="line muted">
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
</p>
</article>
<p v-if="!col.leads.length" class="empty-col">خالی</p>
<a-empty
v-if="!loading && !columns.length"
description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
:style="{ margin: 'auto' }"
/>
<div
v-else-if="columns.length"
class="lead-board-track"
:style="boardTrackStyle"
>
<div
v-for="col in columns"
:key="col.stage.id"
:style="columnStyle(col.stage.id)"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
>
<div :style="columnHeadStyle">
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: col.stage.color || token.colorPrimary,
display: 'inline-block',
flexShrink: 0,
}"
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(col.leads.length) }}
</a-typography-text>
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
:key="lead.id"
hoverable
:bordered="true"
:body-style="cardBodyStyle"
:style="{
cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1,
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
boxShadow: token.boxShadow,
}"
@click="openLead(lead)"
>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="center" :style="{ minWidth: 0 }">
<span
role="button"
tabindex="0"
aria-label="جابه‌جایی لید"
title="برای جابه‌جایی بکشید"
draggable="true"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@click.stop
@dragstart.stop="onDragStart(lead, $event)"
@dragend.stop="onDragEnd"
>
<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
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35 }"
:title="lead.title"
>
{{ lead.title }}
</a-typography-text>
<a-typography-text
v-if="leadSubtitle(lead)"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
lineHeight: 1.35,
}"
:title="leadSubtitle(lead)"
>
{{ leadSubtitle(lead) }}
</a-typography-text>
</div>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{ minWidth: 0 }"
>
<a-flex
v-if="lead.mobile"
align="center"
:gap="6"
dir="ltr"
:style="{ minWidth: 0 }"
>
<PhoneOutlined
:style="{
color: token.colorPrimary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontVariantNumeric: 'tabular-nums',
color: token.colorText,
}"
>
{{ lead.mobile }}
</a-typography-text>
</a-flex>
<a-tag
v-if="lead.assigneeName"
:bordered="false"
:style="{
margin: 0,
maxWidth: '46%',
overflow: 'hidden',
textOverflow: 'ellipsis',
background: token.colorFillSecondary,
}"
:title="lead.assigneeName"
>
{{ lead.assigneeName }}
</a-tag>
</a-flex>
<a-flex
v-if="lead.nextFollowUpAt"
align="center"
:gap="6"
:style="{
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<CalendarOutlined
:style="{
color: token.colorTextSecondary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
type="secondary"
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
}"
>
پیگیری {{ formatDate(lead.nextFollowUpAt) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
<a-typography-text
v-if="!col.leads.length"
type="secondary"
:style="{
textAlign: 'center',
display: 'block',
margin: 'auto',
padding: `${GAP}px`,
}"
>
خالی
</a-typography-text>
</div>
</div>
</section>
</div>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای برای نمایش در مسیر لیدها نیست."
description="در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
/>
</section>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ری‌اسکین Ant */
.lead-board-page {
width: 100%;
}
.link-btn {
text-decoration: none;
.lead-board-track {
scrollbar-width: thin;
}
.board {
display: flex;
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
min-height: 24rem;
.lead-board-track::-webkit-scrollbar {
height: 8px;
}
.column {
flex: 0 0 16.5rem;
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
border: 1px solid var(--line);
border-radius: var(--radius);
display: grid;
grid-template-rows: auto 1fr;
max-height: calc(100vh - 12rem);
}
.column.drop {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.col-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
margin: 0;
font-size: 0.95rem;
flex: 1;
}
.col-head em {
font-style: normal;
font-size: 0.8rem;
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.cards {
padding: 0.55rem;
display: grid;
gap: 0.5rem;
overflow-y: auto;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: calc(var(--radius) - 2px);
padding: 0.65rem 0.7rem;
display: grid;
gap: 0.2rem;
cursor: grab;
}
.card.dragging {
opacity: 0.45;
}
.title {
font-weight: 600;
color: var(--ink);
text-decoration: none;
}
.title:hover {
color: var(--accent);
}
.line {
margin: 0;
font-size: 0.82rem;
}
.muted,
.empty-col {
color: var(--muted);
}
.empty-col {
margin: 0.5rem;
text-align: center;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
margin: 0;
.lead-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+370 -520
View File
@@ -2,6 +2,37 @@
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const ASpaceCompact = Space.Compact;
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = withDefaults(
defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const companyOptions = computed(() => [
{ value: '', label: 'بدون شرکت' },
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
vcfInputKey.value += 1;
void nextTick(() => triggerVcfPick());
}
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
function triggerVcfPick() {
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
input?.click();
}
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
void (async () => {
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file as File);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
})();
return false;
};
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
triggerVcfPick();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
triggerVcfPick();
}
function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
function remove() {
if (!id.value) return;
Modal.confirm({
title: 'حذف شخص',
content: 'این شخص حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
</script>
<template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
<!-- Desktop Header -->
<UiPageHeader
<div>
<a-flex
v-if="!embedded"
:title="pageTitle"
:back-to="backTo"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<template #lead>
<span class="crumb">
<div>
<a-typography-text type="secondary">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
· {{ pageTitle }}
</a-typography-text>
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
</div>
<a-space :size="8" wrap>
<a-button @click="cancel">
<template #icon><ArrowLeftOutlined /></template>
انصراف
</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
<template #icon><DeleteOutlined /></template>
حذف
</UiButton>
</template>
</UiPageHeader>
</a-button>
<a-button type="primary" :loading="saving" @click="submit">
<template #icon><CheckOutlined /></template>
ذخیره
</a-button>
</a-space>
</a-flex>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<a-spin :spinning="loading">
<a-form v-if="!loading" layout="vertical" @finish="submit">
<a-card :bordered="false">
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="firstName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-space-compact block style="width: 100%">
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
<a-button
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<template #icon><PhoneOutlined /></template>
{{ pickingContact ? '…' : 'از مخاطبین' }}
</a-button>
</a-space-compact>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="phone" type="tel" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="email" type="email" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="position" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="آدرس وب‌سایت">
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیج اینستاگرام">
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ تولد">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
</a-row>
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<a-divider orientation="right">آدرس‌ها</a-divider>
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
<a-flex justify="flex-end">
<a-button type="link" @click="addAddress">
<template #icon><PlusOutlined /></template>
افزودن آدرس
</a-button>
</a-flex>
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
<a-col :flex="'auto'">
<a-input
v-model:value="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
</a-col>
<a-col>
<a-button danger @click="removeAddress(idx)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
</a-flex>
<UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" />
</UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</p>
<div class="pick-search">
<UiIcon name="search" :size="15" />
<input
v-model="pickQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<ul v-if="visiblePickDrafts.length" class="pick-list">
<li v-for="row in visiblePickDrafts" :key="row.key">
<button type="button" class="pick-row" @click="choosePick(row)">
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="pick-meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
<template #footer>
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
</template>
</UiModal>
<UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" />
</UiField>
<UiField label="ایمیل" for-id="email">
<UiInput id="email" v-model="email" type="email" />
</UiField>
<UiField label="سمت" for-id="position">
<UiInput id="position" v-model="position" />
</UiField>
<UiField label="آدرس وب‌سایت" for-id="website">
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
</UiField>
<UiField label="پیج اینستاگرام" for-id="instagram">
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
<div class="crm-col-span-full addresses">
<div class="addresses-head">
<span>آدرس‌ها</span>
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
</div>
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
<UiInput
:id="`address-${idx}`"
v-model="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
×
</button>
</div>
</div>
<div class="crm-col-span-full">
<UiField label="وضعیت مشتری">
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood-btn"
type="button"
:class="{ on: mood === item }"
<a-divider orientation="right">وضعیت و شرکت</a-divider>
<a-form-item label="وضعیت مشتری">
<a-row :gutter="[8, 8]">
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
<a-button
block
:type="mood === item ? 'primary' : 'default'"
:ghost="mood === item"
:style="{ height: 'auto', paddingBlock: '12px' }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
<a-flex vertical align="center" :gap="6">
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</a-flex>
</a-button>
</a-col>
</a-row>
</a-form-item>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="شرکت">
<a-select
v-model:value="companyId"
show-search
option-filter-prop="label"
:options="companyOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
v-model:value="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="notes" :rows="3" />
</a-form-item>
</a-col>
</a-row>
<a-card
v-if="!isNew"
:bordered="true"
:style="{ marginTop: '8px' }"
type="inner"
>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<div>
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
<div>
<a-typography-text strong>
{{ toFaDigits(loyaltyPoints) }} امتیاز
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
</a-typography-text>
</div>
</div>
</UiField>
</div>
<RouterLink :to="{ name: 'loyalty' }">
<a-button type="link">مدیریت باشگاه</a-button>
</RouterLink>
</a-flex>
</a-card>
<div class="crm-col-span-full">
<UiField label="شرکت" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
</div>
<CustomFieldsSection entity-type="person" v-model="customFields" />
</a-card>
<div class="crm-col-span-full">
<UiField label="برچسب‌ها" for-id="tags">
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<div class="crm-col-span-full">
<UiField label="یادداشت" for-id="notes">
<UiTextarea id="notes" v-model="notes" :rows="3" />
</UiField>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
<div class="loyalty-info">
<span class="muted">باشگاه مشتریان</span>
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
</div>
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
</UiCard>
<a-space
v-if="embedded"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<a-space
v-else
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
</a-space>
</a-form>
</a-spin>
<p v-if="error" class="crm-field-error">{{ error }}</p>
<a-upload
ref="vcfUploadRef"
:key="vcfInputKey"
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="onVcfBeforeUpload"
:style="{ display: 'none' }"
>
<span />
</a-upload>
<div class="mobile-actions mob-only">
<UiButton variant="primary" block type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
حذف
</UiButton>
</div>
</form>
<a-modal
:open="sourceOpen"
title="از مخاطبین"
:footer="null"
destroy-on-close
@cancel="closeSource"
>
<a-typography-paragraph type="secondary">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
<a-button type="link" @click="closeSource">انصراف</a-button>
</a-flex>
</a-modal>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
</section>
<a-modal
:open="pickOpen"
title="انتخاب مخاطب"
destroy-on-close
@cancel="closePick"
>
<a-typography-paragraph type="secondary">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</a-typography-paragraph>
<a-input
v-model:value="pickQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
:style="{ marginBottom: '12px' }"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-list
v-if="visiblePickDrafts.length"
:data-source="visiblePickDrafts"
:style="{ maxHeight: '360px', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
</a-button>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
<template #footer>
<a-button @click="closePick">انصراف</a-button>
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.crumb {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.embed-actions {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addresses {
display: grid;
gap: var(--sp-2);
}
.addresses-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.add-addr {
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
}
.addr-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
}
.rm-addr {
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.rm-addr:hover {
color: #b91c1c;
border-color: #fecaca;
}
.moods {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.mood-btn {
display: grid;
justify-items: center;
gap: 4px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font: inherit;
color: var(--muted);
font-size: var(--fs-xs);
transition: all var(--transition-fast);
}
.mood-btn.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.loyalty-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
background: var(--surface-subtle);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.loyalty-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.loyalty-link {
color: var(--accent-deep);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.mobile-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
.pick-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: var(--fs-sm);
line-height: 1.7;
}
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.pick-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.pick-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: var(--fs-sm);
}
.pick-meta small {
color: var(--muted);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>
+294 -359
View File
@@ -17,12 +17,28 @@ import {
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import {
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Modal as AModal,
Space as ASpace,
Spin as ASpin,
theme,
} from 'ant-design-vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
@@ -30,6 +46,9 @@ import {
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const { token } = theme.useToken();
const GAP = 16;
/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
const pageStyle = {
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
flex: '1 1 auto',
minHeight: 0,
minWidth: 0,
width: '100%',
height: '100%',
padding: 0,
margin: 0,
};
const route = useRoute();
const router = useRouter();
@@ -62,7 +96,6 @@ const amountMax = ref('');
const showCreate = ref(false);
const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
}
function onDeleteStage(stageId: string) {
deleteStageId.value = stageId;
const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
Modal.confirm({
title: 'حذف ستون',
content: `ستون «${title}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید اول آن‌ها را جابه‌جا کنید.`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
if (!boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
throw e;
} finally {
stageBusy.value = false;
}
},
});
}
const deleteStageTitle = computed(() => {
const id = deleteStageId.value;
return stages.value.find((s) => s.id === id)?.title ?? '';
});
async function confirmDeleteStage() {
const stageId = deleteStageId.value;
if (!stageId || !boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
deleteStageId.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
const deleteDealIds = ref<string[]>([]);
const dealDeleting = ref(false);
const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
const deleteDealTitle = computed(() => {
const ids = deleteDealIds.value;
if (ids.length !== 1) return '';
function dealTitleById(dealId: string) {
for (const s of stages.value) {
const d = s.deals.find((x) => x.id === ids[0]);
const d = s.deals.find((x) => x.id === dealId);
if (d) return d.title;
}
return '';
});
const deleteConfirmCopy = computed(() => {
const n = deleteDealIds.value.length;
if (n <= 0) return '';
if (n === 1) {
return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
}
return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
});
}
function askDeleteDeal(dealId: string) {
menuOpenId.value = null;
deleteDealIds.value = [dealId];
confirmDeleteDeals([dealId]);
}
function startSelect(dealId: string) {
@@ -460,31 +481,43 @@ function clearSelect() {
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuOpenId.value = null;
deleteDealIds.value = [...selectedIds.value];
confirmDeleteDeals([...selectedIds.value]);
}
async function confirmDeleteDeal() {
const ids = deleteDealIds.value;
function confirmDeleteDeals(ids: string[]) {
if (!ids.length || dealDeleting.value) return;
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
deleteDealIds.value = [];
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
} finally {
dealDeleting.value = false;
}
const copy =
ids.length === 1
? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
: `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
Modal.confirm({
title: ids.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله',
content: copy,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
throw e;
} finally {
dealDeleting.value = false;
}
},
});
}
async function onDealSaved() {
@@ -593,6 +626,19 @@ function onMenuDeal(dealId: string) {
});
}
function onDealMenuClick({ key }: { key: string | number }) {
const id = menuOpenId.value;
if (!id) return;
if (key === 'select') startSelect(id);
else if (key === 'view') onOpenDeal(id);
else if (key === 'edit') {
dealModal.value = { dealId: id };
menuOpenId.value = null;
} else if (key === 'promote') void promotePriorDeal(id);
else if (key === 'delete') askDeleteDeal(id);
else menuOpenId.value = null;
}
const unbindMenuDismiss = bindMenuDismiss(
() => menuOpenId.value != null,
() => (menuOpenId.value = null),
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</script>
<template>
<section class="page" dir="rtl">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
<template #excel>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
<SavedFiltersBar
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری مسیر فروش…</p>
<div v-if="selectMode" class="select-bar">
<span>{{ toFaDigits(selectedCount) }} معامله انتخاب شده</span>
<button
v-if="selectedCount"
type="button"
class="select-del"
@click="askBulkDelete"
<div class="pipeline-page" dir="rtl" :style="pageStyle">
<a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
حذف انتخاب‌شده‌ها
</button>
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button>
<template #excel>
<a-button :disabled="loading || busy" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<a-button :disabled="loading || busy">
<template #icon><UploadOutlined /></template>
ورود از اکسل
</a-button>
</ExcelImportFlow>
</template>
<template #filters>
<SavedFiltersBar
compact
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
</template>
</PipelineToolbar>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
<a-alert
v-if="selectMode"
type="info"
show-icon
:message="`${toFaDigits(selectedCount)} معامله انتخاب شده`"
>
<template #action>
<a-space>
<a-button
v-if="selectedCount"
type="primary"
danger
@click="askBulkDelete"
>
<template #icon><DeleteOutlined /></template>
حذف انتخاب‌شده‌ها
</a-button>
<a-button @click="clearSelect">انصراف از انتخاب</a-button>
</a-space>
</template>
</a-alert>
</a-flex>
<div
:style="{
position: 'relative',
flex: '1 1 auto',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<div
v-if="pageBusy"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<a-spin :tip="busyMessage" />
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -734,109 +819,62 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</Teleport>
<Teleport to="body">
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined">
<button type="button" role="menuitem" @click="startSelect(menuOpenId)">
انتخاب
</button>
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
مشاهده معامله
</button>
<button
type="button"
role="menuitem"
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null"
<div
v-if="menuOpenId"
:style="{
position: 'fixed',
zIndex: 1080,
...(menuStyle ?? {}),
}"
>
<a-menu
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
@click="onDealMenuClick"
>
ویرایش
</button>
<button
v-if="canPromotePrior(findDeal(menuOpenId))"
type="button"
role="menuitem"
@click="promotePriorDeal(menuOpenId)"
>
انتقال به کار جاری
</button>
<button type="button" role="menuitem" class="danger" @click="askDeleteDeal(menuOpenId)">
حذف
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
<a-menu-item key="select">انتخاب</a-menu-item>
<a-menu-item key="view">مشاهده معامله</a-menu-item>
<a-menu-item key="edit">ویرایش</a-menu-item>
<a-menu-item
v-if="canPromotePrior(findDeal(menuOpenId))"
key="promote"
>
انتقال به کار جاری
</a-menu-item>
<a-menu-item key="delete" danger>حذف</a-menu-item>
<a-menu-item key="close">بستن</a-menu-item>
</a-menu>
</div>
</Teleport>
<UiModal
<a-modal
:open="showCreate && canCreatePipeline"
title="مسیر فروش جدید"
@close="showCreate = false"
:confirm-loading="saving"
ok-text="ساخت"
cancel-text="انصراف"
:ok-button-props="{ disabled: newName.trim().length < 2 }"
destroy-on-close
@ok="createPipeline"
@cancel="showCreate = false"
>
<form class="create-form" @submit.prevent="createPipeline">
<label class="field">
<span>نام مسیر فروش</span>
<UiInput
v-model="newName"
<a-form layout="vertical" @finish="createPipeline">
<a-form-item label="نام مسیر فروش" required>
<a-input
v-model:value="newName"
placeholder="نام مسیر فروش جدید…"
:maxlength="120"
required
:minlength="2"
/>
</label>
</form>
<template #footer>
<UiButton
type="button"
:disabled="saving || newName.trim().length < 2"
@click="createPipeline"
>
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="showCreate = false">
انصراف
</UiButton>
</template>
</UiModal>
</a-form-item>
</a-form>
</a-modal>
<UiModal
:open="Boolean(deleteStageId)"
title="حذف ستون"
@close="deleteStageId = null"
>
<p class="delete-copy">
ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید
اول آن‌ها را جابه‌جا کنید.
</p>
<template #footer>
<UiButton
type="button"
:disabled="stageBusy"
@click="confirmDeleteStage"
>
{{ stageBusy ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteStageId = null">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
:open="deleteDealIds.length > 0"
:title="deleteDealIds.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله'"
@close="deleteDealIds = []"
>
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
<template #footer>
<UiButton type="button" :disabled="dealDeleting" @click="confirmDeleteDeal">
{{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteDealIds = []">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
<a-modal
:open="Boolean(dealModal)"
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
@close="dealModal = null"
:footer="null"
destroy-on-close
width="720px"
@cancel="dealModal = null"
>
<DealFormView
v-if="dealModal"
@@ -847,117 +885,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@saved="onDealSaved"
@cancel="dealModal = null"
/>
</UiModal>
</section>
</a-modal>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.15rem;
min-width: 0;
max-width: 100%;
direction: rtl;
text-align: right;
container-type: inline-size;
container-name: pipeline;
}
.create-form .field {
display: grid;
gap: 0.35rem;
}
.create-form .field span {
font-size: 0.85rem;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.delete-copy {
/* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
.pipeline-page {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.select-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
padding: 0.55rem 0.85rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line));
font-size: 0.86rem;
font-weight: 700;
color: var(--ink);
}
.select-bar span {
flex: 1;
min-width: 8rem;
}
.select-bar button {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.select-bar .select-del {
color: var(--danger);
}
.select-bar .select-cancel {
color: var(--accent);
}
.menu-pop {
position: fixed;
z-index: 80;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
display: grid;
gap: 0.15rem;
}
.menu-pop button {
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--ink);
text-align: right;
}
.menu-pop button:hover {
background: var(--surface-2, #f1f5f9);
border-radius: calc(var(--radius) - 4px);
}
.menu-pop button.danger {
color: var(--danger);
width: 100%;
}
</style>
@@ -1,13 +1,36 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
@@ -116,110 +139,176 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="صندوق و حساب‌ها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div class="stats">
<article>
<span>مجموع موجودی حساب‌ها</span>
<strong>{{ toman(total) }}</strong>
</article>
<article>
<span>ورودی این فهرست</span>
<strong class="in">{{ toman(periodIn) }}</strong>
</article>
<article>
<span>خروجی این فهرست</span>
<strong class="out">{{ toman(periodOut) }}</strong>
</article>
<article>
<span>حساب‌های فعال</span>
<strong>{{ active.length }} حساب</strong>
</article>
</div>
<div class="list">
<article v-for="account in accounts" :key="account.id" class="account">
<div>
<strong>{{ account.name }}</strong>
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
<p>مشاهده گردش حساب و جزئیات</p>
</div>
<div class="side">
<b>{{ toman(account.balance) }}</b>
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
</div>
</article>
</div>
<div class="actions">
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
</div>
<UiCard v-if="openForm" title="حساب جدید">
<div class="grid">
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
<UiField label="نوع">
<select v-model="form.type" class="select">
<option value="cash">صندوق نقدی</option>
<option value="bank">حساب بانکی</option>
<option value="petty">تنخواه</option>
</select>
</UiField>
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
<UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
<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>
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
</UiCard>
<UiCard v-if="transferOpen" title="انتقال بین حساب‌ها">
<div class="grid">
<UiField label="از حساب">
<select v-model="transfer.fromAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="به حساب">
<select v-model="transfer.toAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
</div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
</UiCard>
</section>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مجموع موجودی حساب‌ها" :value="toman(total)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="ورودی این فهرست"
:value="toman(periodIn)"
:value-style="{ color: 'var(--ant-color-success)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="خروجی این فهرست"
:value="toman(periodOut)"
:value-style="{ color: 'var(--ant-color-error)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="حساب‌های فعال" :value="`${active.length} حساب`" />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-space>
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-form layout="vertical" @finish="createAccount">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام حساب" name="name">
<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-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>
<a-select-option value="petty">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
<a-form-item label="نام بانک" name="bankName">
<a-input v-model:value="form.bankName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شماره یا شناسه" name="accountRef">
<a-input v-model:value="form.accountRef" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName">
<a-input v-model:value="form.responsibleName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance">
<a-input v-model:value="form.openingBalance" />
</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%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
</a-form>
</template>
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<a-form layout="vertical" @finish="sendTransfer">
<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-select-option v-for="account in active" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</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">
{{ 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>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شرح" name="memo">
<a-input v-model:value="transfer.memo" />
</a-form-item>
</a-col>
</a-row>
<a-typography-text type="secondary">
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.
</a-typography-text>
<div>
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
</div>
</a-form>
</template>
<a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
<template #title>
{{ item.name }}
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
.stats article, .account {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
}
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
.in { color: #15803d; }
.out { color: #b91c1c; }
.list { display: grid; gap: 0.65rem; }
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.side { display: grid; justify-items: end; gap: 0.4rem; }
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,11 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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,
List as AList,
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 { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
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');
@@ -35,36 +56,61 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="دسته‌بندی‌های مالی" description="دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div class="add">
<UiField label="نام"><UiInput v-model="name" /></UiField>
<UiField label="نوع">
<select v-model="kind" class="select">
<option value="income">درآمد</option>
<option value="expense">هزینه</option>
</select>
</UiField>
<UiButton @click="add">افزودن</UiButton>
</div>
<ul>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.name }}</strong>
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
</li>
</ul>
</section>
</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>
<style scoped>
.page { display: grid; gap: 1rem; }
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
li span { color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
</style>
<TreasuryNav />
<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-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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
</a-tag>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,6 +1,261 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
import { onMounted, ref } from 'vue';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} 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 { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const form = ref({
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const title = 'پرداخت‌ها و هزینه‌ها';
const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'جزئی',
settled: 'پرداخت‌شده',
void: 'باطل‌شده',
};
const methodOptions = [
{ value: 'cash', label: 'نقدی' },
{ value: 'card_to_card', label: 'کارت‌به‌کارت' },
{ value: 'pos', label: 'کارت‌خوان' },
{ value: 'transfer', label: 'حواله' },
{ value: 'other', label: 'سایر' },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const [docs, cats, acc] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = docs.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
form.value = {
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
};
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<TreasuryDocsView kind="expense" />
<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-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-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</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-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</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-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-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
+129 -79
View File
@@ -1,11 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Row as ARow,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import { UiCard, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
@@ -31,82 +46,117 @@ onMounted(async () => {
</script>
<template>
<section class="page">
<UiPageHeader
title="مدیریت مالی"
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="data" class="stats">
<article v-for="card in cards" :key="card.key">
<span>{{ card.label }}</span>
<strong>{{ toman(data[card.key]) }}</strong>
<small>{{ card.hint }}</small>
</article>
</div>
<div v-if="data" class="split">
<UiCard title="اقساط نزدیک یا معوق">
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingInstallments" :key="row.id">
<div>
<strong>قسط {{ row.position }}</strong>
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
<UiCard title="پرداخت‌های آینده">
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingPayables" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</div>
<UiCard v-if="data" title="آخرین اسناد">
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in data.recent" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</section>
</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>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.stats article {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.stats span, .stats small, .muted { color: #64748b; }
.stats strong { font-size: 1.05rem; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
.list small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="data">
<a-row :gutter="[16, 16]">
<a-col
v-for="card in cards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:xl="6"
>
<a-card :bordered="false">
<a-statistic :title="card.label" :value="toman(data[card.key])" />
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
<a-empty
v-if="!data.upcomingInstallments.length"
description="قسط بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingInstallments"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>قسط {{ item.position }}</template>
<template #description>
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
<a-col :xs="24" :lg="12">
<a-divider orientation="right">پرداخت‌های آینده</a-divider>
<a-empty
v-if="!data.upcomingPayables.length"
description="تعهد پرداخت بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingPayables"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>مانده {{ toman(item.remaining) }}</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
</a-row>
<a-divider orientation="right">آخرین اسناد</a-divider>
<a-empty
v-if="!data.recent.length"
description="هنوز سندی ثبت نشده است."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.recent"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</template>
</a-flex>
</div>
</template>
@@ -1,6 +1,765 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
import { computed, h, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Grid,
Input as AInput,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
ArrowLeftOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
TreasuryDashboardDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
return {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyName: '',
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const pageTitle = 'دریافت‌ها و درآمدها';
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده',
partial: 'جزئی',
settled: 'دریافت‌شده',
void: 'باطل‌شده',
};
const methodOptions = [
{ value: 'cash', label: 'نقدی' },
{ value: 'card_to_card', label: 'کارت‌به‌کارت' },
{ value: 'pos', label: 'کارت‌خوان' },
{ value: 'transfer', label: 'حواله' },
{ value: 'other', label: 'سایر' },
];
const formTabOptions = [
{
value: 'main',
payload: 'main',
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
},
{
value: 'extra',
payload: 'extra',
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
},
];
const remainingAmount = computed(() => {
const total = Number(form.value.amount) || 0;
const settled = Number(form.value.settleAmount) || 0;
return Math.max(0, total - settled);
});
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const [docs, cats, acc, dash] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
]);
rows.value = docs.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
ok.value = '';
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await createDoc();
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<TreasuryDocsView kind="income" />
<div
:style="
isMobile
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
: undefined
"
>
<!-- ─── 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
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
دریافت‌های این ماه
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.actualReceipts) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ArrowDownOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
در انتظار دریافت
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.receivables) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ClockCircleOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-segmented
v-model:value="formTab"
block
:options="formTabOptions"
:style="{ background: token.colorFillTertiary }"
/>
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item
label="عنوان درآمد"
name="title"
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
>
<a-input
v-model:value="form.title"
placeholder="مثلاً: دریافت قسط پروژه الف"
>
<template #prefix><FileTextOutlined /></template>
</a-input>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item
label="مبلغ کل (تومان)"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<a-input
v-model:value="form.amount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="`${settleLabel} (تومان)`"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<a-input
v-model:value="form.settleAmount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
</a-col>
</a-row>
<a-flex
align="center"
justify="space-between"
:style="{
background: token.colorSuccessBg,
borderRadius: `${token.borderRadiusLG * 2}px`,
padding: `${token.paddingSM}px ${token.padding}px`,
marginBottom: `${token.margin}px`,
}"
>
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: token.colorSuccess,
display: 'inline-block',
}"
/>
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
</a-space>
<a-typography-text strong :style="{ color: token.colorSuccess }">
{{ toman(remainingAmount) }}
</a-typography-text>
</a-flex>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="تاریخ دریافت" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="accountId">
<template #label>
<a-space :size="6">
<BankOutlined />
<span>حساب مقصد</span>
</a-space>
</template>
<a-select
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
style="width: 100%"
>
<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>
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
:rows="4"
placeholder="توضیحات اختیاری…"
/>
</a-form-item>
</div>
</a-form>
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
<div
:style="{
position: 'fixed',
left: 0,
right: 0,
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
zIndex: 90,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: `${token.paddingSM}px ${token.padding}px`,
boxShadow: token.boxShadowSecondary,
}"
>
<a-row :gutter="12">
<a-col :span="8">
<a-button block @click="resetForm">انصراف</a-button>
</a-col>
<a-col :span="16">
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
<template #icon><CheckOutlined /></template>
ثبت دریافت
</a-button>
</a-col>
</a-row>
</div>
</template>
<!-- ─── 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-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</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 v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</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
v-for="account in accounts"
:key="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="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -1,13 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type PayrollRow = {
id: string;
fullName: string;
@@ -69,6 +88,10 @@ async function payRow() {
}
}
function startPay(row: PayrollRow) {
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -77,59 +100,109 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را می‌بینند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="ثبت دوره">
<div class="grid">
<UiField label="پرسنل">
<select v-model="form.userId" class="select">
<option value="">انتخاب</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
</div>
<UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.fullName }}</strong>
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را می‌بینند.</a-typography-text>
</div>
<div class="side">
<b>{{ toman(row.payable) }}</b>
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
</div>
</article>
<UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid">
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
<UiField label="حساب">
<select v-model="pay.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton @click="payRow">ثبت پرداخت</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.side { display: grid; justify-items: end; }
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<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="createRow">
<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-select-option v-for="user in users" :key="user.id" :value="user.id">
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="period">
<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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مزایا" name="benefits">
<a-input v-model:value="form.benefits" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="bonus">
<a-input v-model:value="form.bonus" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کسورات" name="deductions">
<a-input v-model:value="form.deductions" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
</a-form>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<a-form layout="vertical" @finish="payRow">
<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>
</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">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
</a-form>
</template>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
<a-button
v-if="item.status !== 'settled' && item.status !== 'void'"
type="link"
@click="startPay(item)"
>
پرداخت
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.fullName }}</template>
<template #description>
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,14 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, 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';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
const rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -36,6 +59,31 @@ const statusLabel: Record<string, string> = {
void: 'باطل',
};
const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: 'قسط', dataIndex: 'position', key: 'position' },
{
title: 'سررسید',
key: 'dueDate',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
},
{
title: 'مبلغ',
key: 'amount',
customRender: ({ record }) => toman(record.amount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }) => toman(record.remaining),
},
{
title: 'وضعیت',
key: 'status',
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
},
{ title: '', key: 'actions' },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -112,6 +160,15 @@ async function collectInstallment() {
}
}
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
collect.value = {
installmentId: item.id,
receivableId: row.id,
amount: item.remaining,
accountId: accounts.value[0]?.id || '',
};
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری مطالبات ناموفق بود.';
@@ -120,113 +177,166 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader
title="اقساط و مطالبات"
description="دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="قرارداد جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
<UiField label="پیش‌پرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
<UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
<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>
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom">
<div v-for="(line, index) in customLines" :key="index" class="grid">
<UiField label="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
</div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
</div>
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="contract">
<header>
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.totalAmount) }}</b>
</header>
<table>
<thead>
<tr>
<th>قسط</th>
<th>سررسید</th>
<th>مبلغ</th>
<th>مانده</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.installments" :key="item.id">
<td>{{ item.position }}</td>
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
<td>{{ toman(item.amount) }}</td>
<td>{{ toman(item.remaining) }}</td>
<td>{{ statusLabel[item.status] }}</td>
<td>
<button
v-if="item.status !== 'settled'"
type="button"
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
>
دریافت
</button>
</td>
</tr>
</tbody>
</table>
</article>
<TreasuryNav />
<UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid">
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
<UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
</UiCard>
</section>
<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-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<a-input v-model:value="form.prepaidAmount" />
</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-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-col :xs="24" :md="8">
<a-form-item label="تعداد قسط" name="count">
<a-input v-model:value="form.count" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
</a-select>
</a-form-item>
</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%" />
</a-form-item>
</a-col>
<a-col :xs="24">
<a-form-item>
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
</a-form-item>
</a-col>
</a-row>
<template v-if="custom">
<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" />
</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%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
</a-form-item>
</template>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
<a-button @click="downloadExcel">خروجی اکسل</a-button>
</a-space>
</a-form>
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form layout="vertical" @finish="collectInstallment">
<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>
</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">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
</a-form>
</template>
<a-divider orientation="right">قراردادها</a-divider>
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
<template v-else>
<div v-for="row in rows" :key="row.id">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
<div>
<a-typography-text strong>{{ row.title }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
</a-typography-text>
</div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex>
<a-table
:columns="installmentColumns"
:data-source="row.installments"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="record.status !== 'settled'"
type="link"
@click="startCollect(row, record as InstallmentRow)"
>
دریافت
</a-button>
</template>
</template>
</a-table>
<a-divider />
</div>
</template>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
.contract small { display: block; color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,13 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { UiButton, UiCard, 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';
const { Title: ATypographyTitle, 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 };
const rows = ref<Row[]>([]);
const error = ref('');
@@ -38,48 +57,80 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="الگوی جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="نوع">
<select v-model="form.kind" class="select">
<option value="expense">هزینه</option>
<option value="income">درآمد</option>
</select>
</UiField>
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
<option value="yearly">سالانه</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
</div>
<UiButton @click="createItem">ثبت الگو</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
<a-typography-text type="secondary">
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.
</a-typography-text>
</div>
<b>{{ toman(row.amount) }}</b>
</article>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<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="createItem">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<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-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>
</a-select>
</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>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<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>
<a-select-option value="yearly">سالانه</a-select-option>
</a-select>
</a-form-item>
</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%" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
</a-form>
<a-divider orientation="right">فهرست الگوها</a-divider>
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
· {{ item.interval }}
· بعدی {{ formatJalaliNumeric(new Date(item.nextDue)) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,10 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { UiButton, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type Period = {
bookedIncome: string;
bookedExpense: string;
@@ -14,6 +30,14 @@ type Period = {
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
type MetricRow = {
key: string;
label: string;
current: string;
previous: string;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
@@ -29,6 +53,22 @@ const lines = [
['خالص جریان نقدی', 'cashNet'],
] as const;
const metricColumns: TableColumnsType<MetricRow> = [
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
];
const metricRows = computed<MetricRow[]>(() => {
if (!current.value || !previous.value) return [];
return lines.map(([label, key]) => ({
key,
label,
current: toman(current.value![key]),
previous: toman(previous.value![key]),
}));
});
async function load() {
const { data } = await api.get<{
current: Period;
@@ -60,49 +100,85 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
<table v-if="current && previous">
<thead>
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
</thead>
<tbody>
<tr v-for="line in lines" :key="line[1]">
<td>{{ line[0] }}</td>
<td>{{ toman(current[line[1]]) }}</td>
<td>{{ toman(previous[line[1]]) }}</td>
</tr>
</tbody>
</table>
<h2>مطالبات به تفکیک طرف حساب</h2>
<ul>
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
</ul>
<h2>تعهدات به تفکیک ماه</h2>
<ul>
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
</ul>
<h2 v-if="current">سهم هزینه‌ها</h2>
<ul v-if="current">
<li v-for="row in current.expenseCategories" :key="row.name">
<span>{{ row.name }}</span>
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
</li>
</ul>
</section>
</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-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<style scoped>
.page { display: grid; gap: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
h2 { margin: 0; font-size: 1rem; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">مقایسه دوره‌ها</a-divider>
<a-table
v-if="metricRows.length"
:columns="metricColumns"
:data-source="metricRows"
:pagination="false"
row-key="key"
/>
<a-empty v-else description="داده‌ای برای نمایش نیست." />
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.month }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>
{{ toman(item.amount) }} · {{ item.share }}٪
</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</a-card>
</a-flex>
</div>
</template>