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:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user