feat: add treasury audit, attachment download, and installment SMS
Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import type {
|
|||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
|
|
||||||
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
function dec(value: Prisma.Decimal | number | string | null | undefined) {
|
||||||
@@ -45,6 +47,7 @@ export class TreasuryReceivablesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
async list(actor: Actor): Promise<FinancialReceivableDto[]> {
|
||||||
@@ -211,6 +214,27 @@ export class TreasuryReceivablesService {
|
|||||||
return this.get(actor, installment.receivableId);
|
return this.get(actor, installment.receivableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async excel(actor: Actor) {
|
||||||
|
const rows = await this.list(actor);
|
||||||
|
const book = new ExcelJS.Workbook();
|
||||||
|
const sheet = book.addWorksheet('اقساط');
|
||||||
|
sheet.addRow(['قرارداد', 'طرف حساب', 'قسط', 'سررسید', 'مبلغ', 'مانده', 'وضعیت']);
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const item of row.installments) {
|
||||||
|
sheet.addRow([
|
||||||
|
row.title,
|
||||||
|
row.partyName ?? '',
|
||||||
|
item.position,
|
||||||
|
item.dueDate.slice(0, 10),
|
||||||
|
item.amount,
|
||||||
|
item.remaining,
|
||||||
|
item.status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Buffer.from(await book.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
async upcoming(actor: Actor) {
|
async upcoming(actor: Actor) {
|
||||||
const rows = await this.prisma.financialInstallment.findMany({
|
const rows = await this.prisma.financialInstallment.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -264,18 +288,27 @@ export class TreasuryReceivablesService {
|
|||||||
},
|
},
|
||||||
select: { userId: true },
|
select: { userId: true },
|
||||||
});
|
});
|
||||||
|
const title = dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید';
|
||||||
|
const body = `${row.receivable.title} · قسط ${row.position} · مانده ${dto.remaining}`;
|
||||||
for (const admin of admins) {
|
for (const admin of admins) {
|
||||||
await this.notifications.create({
|
await this.notifications.create({
|
||||||
organizationId: row.receivable.organizationId,
|
organizationId: row.receivable.organizationId,
|
||||||
userId: admin.userId,
|
userId: admin.userId,
|
||||||
type: 'treasury_installment',
|
type: 'treasury_installment',
|
||||||
title: dto.status === 'overdue' ? 'قسط معوق' : 'قسط نزدیک سررسید',
|
title,
|
||||||
body: `${row.receivable.title} · قسط ${row.position}`,
|
body,
|
||||||
link: '/finance/receivables',
|
link: '/finance/receivables',
|
||||||
entityType: 'financial_installment',
|
entityType: 'financial_installment',
|
||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
dedupe: true,
|
dedupe: true,
|
||||||
});
|
});
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: admin.userId },
|
||||||
|
select: { mobile: true },
|
||||||
|
});
|
||||||
|
if (user?.mobile && this.sms.isConfigured()) {
|
||||||
|
await this.sms.sendSmsDetailed(user.mobile, `${title}: ${body}`).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await this.prisma.financialInstallment.update({
|
await this.prisma.financialInstallment.update({
|
||||||
where: { id: row.id },
|
where: { id: row.id },
|
||||||
|
|||||||
@@ -95,6 +95,35 @@ export class TreasuryController {
|
|||||||
return this.treasury.createDocument(user, body);
|
return this.treasury.createDocument(user, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('documents/export')
|
||||||
|
async exportDocuments(
|
||||||
|
@CurrentUser() user: Actor,
|
||||||
|
@Query('kind') kind?: string,
|
||||||
|
) {
|
||||||
|
const buffer = await this.treasury.documentsExcel(
|
||||||
|
user,
|
||||||
|
kind === 'expense' ? 'expense' : 'income',
|
||||||
|
);
|
||||||
|
return new StreamableFile(buffer, {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
disposition: 'attachment; filename="treasury-documents.xlsx"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('attachments/:id')
|
||||||
|
async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||||
|
const file = await this.treasury.openAttachment(user, id);
|
||||||
|
return new StreamableFile(file.stream, {
|
||||||
|
type: file.mimeType,
|
||||||
|
disposition: `attachment; filename="${encodeURIComponent(file.fileName)}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('audit')
|
||||||
|
audit(@CurrentUser() user: Actor) {
|
||||||
|
return this.treasury.listAudit(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('documents/:id/attachments')
|
@Post('documents/:id/attachments')
|
||||||
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
|
@UseInterceptors(FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 8_000_000 } }))
|
||||||
addAttachment(
|
addAttachment(
|
||||||
@@ -114,6 +143,15 @@ export class TreasuryController {
|
|||||||
return this.treasury.settleDocument(user, id, body);
|
return this.treasury.settleDocument(user, id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('receivables/export')
|
||||||
|
async exportReceivables(@CurrentUser() user: Actor) {
|
||||||
|
const buffer = await this.receivables.excel(user);
|
||||||
|
return new StreamableFile(buffer, {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
disposition: 'attachment; filename="treasury-installments.xlsx"',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get('receivables')
|
@Get('receivables')
|
||||||
receivablesList(@CurrentUser() user: Actor) {
|
receivablesList(@CurrentUser() user: Actor) {
|
||||||
return this.receivables.list(user);
|
return this.receivables.list(user);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { SmsService } from '../common/sms.service';
|
||||||
import { TreasuryController } from './treasury.controller';
|
import { TreasuryController } from './treasury.controller';
|
||||||
import { TreasuryAccessGuard } from './treasury-access.guard';
|
import { TreasuryAccessGuard } from './treasury-access.guard';
|
||||||
import { TreasuryBridgeService } from './treasury-bridge.service';
|
import { TreasuryBridgeService } from './treasury-bridge.service';
|
||||||
@@ -17,6 +18,7 @@ import { TreasuryService } from './treasury.service';
|
|||||||
TreasuryOpsService,
|
TreasuryOpsService,
|
||||||
TreasuryBridgeService,
|
TreasuryBridgeService,
|
||||||
TreasuryAccessGuard,
|
TreasuryAccessGuard,
|
||||||
|
SmsService,
|
||||||
],
|
],
|
||||||
exports: [TreasuryService, TreasuryBridgeService],
|
exports: [TreasuryService, TreasuryBridgeService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
import type {
|
import type {
|
||||||
FinancialAccountDto,
|
FinancialAccountDto,
|
||||||
FinancialCategoryDto,
|
FinancialCategoryDto,
|
||||||
@@ -365,7 +366,7 @@ export class TreasuryService {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: { category: true, account: true, movements: true },
|
include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } },
|
||||||
orderBy: { bookedAt: 'desc' },
|
orderBy: { bookedAt: 'desc' },
|
||||||
take: 200,
|
take: 200,
|
||||||
});
|
});
|
||||||
@@ -555,6 +556,55 @@ export class TreasuryService {
|
|||||||
return this.toDocumentDto(fresh);
|
return this.toDocumentDto(fresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listAudit(actor: Actor) {
|
||||||
|
const rows = await this.prisma.financialAuditLog.findMany({
|
||||||
|
where: { organizationId: actor.organizationId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
action: row.action,
|
||||||
|
before: row.before,
|
||||||
|
after: row.after,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async openAttachment(actor: Actor, id: string) {
|
||||||
|
const row = await this.prisma.financialAttachment.findFirst({
|
||||||
|
where: { id, organizationId: actor.organizationId },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('پیوست پیدا نشد.');
|
||||||
|
return {
|
||||||
|
stream: this.files.openReadStream(row.storageKey),
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
fileName: row.fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async documentsExcel(actor: Actor, kind: 'income' | 'expense') {
|
||||||
|
const rows = await this.listDocuments(actor, kind);
|
||||||
|
const book = new ExcelJS.Workbook();
|
||||||
|
const sheet = book.addWorksheet(kind === 'income' ? 'درآمدها' : 'هزینهها');
|
||||||
|
sheet.addRow(['عنوان', 'مبلغ', 'تسویه', 'مانده', 'وضعیت', 'دسته', 'طرف حساب', 'تاریخ']);
|
||||||
|
for (const row of rows) {
|
||||||
|
sheet.addRow([
|
||||||
|
row.title,
|
||||||
|
row.amount,
|
||||||
|
row.settledAmount,
|
||||||
|
row.remaining,
|
||||||
|
row.status,
|
||||||
|
row.categoryName ?? '',
|
||||||
|
row.partyName ?? '',
|
||||||
|
row.bookedAt.slice(0, 10),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Buffer.from(await book.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
|
async addAttachment(actor: Actor, documentId: string, file: { originalname: string; mimetype: string; buffer: Buffer }) {
|
||||||
const doc = await this.prisma.financialDocument.findFirst({
|
const doc = await this.prisma.financialDocument.findFirst({
|
||||||
where: { id: documentId, organizationId: actor.organizationId },
|
where: { id: documentId, organizationId: actor.organizationId },
|
||||||
@@ -774,6 +824,7 @@ export class TreasuryService {
|
|||||||
category?: { name: string } | null;
|
category?: { name: string } | null;
|
||||||
account?: { name: string } | null;
|
account?: { name: string } | null;
|
||||||
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>;
|
||||||
|
attachments?: Array<{ id: string; fileName: string }>;
|
||||||
}): FinancialDocumentDto {
|
}): FinancialDocumentDto {
|
||||||
const settled = this.settledOf(row.movements);
|
const settled = this.settledOf(row.movements);
|
||||||
const rawRemaining = row.amount.minus(settled);
|
const rawRemaining = row.amount.minus(settled);
|
||||||
@@ -798,6 +849,7 @@ export class TreasuryService {
|
|||||||
reference: row.reference,
|
reference: row.reference,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
attachments: row.attachments ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const links = computed(() => [
|
|||||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||||
{ to: '/finance/reports', label: 'گزارش' },
|
{ to: '/finance/reports', label: 'گزارش' },
|
||||||
|
{ to: '/finance/audit', label: 'سوابق' },
|
||||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,12 @@ export const router = createRouter({
|
|||||||
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/audit',
|
||||||
|
name: 'finance-audit',
|
||||||
|
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
|
||||||
|
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/categories',
|
path: 'finance/categories',
|
||||||
name: 'finance-categories',
|
name: 'finance-categories',
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const items = ref<ChequeDto[]>([]);
|
|||||||
const status = ref('');
|
const status = ref('');
|
||||||
const q = ref('');
|
const q = ref('');
|
||||||
const savingId = ref('');
|
const savingId = ref('');
|
||||||
|
const clearOpen = ref(false);
|
||||||
|
const clearRow = ref<ChequeDto | null>(null);
|
||||||
|
const clearAccountId = ref('');
|
||||||
|
const clearAccounts = ref<Array<{ id: string; name: string }>>([]);
|
||||||
const tab = ref<ChequeDirection>('in');
|
const tab = ref<ChequeDirection>('in');
|
||||||
|
|
||||||
const formOpen = ref(false);
|
const formOpen = ref(false);
|
||||||
@@ -95,9 +99,26 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmClear() {
|
||||||
|
if (!clearRow.value || !clearAccountId.value) return;
|
||||||
|
savingId.value = clearRow.value.id;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const { data } = await api.patch<ChequeDto>(`/cheques/${clearRow.value.id}`, {
|
||||||
|
status: 'cleared',
|
||||||
|
financialAccountId: clearAccountId.value,
|
||||||
|
});
|
||||||
|
items.value = items.value.map((item) => (item.id === data.id ? data : item));
|
||||||
|
clearOpen.value = false;
|
||||||
|
} catch {
|
||||||
|
error.value = 'وصول چک ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
savingId.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
||||||
if (row.status === next) return;
|
if (row.status === next) return;
|
||||||
let financialAccountId: string | undefined;
|
|
||||||
if (next === 'cleared' && row.status !== 'cleared') {
|
if (next === 'cleared' && row.status !== 'cleared') {
|
||||||
const accountsRes = await api
|
const accountsRes = await api
|
||||||
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
|
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
|
||||||
@@ -114,30 +135,20 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accounts = accountsRes.data;
|
const active = accountsRes.data.filter((item) => item.isActive);
|
||||||
const active = accounts.filter((item) => item.isActive);
|
|
||||||
if (!active.length) {
|
if (!active.length) {
|
||||||
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
|
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (active.length === 1) {
|
clearAccounts.value = active.map((item) => ({ id: item.id, name: item.name }));
|
||||||
financialAccountId = active[0]!.id;
|
clearAccountId.value = active[0]!.id;
|
||||||
} else {
|
clearRow.value = row;
|
||||||
const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`);
|
clearOpen.value = true;
|
||||||
const match = active.find((item) => item.name.trim() === picked?.trim());
|
return;
|
||||||
if (!match) {
|
|
||||||
error.value = 'حساب صندوق انتخاب نشد.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
financialAccountId = match.id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
savingId.value = row.id;
|
savingId.value = row.id;
|
||||||
try {
|
try {
|
||||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
|
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
|
||||||
status: next,
|
|
||||||
...(financialAccountId ? { financialAccountId } : {}),
|
|
||||||
});
|
|
||||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'تغییر وضعیت چک ناموفق بود.';
|
error.value = 'تغییر وضعیت چک ناموفق بود.';
|
||||||
@@ -326,6 +337,24 @@ onMounted(load);
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UiModal :open="clearOpen" title="حساب وصول چک" @close="clearOpen = false">
|
||||||
|
<p class="hint">چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>حساب</span>
|
||||||
|
<select v-model="clearAccountId">
|
||||||
|
<option v-for="account in clearAccounts" :key="account.id" :value="account.id">
|
||||||
|
{{ account.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<UiButton
|
||||||
|
:loading="savingId === clearRow?.id"
|
||||||
|
@click="confirmClear"
|
||||||
|
>
|
||||||
|
ثبت وصول
|
||||||
|
</UiButton>
|
||||||
|
</UiModal>
|
||||||
|
|
||||||
<UiModal
|
<UiModal
|
||||||
:open="formOpen"
|
:open="formOpen"
|
||||||
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { UiPageHeader } from '@/components/ui';
|
||||||
|
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||||
|
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
createdAt: string;
|
||||||
|
before: unknown;
|
||||||
|
after: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = ref<Row[]>([]);
|
||||||
|
const error = ref('');
|
||||||
|
const actionLabel: Record<string, string> = {
|
||||||
|
settle: 'تسویه سند',
|
||||||
|
cash_post: 'نشستن وجه در حساب',
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get<Row[]>('/treasury/audit');
|
||||||
|
rows.value = data;
|
||||||
|
} catch {
|
||||||
|
error.value = 'سوابق بارگذاری نشد.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<UiPageHeader title="سوابق تغییرات مالی" description="تسویه سند و نشستن وجه در صندوق، با مقدار ثبتشده." />
|
||||||
|
<TreasuryNav />
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
<p v-if="!error && !rows.length" class="muted">سابقهای نیست.</p>
|
||||||
|
<article v-for="row in rows" :key="row.id">
|
||||||
|
<header>
|
||||||
|
<strong>{{ actionLabel[row.action] ?? row.action }}</strong>
|
||||||
|
<time>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</time>
|
||||||
|
</header>
|
||||||
|
<pre>{{ JSON.stringify(row.after ?? row.before, null, 2) }}</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { display: grid; gap: 0.8rem; }
|
||||||
|
article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||||
|
header { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||||
|
time, .muted { color: #64748b; }
|
||||||
|
pre { margin: 0.45rem 0 0; white-space: pre-wrap; font: inherit; color: #334155; }
|
||||||
|
.err { color: #b91c1c; font-weight: 700; }
|
||||||
|
</style>
|
||||||
@@ -91,6 +91,29 @@ async function createDoc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadExcel() {
|
||||||
|
const { data } = await api.get<Blob>('/treasury/documents/export', {
|
||||||
|
params: { kind: props.kind },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = props.kind === 'income' ? 'incomes.xlsx' : 'expenses.xlsx';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadAttachment(id: string, fileName: string) {
|
||||||
|
const { data } = await api.get<Blob>(`/treasury/attachments/${id}`, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(id: string, event: Event) {
|
async function upload(id: string, event: Event) {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -99,6 +122,7 @@ async function upload(id: string, event: Event) {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/treasury/documents/${id}/attachments`, body);
|
await api.post(`/treasury/documents/${id}/attachments`, body);
|
||||||
ok.value = 'پیوست ذخیره شد.';
|
ok.value = 'پیوست ذخیره شد.';
|
||||||
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiMessage(e);
|
error.value = apiMessage(e);
|
||||||
}
|
}
|
||||||
@@ -161,6 +185,7 @@ onMounted(() => {
|
|||||||
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
|
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
|
||||||
</div>
|
</div>
|
||||||
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
|
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
|
||||||
|
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
|
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
|
||||||
<div class="list">
|
<div class="list">
|
||||||
@@ -176,6 +201,15 @@ onMounted(() => {
|
|||||||
<div class="side">
|
<div class="side">
|
||||||
<b>{{ toman(row.amount) }}</b>
|
<b>{{ toman(row.amount) }}</b>
|
||||||
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
||||||
|
<button
|
||||||
|
v-for="file in row.attachments ?? []"
|
||||||
|
:key="file.id"
|
||||||
|
type="button"
|
||||||
|
class="file"
|
||||||
|
@click="downloadAttachment(file.id, file.fileName)"
|
||||||
|
>
|
||||||
|
{{ file.fileName }}
|
||||||
|
</button>
|
||||||
<label v-if="row.status !== 'void'" class="file">
|
<label v-if="row.status !== 'void'" class="file">
|
||||||
پیوست
|
پیوست
|
||||||
<input type="file" @change="upload(row.id, $event)" />
|
<input type="file" @change="upload(row.id, $event)" />
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ async function load() {
|
|||||||
accounts.value = acc.data.filter((row) => row.isActive);
|
accounts.value = acc.data.filter((row) => row.isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadExcel() {
|
||||||
|
const { data } = await api.get<Blob>('/treasury/receivables/export', { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'installments.xlsx';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
async function createContract() {
|
async function createContract() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
@@ -148,6 +158,7 @@ onMounted(() => {
|
|||||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||||
|
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||||
</UiCard>
|
</UiCard>
|
||||||
|
|
||||||
<article v-for="row in rows" :key="row.id" class="contract">
|
<article v-for="row in rows" :key="row.id" class="contract">
|
||||||
|
|||||||
@@ -5664,6 +5664,7 @@ export interface FinancialDocumentDto {
|
|||||||
reference: string | null;
|
reference: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
attachments?: Array<{ id: string; fileName: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinancialMovementDto {
|
export interface FinancialMovementDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user