feat(treasury): separate receipts from income and fit finance on a phone

A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-10 12:28:27 +03:30
co-authored by Cursor
parent 49459707cb
commit d12d7b91cb
16 changed files with 626 additions and 537 deletions
+171 -74
View File
@@ -360,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
...(from || to ...(from || to
? { ? {
occurredAt: { occurredAt: {
@@ -378,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from }, occurredAt: { lt: from },
}, },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
@@ -531,6 +533,8 @@ export class TreasuryService {
settleAmount?: string | number | null; settleAmount?: string | number | null;
accountId?: string | null; accountId?: string | null;
settledAt?: string | null; settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
}, },
): Promise<FinancialDocumentDto> { ): Promise<FinancialDocumentDto> {
const title = input.title.trim(); const title = input.title.trim();
@@ -561,69 +565,18 @@ export class TreasuryService {
} }
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const payExisting = const settlement = input.settlement === true;
party.partyKind !== 'other' && settle.equals(amount) && settle.greaterThan(0); if (settlement) {
if (party.partyKind === 'other') {
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
}
if (!settle.equals(amount) || !settle.greaterThan(0)) {
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
}
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
}
const payExisting = settlement;
const doc = await this.prisma.$transaction(async (tx) => { const doc = await this.prisma.$transaction(async (tx) => {
let amountLeft = amount;
let settleLeft = settle;
let touchedId = '';
if (payExisting && input.accountId) {
const scope = await this.accountScope(actor);
const openDocs = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: input.kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: party.partyKind,
...(party.personId ? { personId: party.personId } : {}),
...(party.companyId ? { companyId: party.companyId } : {}),
...(party.userId ? { userId: party.userId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
for (const row of openDocs) {
if (!settleLeft.greaterThan(0)) break;
const remaining = row.amount.minus(this.settledOf(row.movements));
if (!remaining.greaterThan(0)) continue;
const slice = settleLeft.lessThan(remaining) ? settleLeft : remaining;
const status = slice.equals(remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
documentId: row.id,
direction: input.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.id },
data: { status, accountId: input.accountId },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
touchedId = row.id;
settleLeft = settleLeft.minus(slice);
amountLeft = amountLeft.minus(slice);
}
}
if (!amountLeft.greaterThan(0)) {
return tx.financialDocument.findUniqueOrThrow({
where: { id: touchedId },
include: { category: true, account: true, movements: true },
});
}
const reference = await this.nextDocumentReference( const reference = await this.nextDocumentReference(
tx, tx,
actor.organizationId, actor.organizationId,
@@ -636,7 +589,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
kind: input.kind, kind: input.kind,
title, title,
amount: amountLeft, amount,
bookedAt, bookedAt,
dueAt: input.dueAt ? startOfDay(input.dueAt) : null, dueAt: input.dueAt ? startOfDay(input.dueAt) : null,
categoryId: input.categoryId || null, categoryId: input.categoryId || null,
@@ -647,20 +600,20 @@ export class TreasuryService {
partyName: party.partyName, partyName: party.partyName,
accountId: input.accountId || null, accountId: input.accountId || null,
method: input.method || null, method: input.method || null,
status: settleLeft.equals(0) ? 'open' : settleLeft.equals(amountLeft) ? 'settled' : 'partial', status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
reference, reference,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
}); });
if (settleLeft.greaterThan(0) && input.accountId) { if (settle.greaterThan(0) && input.accountId) {
await tx.financialMovement.create({ await tx.financialMovement.create({
data: { data: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId: input.accountId, accountId: input.accountId,
documentId: created.id, documentId: created.id,
direction: input.kind === 'income' ? 'in' : 'out', direction: input.kind === 'income' ? 'in' : 'out',
amount: settleLeft, amount: settle,
kind: input.kind === 'income' ? 'receipt' : 'payment', kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt, occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title, memo: title,
@@ -669,6 +622,45 @@ export class TreasuryService {
}, },
}); });
} }
if (payExisting && input.accountId) {
const leftover = await this.allocatePartyBalance(tx, actor, {
party,
sourceId: created.id,
kind: input.kind,
amount: settle,
accountId: input.accountId,
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
});
if (leftover.greaterThan(0)) {
const opposite = input.kind === 'income' ? 'expense' : 'income';
const oppositeRef = await this.nextDocumentReference(
tx,
actor.organizationId,
opposite,
input.bookedAt,
bookedAt,
);
await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
kind: opposite,
title,
amount: leftover,
bookedAt,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
status: 'open',
reference: oppositeRef,
notes: `source:${created.id}`,
createdById: actor.userId,
},
});
}
}
return tx.financialDocument.findUniqueOrThrow({ return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -1115,16 +1107,51 @@ export class TreasuryService {
}); });
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date(); const now = new Date();
await this.prisma.$transaction([ await this.prisma.$transaction(async (tx) => {
this.prisma.financialMovement.updateMany({ const allocations = await tx.financialMovement.findMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
select: { documentId: true },
});
await tx.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null }, where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now }, data: { voidedAt: now },
}), });
this.prisma.financialDocument.update({ await tx.financialMovement.updateMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
data: { voidedAt: now },
});
await tx.financialDocument.update({
where: { id: doc.id }, where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}), });
]); const excess = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
notes: `source:${doc.id}`,
voidedAt: null,
status: { in: ['open', 'partial'] },
},
include: { movements: { select: { voidedAt: true } } },
});
for (const child of excess) {
if (child.movements.some((row) => !row.voidedAt)) continue;
await tx.financialDocument.update({
where: { id: child.id },
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
});
}
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
for (const documentId of affected) {
const row = await tx.financialDocument.findUnique({
where: { id: documentId },
include: { movements: { select: { amount: true, voidedAt: true } } },
});
if (!row || row.voidedAt || row.status === 'void') continue;
const settled = this.settledOf(row.movements);
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
}
});
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({ const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id }, where: { id: doc.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -1329,6 +1356,76 @@ export class TreasuryService {
}; };
} }
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
private async allocatePartyBalance(
tx: Prisma.TransactionClient,
actor: Actor,
input: {
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
sourceId: string;
kind: 'income' | 'expense';
amount: Prisma.Decimal;
accountId: string;
occurredAt: Date;
memo: string;
},
): Promise<Prisma.Decimal> {
const scope = await this.accountScope(actor);
const openDocs = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
id: { not: input.sourceId },
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.party.partyKind,
...(input.party.personId ? { personId: input.party.personId } : {}),
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
...(input.party.userId ? { userId: input.party.userId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const ordered = [
...openDocs.filter((row) => row.kind === input.kind),
...openDocs.filter((row) => row.kind !== input.kind),
];
let left = input.amount;
for (const row of ordered) {
if (!left.greaterThan(0)) break;
const remaining = row.amount.minus(this.settledOf(row.movements));
if (!remaining.greaterThan(0)) continue;
const slice = left.lessThan(remaining) ? left : remaining;
const status = slice.equals(remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
documentId: row.id,
direction: row.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: 'allocation',
occurredAt: input.occurredAt,
memo: input.memo,
reference: `alloc:${input.sourceId}`,
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.id },
data: { status },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
left = left.minus(slice);
}
return left;
}
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) { private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements return movements
.filter((row) => !row.voidedAt) .filter((row) => !row.voidedAt)
@@ -1337,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) { private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null }, where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
}); });
return rows.reduce( return rows.reduce(
@@ -1412,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({ const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null }, where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true, kind: true, occurredAt: true }, select: { direction: true, amount: true, kind: true, occurredAt: true },
}); });
let balance = new Prisma.Decimal(0); let balance = new Prisma.Decimal(0);
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue'; import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared'; import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => { const links = computed(() => {
const role = auth.user?.role; const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance'; return '/finance';
}); });
watch(
active,
async () => {
await nextTick();
scroller.value
?.querySelector('.ant-segmented-item-selected')
?.scrollIntoView({ inline: 'center', block: 'nearest' });
},
{ immediate: true },
);
function onChange(value: string | number) { function onChange(value: string | number) {
const next = String(value); const next = String(value);
if (next && next !== route.path) void router.push(next); if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template> <template>
<div <div
ref="scroller"
:style="{ :style="{
width: '100%', width: '100%',
overflowX: 'auto', overflowX: 'auto',
@@ -1,14 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue'; import {
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format'; import { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{ const props = defineProps<{
rows: TreasuryPartyBalanceDto[]; rows: TreasuryPartyBalanceDto[];
settledLabel: string; settledLabel: string;
}>(); }>();
const isMobile = useTreasuryMobile();
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const kindLabel: Record<string, string> = { const kindLabel: Record<string, string> = {
person: 'مشتری', person: 'مشتری',
company: 'شرکت', company: 'شرکت',
@@ -39,10 +49,43 @@ const columns = computed(() => [
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining), customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
}, },
]); ]);
function kindOf(record: TreasuryPartyBalanceDto) {
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
}
</script> </script>
<template> <template>
<a-list
v-if="isMobile"
:data-source="rows"
:locale="{ emptyText: 'مانده‌ای نیست.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-flex vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.partyName }}</a-typography-text>
<a-typography-text type="secondary">{{ kindOf(item) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مبلغ کل</a-typography-text>
<a-typography-text>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">{{ settledLabel }}</a-typography-text>
<a-typography-text>{{ toman(item.settledAmount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مانده</a-typography-text>
<a-typography-text strong>{{ toman(item.remaining) }}</a-typography-text>
</a-flex>
</a-flex>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else
:columns="columns" :columns="columns"
:data-source="rows" :data-source="rows"
:pagination="false" :pagination="false"
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup; const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind'); const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId'); const partyId = defineModel<string | undefined>('partyId');
@@ -156,12 +161,12 @@ onMounted(() => {
:filter-option="false" :filter-option="false"
:loading="loading" :loading="loading"
:value="selectValue" :value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر" :placeholder="placeholder"
style="width: 100%" style="width: 100%"
@search="onSearch" @search="onSearch"
@change="onChange" @change="onChange"
> >
<a-select-option value="other" label="سایر">سایر</a-select-option> <a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان"> <a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option <a-select-option
v-for="row in peopleOptions" v-for="row in peopleOptions"
@@ -16,12 +16,16 @@ import {
Space as ASpace, Space as ASpace,
Statistic as AStatistic, Statistic as AStatistic,
Table as ATable, Table as ATable,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
List as AList,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue'; import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared'; import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryPageChrome } from './treasuryPageChrome'; import { useTreasuryPageChrome } from './treasuryPageChrome';
@@ -37,6 +41,8 @@ const reason = ref('');
const note = ref(''); const note = ref('');
const busy = ref(false); const busy = ref(false);
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const columns: TableColumnsType<FinancialMovementDto> = [ const columns: TableColumnsType<FinancialMovementDto> = [
{ {
title: 'تاریخ', title: 'تاریخ',
@@ -190,8 +196,29 @@ onUnmounted(() => {
<a-divider orientation="right">گردش حساب</a-divider> <a-divider orientation="right">گردش حساب</a-divider>
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." /> <a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
<a-list v-else-if="isMobile" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="formatJalaliNumeric(new Date(item.occurredAt))" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="شرح">{{ item.memo || '—' }}</a-descriptions-item>
<a-descriptions-item label="نوع">{{ item.kindLabel }}</a-descriptions-item>
<a-descriptions-item label="ورودی">
{{ item.direction === 'in' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="خروجی">
{{ item.direction === 'out' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="مانده">
{{ item.balanceAfter ? toman(item.balanceAfter) : '—' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else v-else-if="items.length"
:columns="columns" :columns="columns"
:data-source="items" :data-source="items"
:pagination="false" :pagination="false"
@@ -27,9 +27,11 @@ import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@men
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
@@ -420,7 +422,7 @@ onMounted(() => {
<a-divider orientation="right">فهرست حساب‌ها</a-divider> <a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." /> <a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="accounts">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -23,8 +23,10 @@ import {
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared'; import type { FinancialCategoryDto } from '@mento/shared';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
@@ -151,7 +153,7 @@ onMounted(() => {
<a-divider orientation="right">درآمد</a-divider> <a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." /> <a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="incomeRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -176,7 +178,7 @@ onMounted(() => {
<a-divider orientation="right">هزینه</a-divider> <a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." /> <a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="expenseRows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="expenseRows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
@@ -14,11 +14,13 @@ import {
Input as AInput, Input as AInput,
List as AList, List as AList,
Row as ARow, Row as ARow,
Segmented as ASegmented,
Select as ASelect, Select as ASelect,
Typography, Typography,
message, message,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type { import type {
FinancialAccountDto, FinancialAccountDto,
FinancialCategoryDto, FinancialCategoryDto,
@@ -26,18 +28,20 @@ import type {
FinancialPartyKind, FinancialPartyKind,
TreasuryPartyBalanceDto, TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault'; import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { partyNetPosition, toman } from './treasury-format'; import { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money'; import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const isMobile = useTreasuryMobile();
const kind = 'expense' as const; const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]); const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]); const balances = ref<TreasuryPartyBalanceDto[]>([]);
@@ -58,10 +62,17 @@ const form = ref({
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()),
}); });
const settleMirror = useSettleDefault(form); const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده'; const settleLabel = 'مبلغ پرداخت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'پرداخت', value: 'settlement' },
{ label: 'هزینه', value: 'accrual' },
];
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده', open: 'پرداخت‌نشده',
partial: 'جزئی', partial: 'جزئی',
@@ -134,46 +145,61 @@ async function createDoc() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.'; error.value = settlement ? 'مبلغ پرداخت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.'; error.value = 'طرف حساب را انتخاب کنید.';
return; return;
} }
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; if (settlement && form.value.partyKind === 'other') {
if (form.value.partyKind === 'other' && settleAmount !== amount) { error.value = 'برای پرداخت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.'; error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return; return;
} }
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ پرداخت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey = const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}` ? `${form.value.partyKind}:${form.value.partyId}`
: ''; : '';
const openBalance = partyKey const payable = partyKey
? Number( ? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
: 0; : 0;
const collectsBalance = const receivable = partyKey
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0; ? Number(receivableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (payable > 0 || receivable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', { const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
settlement,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null, personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null, companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null, userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount: settlement ? amount : settleAmount,
method: form.value.method, method: form.value.method,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
}); });
settleMirror.reset(); settleMirror.reset();
form.value = { form.value = {
@@ -186,6 +212,7 @@ async function createDoc() {
settleAmount: '', settleAmount: '',
method: 'transfer', method: 'transfer',
notes: '', notes: '',
bookedAt: formatLocalDateInput(new Date()),
}; };
ok.value = collectsBalance ok.value = collectsBalance
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.` ? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
@@ -204,6 +231,9 @@ const partyRules = [
{ {
validator: async () => { validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است'); if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای پرداخت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) { if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید'); throw new Error('طرف حساب را انتخاب کنید');
} }
@@ -211,6 +241,33 @@ const partyRules = [
}, },
]; ];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const partyPosition = computed(() => { const partyPosition = computed(() => {
const partyKind = form.value.partyKind; const partyKind = form.value.partyKind;
const id = form.value.partyId; const id = form.value.partyId;
@@ -244,7 +301,10 @@ onMounted(() => {
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider> <a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form <a-form
layout="vertical" layout="vertical"
:model="form" :model="form"
@@ -256,7 +316,12 @@ onMounted(() => {
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules"> <a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle"> <a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24"> <a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" /> <TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col> </a-col>
<a-col v-if="partyPosition" :span="8"> <a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text> <a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
@@ -273,7 +338,7 @@ onMounted(() => {
<a-input v-model:value="form.title" /> <a-input v-model:value="form.title" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item <a-form-item
label="مبلغ کل" label="مبلغ کل"
name="amount" name="amount"
@@ -283,11 +348,25 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item
<MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> :label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
@@ -302,7 +381,7 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId"> <a-form-item label="حساب" name="accountId" :rules="accountRules">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
@@ -324,8 +403,18 @@ onMounted(() => {
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </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 v-if="!isSettlement" :span="24">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider> <a-divider orientation="right">مانده پرداختنی</a-divider>
@@ -349,10 +438,10 @@ onMounted(() => {
</a-form> </a-form>
<a-empty v-if="!rows.length" description="سندی نیست." /> <a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text> <a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button <a-button
v-if="item.status !== 'void'" v-if="item.status !== 'void'"
@@ -363,7 +452,29 @@ onMounted(() => {
ابطال ابطال
</a-button> </a-button>
</template> </template>
<a-list-item-meta> <a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template> <template #description>{{ docLine(item) }}</template>
</a-list-item-meta> </a-list-item-meta>
@@ -15,8 +15,10 @@ import {
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared'; import type { TreasuryDashboardDto } from '@mento/shared';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
@@ -75,7 +77,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingInstallments" :data-source="data.upcomingInstallments"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -102,7 +104,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingPayables" :data-source="data.upcomingPayables"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -127,7 +129,7 @@ onMounted(async () => {
/> />
<a-list <a-list
v-else v-else
item-layout="horizontal" :item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.recent" :data-source="data.recent"
> >
<template #renderItem="{ item }"> <template #renderItem="{ item }">
@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { import {
Alert as AAlert, Alert as AAlert,
Avatar as AAvatar,
Button as AButton, Button as AButton,
Card as ACard, Card as ACard,
Col as ACol, Col as ACol,
@@ -18,23 +17,9 @@ import {
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
Select as ASelect, Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography, Typography,
message, message,
theme,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
TagOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali'; import { formatLocalDateInput } from '@/lib/jalali';
import type { import type {
@@ -42,7 +27,6 @@ import type {
FinancialCategoryDto, FinancialCategoryDto,
FinancialDocumentDto, FinancialDocumentDto,
FinancialPartyKind, FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto, TreasuryPartyBalanceDto,
} from '@mento/shared'; } from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -57,7 +41,6 @@ const { Text: ATypographyText } = Typography;
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md); const isMobile = computed(() => !screens.value.md);
@@ -67,13 +50,10 @@ const balances = ref<TreasuryPartyBalanceDto[]>([]);
const payableBalances = ref<TreasuryPartyBalanceDto[]>([]); const payableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]); const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]); const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref(''); const error = ref('');
const ok = ref(''); const ok = ref('');
const saving = ref(false); const saving = ref(false);
const q = ref(''); const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const mobileFormRef = ref<FormInstance>();
function emptyForm() { function emptyForm() {
return { return {
@@ -94,6 +74,12 @@ const form = ref(emptyForm());
const settleMirror = useSettleDefault(form); const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده'; const settleLabel = 'مبلغ دریافت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'دریافت', value: 'settlement' },
{ label: 'درآمد', value: 'accrual' },
];
const statusLabel: Record<string, string> = { const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده', open: 'دریافت‌نشده',
partial: 'جزئی', partial: 'جزئی',
@@ -109,23 +95,13 @@ const methodOptions = [
{ value: 'other', 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 partyRules = [ const partyRules = [
{ {
validator: async () => { validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است'); if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای دریافت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) { if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید'); throw new Error('طرف حساب را انتخاب کنید');
} }
@@ -133,6 +109,17 @@ const partyRules = [
}, },
]; ];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const partyPosition = computed(() => { const partyPosition = computed(() => {
const partyKind = form.value.partyKind; const partyKind = form.value.partyKind;
const id = form.value.partyId; const id = form.value.partyId;
@@ -143,6 +130,16 @@ const partyPosition = computed(() => {
return partyNetPosition(receivable, payable); return partyNetPosition(receivable, payable);
}); });
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const remainingAmount = computed(() => { const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0; const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
@@ -171,7 +168,7 @@ function docLine(item: FinancialDocumentDto) {
} }
async function load() { async function load() {
const [docs, cats, acc, dash, bals, payables] = await Promise.all([ const [docs, cats, acc, bals, payables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }), api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => { api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) { if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
@@ -185,7 +182,6 @@ async function load() {
} }
throw e; throw e;
}), }),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => { api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] }; if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e; throw e;
@@ -200,13 +196,11 @@ async function load() {
payableBalances.value = payables.data; payableBalances.value = payables.data;
categories.value = cats.data.filter((row) => row.isActive); categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive); accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
} }
function resetForm() { function resetForm() {
settleMirror.reset(); settleMirror.reset();
form.value = emptyForm(); form.value = emptyForm();
formTab.value = 'main';
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
} }
@@ -216,44 +210,58 @@ async function createDoc() {
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0); const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) { if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.'; error.value = settlement ? 'مبلغ دریافت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return; return;
} }
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) { if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.'; error.value = 'طرف حساب را انتخاب کنید.';
return; return;
} }
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0; if (settlement && form.value.partyKind === 'other') {
if (form.value.partyKind === 'other' && settleAmount !== amount) { error.value = 'برای دریافت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.'; error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return; return;
} }
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ دریافت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey = const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}` ? `${form.value.partyKind}:${form.value.partyId}`
: ''; : '';
const openBalance = partyKey const receivable = partyKey
? Number( ? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
: 0; : 0;
const collectsBalance = const payable = partyKey
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0; ? Number(payableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (receivable > 0 || payable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', { const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind, kind,
settlement,
title: form.value.title, title: form.value.title,
amount, amount,
categoryId: form.value.categoryId || null, categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind, partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null, personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null, companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null, userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null, accountId: form.value.accountId || null,
settleAmount, settleAmount: settlement ? amount : settleAmount,
method: form.value.method, method: form.value.method,
notes: form.value.notes || null, notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined, bookedAt: form.value.bookedAt || undefined,
@@ -272,16 +280,6 @@ async function createDoc() {
} }
} }
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await createDoc();
}
async function voidDoc(id: string) { async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try { try {
@@ -300,345 +298,15 @@ onMounted(() => {
</script> </script>
<template> <template>
<div <a-flex vertical :gap="16">
: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">
<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="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
<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="24">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ 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-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="width: 100%"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="settleLabel"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</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 show-search option-filter-prop="label"
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"
:label="account.name"
>
{{ 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="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>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<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>{{ docLine(item) }}</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">
<a-alert v-if="error" type="error" show-icon :message="error" /> <a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" /> <a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false"> <a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider> <a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form <a-form
layout="vertical" layout="vertical"
:model="form" :model="form"
@@ -650,7 +318,12 @@ onMounted(() => {
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules"> <a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle"> <a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24"> <a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" /> <TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col> </a-col>
<a-col v-if="partyPosition" :span="8"> <a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text> <a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
@@ -667,7 +340,7 @@ onMounted(() => {
<a-input v-model:value="form.title" /> <a-input v-model:value="form.title" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item <a-form-item
label="مبلغ کل" label="مبلغ کل"
name="amount" name="amount"
@@ -677,11 +350,25 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount"> <a-form-item
<MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" /> :label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId"> <a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.categoryId" v-model:value="form.categoryId"
@@ -696,7 +383,7 @@ onMounted(() => {
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="8"> <a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId"> <a-form-item label="حساب" name="accountId" :rules="accountRules">
<a-select show-search option-filter-prop="label" <a-select show-search option-filter-prop="label"
v-model:value="form.accountId" v-model:value="form.accountId"
allow-clear allow-clear
@@ -733,13 +420,13 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" /> <JalaliDateInput v-model="form.bookedAt" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :xs="24" :md="16"> <a-col v-if="!isSettlement" :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes"> <a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" /> <a-input v-model:value="form.notes" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button> <a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form> </a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider> <a-divider orientation="right">مانده دریافتنی</a-divider>
@@ -762,10 +449,10 @@ onMounted(() => {
</a-form> </a-form>
<a-empty v-if="!rows.length" description="سندی نیست." /> <a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text> <a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button <a-button
v-if="item.status !== 'void'" v-if="item.status !== 'void'"
@@ -776,7 +463,29 @@ onMounted(() => {
ابطال ابطال
</a-button> </a-button>
</template> </template>
<a-list-item-meta> <a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template> <template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template> <template #description>{{ docLine(item) }}</template>
</a-list-item-meta> </a-list-item-meta>
@@ -784,8 +493,5 @@ onMounted(() => {
</template> </template>
</a-list> </a-list>
</a-card> </a-card>
</a-flex> </a-flex>
</template>
</div>
</template> </template>
+12 -7
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { RouterView, useRoute } from 'vue-router'; import { RouterView, useRoute } from 'vue-router';
import { Flex as AFlex, Typography, theme } from 'ant-design-vue'; import { Flex as AFlex, Grid, Typography, theme } from 'ant-design-vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { provideTreasuryPageChrome } from './treasuryPageChrome'; import { provideTreasuryPageChrome } from './treasuryPageChrome';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken(); const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const route = useRoute(); const route = useRoute();
const chrome = provideTreasuryPageChrome(); const chrome = provideTreasuryPageChrome();
@@ -31,16 +33,19 @@ const description = computed(
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''), (typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
); );
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */ const headerStyle = computed(() =>
const headerStyle = computed(() => ({ isMobile.value
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`, ? undefined
})); : {
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
},
);
</script> </script>
<template> <template>
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }"> <a-flex vertical :gap="isMobile ? token.marginSM : token.margin" :style="{ width: '100%' }">
<div :style="headerStyle"> <div :style="headerStyle">
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title> <a-typography-title :level="isMobile ? 4 : 3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text <a-typography-text
type="secondary" type="secondary"
:ellipsis="description ? { tooltip: description } : false" :ellipsis="description ? { tooltip: description } : false"
@@ -18,6 +18,7 @@ import {
Select as ASelect, Select as ASelect,
Space as ASpace, Space as ASpace,
Table as ATable, Table as ATable,
List as AList,
Textarea as ATextarea, Textarea as ATextarea,
Tooltip as ATooltip, Tooltip as ATooltip,
Typography, Typography,
@@ -49,6 +50,7 @@ const { useToken } = theme;
const { token } = useToken(); const { token } = useToken();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md); const isMobile = computed(() => !screens.value.md);
const AListItem = AList.Item;
type PayrollPayment = { type PayrollPayment = {
id: string; id: string;
@@ -651,8 +653,24 @@ onMounted(() => {
</a-space> </a-space>
</a-form> </a-form>
<a-list v-if="isMobile && selectedPay?.payments.length" :data-source="selectedPay.payments">
<template #renderItem="{ item }">
<a-list-item>
<a-card style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="تاریخ">
{{ formatJalaliNumeric(new Date(item.occurredAt)) }}
</a-descriptions-item>
<a-descriptions-item label="حساب">{{ item.accountName }}</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
</a-descriptions>
<a-button type="link" @click="startEditPayment(item)">ویرایش</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-if="selectedPay?.payments.length" v-else-if="selectedPay?.payments.length"
:columns="paymentColumns" :columns="paymentColumns"
:data-source="selectedPay.payments" :data-source="selectedPay.payments"
:pagination="false" :pagination="false"
@@ -7,12 +7,15 @@ import {
Card as ACard, Card as ACard,
Checkbox as ACheckbox, Checkbox as ACheckbox,
Col as ACol, Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider, Divider as ADivider,
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Space as ASpace, Space as ASpace,
@@ -27,6 +30,7 @@ import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared'
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
@@ -87,6 +91,9 @@ const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: '', key: 'actions' }, { title: '', key: 'actions' },
]; ];
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
function apiMessage(e: unknown) { function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.'; if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -365,7 +372,33 @@ onMounted(() => {
</div> </div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text> <a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex> </a-flex>
<a-list v-if="isMobile" :data-source="row.installments">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="`قسط ${item.position}`" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="سررسید">
{{ formatJalaliNumeric(new Date(item.dueDate)) }}
</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
<a-descriptions-item label="مانده">{{ toman(item.remaining) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">
{{ statusLabel[item.status] ?? item.status }}
</a-descriptions-item>
</a-descriptions>
<a-button
v-if="item.status !== 'settled'"
type="link"
@click="startCollect(row, item)"
>
دریافت
</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-else
:columns="installmentColumns" :columns="installmentColumns"
:data-source="row.installments" :data-source="row.installments"
:pagination="false" :pagination="false"
@@ -22,10 +22,12 @@ import { api } from '@/lib/api';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money'; import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option; const ASelectOption = ASelect.Option;
@@ -146,7 +148,7 @@ onMounted(() => {
<a-divider orientation="right">فهرست الگوها</a-divider> <a-divider orientation="right">فهرست الگوها</a-divider>
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." /> <a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -9,6 +9,8 @@ import {
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
List as AList, List as AList,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Table as ATable, Table as ATable,
Tabs as ATabs, Tabs as ATabs,
Typography, Typography,
@@ -20,8 +22,10 @@ import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format'; import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item; const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta; const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane; const ATabPane = ATabs.TabPane;
@@ -186,8 +190,20 @@ onMounted(() => {
<a-tabs v-model:activeKey="reportTab"> <a-tabs v-model:activeKey="reportTab">
<a-tab-pane key="periods" tab="مقایسه دوره‌ها"> <a-tab-pane key="periods" tab="مقایسه دوره‌ها">
<a-flex vertical :gap="16"> <a-flex vertical :gap="16">
<a-list v-if="isMobile && metricRows.length" :data-source="metricRows">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="item.label" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="این دوره">{{ item.current }}</a-descriptions-item>
<a-descriptions-item label="دوره قبل">{{ item.previous }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table <a-table
v-if="metricRows.length" v-else-if="metricRows.length"
:columns="metricColumns" :columns="metricColumns"
:data-source="metricRows" :data-source="metricRows"
:pagination="false" :pagination="false"
@@ -197,7 +213,7 @@ onMounted(() => {
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider> <a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." /> <a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="receivablesByParty">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -212,7 +228,7 @@ onMounted(() => {
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider> <a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." /> <a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="payablesByMonth">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -228,7 +244,7 @@ onMounted(() => {
<template v-if="current"> <template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider> <a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." /> <a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories"> <a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="current.expenseCategories">
<template #renderItem="{ item }"> <template #renderItem="{ item }">
<a-list-item> <a-list-item>
<template #actions> <template #actions>
@@ -0,0 +1,7 @@
import { computed } from 'vue';
import { Grid } from 'ant-design-vue';
export function useTreasuryMobile() {
const screens = Grid.useBreakpoint();
return computed(() => !screens.value.md);
}