From 4409ded0ab6d2bbd3ce21f676683ae64228396aa Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 15 Nov 2026 12:42:01 +0330 Subject: [PATCH] Add treasury document photos, table list, and manage permission. Income/expense lists are table-or-card with detail, edit, void, and delete; edit/delete require owner, admin, or the new finance manage permission. Co-authored-by: Cursor --- apps/api/src/treasury/treasury.controller.ts | 27 + apps/api/src/treasury/treasury.service.ts | 126 +++- .../treasury/TreasuryDocPhotoField.vue | 89 +++ .../treasury/TreasuryDocumentsPanel.vue | 594 ++++++++++++++++++ apps/web/src/lib/treasury-doc-photos.ts | 22 + apps/web/src/views/TeamSettingsView.vue | 1 + .../src/views/treasury/TreasuryDocsView.vue | 203 +++++- .../views/treasury/TreasuryExpensesView.vue | 368 +++++------ .../views/treasury/TreasuryIncomesView.vue | 391 +++++------- packages/shared/src/index.ts | 13 +- 10 files changed, 1383 insertions(+), 451 deletions(-) create mode 100644 apps/web/src/components/treasury/TreasuryDocPhotoField.vue create mode 100644 apps/web/src/components/treasury/TreasuryDocumentsPanel.vue create mode 100644 apps/web/src/lib/treasury-doc-photos.ts diff --git a/apps/api/src/treasury/treasury.controller.ts b/apps/api/src/treasury/treasury.controller.ts index 75880d4..f0ec94f 100644 --- a/apps/api/src/treasury/treasury.controller.ts +++ b/apps/api/src/treasury/treasury.controller.ts @@ -181,6 +181,33 @@ export class TreasuryController { }); } + @Get('documents/:id') + getDocument(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.treasury.getDocument(user, id); + } + + @Patch('documents/:id') + updateDocument( + @CurrentUser() user: Actor, + @Param('id') id: string, + @Body() + body: { + title?: string; + notes?: string | null; + categoryId?: string | null; + method?: string | null; + bookedAt?: string; + amount?: string | number; + }, + ) { + return this.treasury.updateDocument(user, id, body); + } + + @Delete('documents/:id') + deleteDocument(@CurrentUser() user: Actor, @Param('id') id: string) { + return this.treasury.deleteDocument(user, id); + } + @Get('attachments/:id') async downloadAttachment(@CurrentUser() user: Actor, @Param('id') id: string) { const file = await this.treasury.openAttachment(user, id); diff --git a/apps/api/src/treasury/treasury.service.ts b/apps/api/src/treasury/treasury.service.ts index d9cfdc3..6a3abc6 100644 --- a/apps/api/src/treasury/treasury.service.ts +++ b/apps/api/src/treasury/treasury.service.ts @@ -4,7 +4,11 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { parseUserTreasuryAccess } from '@mento/shared'; +import { + canManageTreasuryDocuments, + parseUserTreasuryAccess, + permissionsWithTreasuryOverride, +} from '@mento/shared'; import { randomUUID } from 'node:crypto'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; @@ -506,13 +510,87 @@ export class TreasuryService { } : {}), }, - include: { category: true, account: true, movements: true, attachments: { select: { id: true, fileName: true } } }, + include: { + category: true, + account: true, + movements: true, + attachments: { select: { id: true, fileName: true, mimeType: true } }, + }, orderBy: { bookedAt: 'desc' }, take: 200, }); return rows.map((row) => this.toDocumentDto(row)); } + async getDocument(actor: Actor, id: string): Promise { + const doc = await this.requireScopedDocument(actor, id); + return this.toDocumentDto(doc); + } + + async updateDocument( + actor: Actor, + id: string, + input: { + title?: string; + notes?: string | null; + categoryId?: string | null; + method?: string | null; + bookedAt?: string; + amount?: string | number; + }, + ): Promise { + await this.assertDocumentsManage(actor); + const doc = await this.requireScopedDocument(actor, id); + if (doc.status === 'void' || doc.voidedAt) { + throw new BadRequestException('سند باطل‌شده قابل ویرایش نیست.'); + } + const data: Prisma.FinancialDocumentUncheckedUpdateInput = {}; + if (input.title !== undefined) { + const title = input.title.trim(); + if (title.length < 2) throw new BadRequestException('عنوان را وارد کنید.'); + data.title = title; + } + if (input.notes !== undefined) data.notes = input.notes?.trim() || null; + if (input.categoryId !== undefined) data.categoryId = input.categoryId || null; + if (input.method !== undefined) data.method = input.method || null; + if (input.bookedAt !== undefined) { + const bookedAt = startOfDay(input.bookedAt); + if (!bookedAt) throw new BadRequestException('تاریخ نامعتبر است.'); + data.bookedAt = bookedAt; + } + if (input.amount !== undefined) { + const active = doc.movements.filter((row) => !row.voidedAt); + if (active.length > 0 || doc.status !== 'open') { + throw new BadRequestException('مبلغ فقط برای سند باز بدون دریافت/پرداخت قابل تغییر است.'); + } + const amount = dec(input.amount); + if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.'); + data.amount = amount; + } + await this.prisma.financialDocument.update({ where: { id: doc.id }, data }); + return this.getDocument(actor, id); + } + + async deleteDocument(actor: Actor, id: string): Promise<{ ok: true }> { + await this.assertDocumentsManage(actor); + const doc = await this.requireScopedDocument(actor, id); + const active = doc.movements.filter((row) => !row.voidedAt); + if (doc.status !== 'void' && active.length > 0) { + throw new BadRequestException('ابتدا سند را ابطال کنید، سپس حذف کنید.'); + } + const attachments = await this.prisma.financialAttachment.findMany({ + where: { documentId: doc.id, organizationId: actor.organizationId }, + select: { id: true, storageKey: true }, + }); + for (const file of attachments) { + const bytes = await this.files.sizeOf(file.storageKey); + await this.files.remove(file.storageKey); + if (bytes) await this.storage.addLocal(actor.organizationId, -bytes); + } + await this.prisma.financialDocument.delete({ where: { id: doc.id } }); + return { ok: true }; + } + async createDocument( actor: Actor, input: { @@ -1443,6 +1521,47 @@ export class TreasuryService { ); } + private async actorPermissions(actor: Actor): Promise { + const membership = await this.prisma.membership.findFirst({ + where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true }, + select: { + orgRole: { select: { permissions: true } }, + treasuryAccessOverride: true, + }, + }); + const rolePermissions = Array.isArray(membership?.orgRole?.permissions) + ? membership.orgRole.permissions.map(String) + : []; + return permissionsWithTreasuryOverride( + rolePermissions, + parseUserTreasuryAccess(membership?.treasuryAccessOverride), + ); + } + + private async assertDocumentsManage(actor: Actor) { + if (canManageTreasuryDocuments(actor.role, await this.actorPermissions(actor))) return; + throw new ForbiddenException('اجازه ویرایش یا حذف اسناد را ندارید.'); + } + + private async requireScopedDocument(actor: Actor, id: string) { + const scope = await this.accountScope(actor); + const doc = await this.prisma.financialDocument.findFirst({ + where: { + id, + organizationId: actor.organizationId, + ...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}), + }, + include: { + category: true, + account: true, + movements: true, + attachments: { select: { id: true, fileName: true, mimeType: true } }, + }, + }); + if (!doc) throw new NotFoundException('سند پیدا نشد.'); + return doc; + } + private async accountScope(actor: Actor): Promise { if (actor.role === 'owner' || actor.role === 'admin') return null; const membership = await this.prisma.membership.findFirst({ @@ -1566,7 +1685,8 @@ export class TreasuryService { category?: { name: string } | null; account?: { name: string } | null; movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; - attachments?: Array<{ id: string; fileName: string }>; + attachments?: Array<{ id: string; fileName: string; mimeType?: string }>; + voidedAt?: Date | null; }): FinancialDocumentDto { const settled = this.settledOf(row.movements); const rawRemaining = row.amount.minus(settled); diff --git a/apps/web/src/components/treasury/TreasuryDocPhotoField.vue b/apps/web/src/components/treasury/TreasuryDocPhotoField.vue new file mode 100644 index 0000000..d8fb89e --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryDocPhotoField.vue @@ -0,0 +1,89 @@ + + + diff --git a/apps/web/src/components/treasury/TreasuryDocumentsPanel.vue b/apps/web/src/components/treasury/TreasuryDocumentsPanel.vue new file mode 100644 index 0000000..8821efc --- /dev/null +++ b/apps/web/src/components/treasury/TreasuryDocumentsPanel.vue @@ -0,0 +1,594 @@ + + + diff --git a/apps/web/src/lib/treasury-doc-photos.ts b/apps/web/src/lib/treasury-doc-photos.ts new file mode 100644 index 0000000..7c9692a --- /dev/null +++ b/apps/web/src/lib/treasury-doc-photos.ts @@ -0,0 +1,22 @@ +import { api } from '@/lib/api'; + +export type TreasuryPendingPhoto = { + localId: string; + name: string; + dataUrl: string; + file: File; +}; + +export async function uploadTreasuryDocumentPhotos( + documentId: string, + photos: TreasuryPendingPhoto[], +): Promise { + let attached = 0; + for (const photo of photos) { + const body = new FormData(); + body.append('file', photo.file); + await api.post(`/treasury/documents/${documentId}/attachments`, body); + attached += 1; + } + return attached; +} diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 2d3b723..b006079 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -86,6 +86,7 @@ const PERMISSION_MODULES: Partial> = { treasury_reports: ['treasury'], treasury_audit: ['treasury'], treasury_categories: ['treasury'], + treasury_documents_manage: ['treasury'], behavior: ['behavior'], }; diff --git a/apps/web/src/views/treasury/TreasuryDocsView.vue b/apps/web/src/views/treasury/TreasuryDocsView.vue index b998549..19a7a09 100644 --- a/apps/web/src/views/treasury/TreasuryDocsView.vue +++ b/apps/web/src/views/treasury/TreasuryDocsView.vue @@ -6,15 +6,21 @@ import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui'; import MoneyInput from '@/components/MoneyInput.vue'; import TreasuryNav from '@/components/treasury/TreasuryNav.vue'; +import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress'; import { toman } from './treasury-format'; const props = defineProps<{ kind: 'income' | 'expense' }>(); +const PHOTO_LIMIT = 8; +type PendingPhoto = { localId: string; name: string; dataUrl: string; file: File }; + const rows = ref([]); const categories = ref([]); const accounts = ref([]); const error = ref(''); const ok = ref(''); const saving = ref(false); +const photoBusy = ref(false); +const pendingPhotos = ref([]); const q = ref(''); const form = ref({ title: '', @@ -43,6 +49,76 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } +function resetForm() { + form.value = { + title: '', + amount: '', + categoryId: '', + partyName: '', + accountId: '', + settleAmount: '', + method: 'transfer', + reference: '', + notes: '', + }; + pendingPhotos.value = []; +} + +async function prepareTreasuryImage(file: File) { + if (!file.type.startsWith('image/')) { + throw new Error('فقط فایل تصویری مجاز است.'); + } + try { + return await prepareFormFieldFile(file); + } catch (e) { + if (e instanceof FormFileTooLargeError) { + throw new Error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد'); + } + throw new Error('فشرده‌سازی عکس ناموفق بود.'); + } +} + +async function onPhotosPicked(event: Event) { + const input = event.target as HTMLInputElement; + const files = Array.from(input.files ?? []); + input.value = ''; + if (!files.length) return; + error.value = ''; + for (const file of files) { + if (pendingPhotos.value.length >= PHOTO_LIMIT) { + error.value = `حداکثر ${PHOTO_LIMIT} عکس برای هر سند.`; + break; + } + photoBusy.value = true; + try { + const prepared = await prepareTreasuryImage(file); + pendingPhotos.value = [ + ...pendingPhotos.value, + { + localId: crypto.randomUUID(), + name: prepared.name, + dataUrl: prepared.dataUrl, + file: prepared.file, + }, + ]; + } catch (e) { + error.value = e instanceof Error ? e.message : 'افزودن عکس ناموفق بود.'; + } finally { + photoBusy.value = false; + } + } +} + +function removePendingPhoto(localId: string) { + pendingPhotos.value = pendingPhotos.value.filter((photo) => photo.localId !== localId); +} + +async function uploadPreparedFile(documentId: string, file: File) { + const body = new FormData(); + body.append('file', file); + await api.post(`/treasury/documents/${documentId}/attachments`, body); +} + async function load() { const [docs, cats, acc] = await Promise.all([ api.get('/treasury/documents', { params: { kind: props.kind, q: q.value || undefined } }), @@ -59,7 +135,7 @@ async function createDoc() { error.value = ''; ok.value = ''; try { - await api.post('/treasury/documents', { + const { data } = await api.post('/treasury/documents', { kind: props.kind, title: form.value.title, amount: form.value.amount, @@ -71,18 +147,22 @@ async function createDoc() { reference: form.value.reference || null, notes: form.value.notes || null, }); - form.value = { - title: '', - amount: '', - categoryId: '', - partyName: '', - accountId: '', - settleAmount: '', - method: 'transfer', - reference: '', - notes: '', - }; - ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; + const photos = [...pendingPhotos.value]; + let attached = 0; + for (const photo of photos) { + try { + await uploadPreparedFile(data.id, photo.file); + attached += 1; + } catch (e) { + error.value = apiMessage(e); + break; + } + } + resetForm(); + ok.value = + attached > 0 + ? `سند ثبت شد و ${attached} عکس پیوست شد.` + : 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.'; await load(); } catch (e) { error.value = apiMessage(e); @@ -115,16 +195,22 @@ async function downloadAttachment(id: string, fileName: string) { } async function upload(id: string, event: Event) { - const file = (event.target as HTMLInputElement).files?.[0]; + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; if (!file) return; - const body = new FormData(); - body.append('file', file); + error.value = ''; + ok.value = ''; + photoBusy.value = true; try { - await api.post(`/treasury/documents/${id}/attachments`, body); - ok.value = 'پیوست ذخیره شد.'; + const prepared = await prepareTreasuryImage(file); + await uploadPreparedFile(id, prepared.file); + ok.value = 'عکس پیوست ذخیره شد.'; await load(); } catch (e) { - error.value = apiMessage(e); + error.value = e instanceof Error && !(axios.isAxiosError(e)) ? e.message : apiMessage(e); + } finally { + photoBusy.value = false; } } @@ -139,6 +225,9 @@ async function voidDoc(id: string) { } watch(() => props.kind, () => { + resetForm(); + error.value = ''; + ok.value = ''; void load(); }); @@ -184,8 +273,30 @@ onMounted(() => { - ثبت - خروجی اکسل +
+
+ عکس فاکتور / رسید + اختیاری · فشرده‌سازی خودکار · حداکثر ۲۰۰ کیلوبایت برای هر عکس · تا {{ PHOTO_LIMIT }} عکس +
+
+
+ + +
+
+ +
+ ثبت + خروجی اکسل
@@ -210,9 +321,9 @@ onMounted(() => { > {{ file.fileName }} -
@@ -231,7 +342,51 @@ onMounted(() => { .side { display: grid; justify-items: end; gap: 0.3rem; } .side button, .file { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; } .file { color: #0f766e; font-weight: 700; } +.file.busy { opacity: 0.6; pointer-events: none; } .file input { display: none; } +.photos { display: grid; gap: 0.55rem; margin-bottom: 0.85rem; } +.photos-head { display: grid; gap: 0.15rem; } +.photos-head small { color: #64748b; font-size: 0.82rem; } +.thumbs { display: flex; flex-wrap: wrap; gap: 0.55rem; } +.thumbs figure { + margin: 0; + width: 88px; + display: grid; + gap: 0.25rem; + justify-items: center; +} +.thumbs img { + width: 88px; + height: 88px; + object-fit: cover; + border-radius: 12px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} +.thumbs button { + border: 0; + background: transparent; + color: #b91c1c; + cursor: pointer; + font: inherit; + font-size: 0.8rem; +} +.add-photo { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-height: var(--control-h); + padding: 0 0.9rem; + border: 1px dashed #94a3b8; + border-radius: 12px; + background: #fff; + color: #0f766e; + font-weight: 700; + cursor: pointer; +} +.add-photo.busy { opacity: 0.55; pointer-events: none; } +.add-photo input { display: none; } .err { color: #b91c1c; font-weight: 700; } .ok { color: #0f766e; font-weight: 700; } diff --git a/apps/web/src/views/treasury/TreasuryExpensesView.vue b/apps/web/src/views/treasury/TreasuryExpensesView.vue index 7c4054c..75d0043 100644 --- a/apps/web/src/views/treasury/TreasuryExpensesView.vue +++ b/apps/web/src/views/treasury/TreasuryExpensesView.vue @@ -6,13 +6,10 @@ import { Button as AButton, Card as ACard, Col as ACol, - Divider as ADivider, - Empty as AEmpty, Flex as AFlex, Form as AForm, FormItem as AFormItem, Input as AInput, - List as AList, Row as ARow, Segmented as ASegmented, Select as ASelect, @@ -30,16 +27,20 @@ import type { } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryDocPhotoField from '@/components/treasury/TreasuryDocPhotoField.vue'; +import TreasuryDocumentsPanel from '@/components/treasury/TreasuryDocumentsPanel.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; import { useSettleDefault } from '@/components/treasury/useSettleDefault'; +import { + uploadTreasuryDocumentPhotos, + type TreasuryPendingPhoto, +} from '@/lib/treasury-doc-photos'; import { partyNetPosition, toman } from './treasury-format'; import { useTreasuryMobile } from './useTreasuryMobile'; import { parseMoneyNumber } from '@/lib/money'; const { Text: ATypographyText } = Typography; -const AListItem = AList.Item; -const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; const isMobile = useTreasuryMobile(); @@ -52,6 +53,7 @@ const accounts = ref([]); const error = ref(''); const ok = ref(''); const saving = ref(false); +const pendingPhotos = ref([]); const q = ref(''); const form = ref({ title: '', @@ -95,17 +97,6 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } -function docLine(item: FinancialDocumentDto) { - const parts = [ - item.reference ? `پیگیری ${item.reference}` : '', - item.partyName || 'بدون طرف حساب', - statusLabel[item.status] ?? item.status, - `مانده ${toman(item.remaining)}`, - ].filter(Boolean); - if (item.categoryName) parts.push(item.categoryName); - return parts.join(' · '); -} - async function load() { const [docs, cats, acc, bals, receivables] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), @@ -202,7 +193,17 @@ async function createDoc() { notes: form.value.notes || null, bookedAt: form.value.bookedAt || undefined, }); + const photos = [...pendingPhotos.value]; + let attached = 0; + if (photos.length) { + try { + attached = await uploadTreasuryDocumentPhotos(data.id, photos); + } catch (e) { + error.value = apiMessage(e); + } + } settleMirror.reset(); + pendingPhotos.value = []; form.value = { title: '', amount: '', @@ -215,11 +216,12 @@ async function createDoc() { notes: '', bookedAt: formatLocalDateInput(new Date()), }; - ok.value = collectsBalance + const base = collectsBalance ? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.` : data.reference ? `سند ثبت شد. شماره پیگیری: ${data.reference}` : 'سند ثبت شد.'; + ok.value = attached > 0 ? `${base} ${attached} عکس پیوست شد.` : base; await load(); } catch (e) { error.value = apiMessage(e); @@ -279,16 +281,6 @@ const partyPosition = computed(() => { return partyNetPosition(receivable, payable); }); -async function voidDoc(id: string) { - if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; - try { - await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' }); - await load(); - } catch (e) { - error.value = apiMessage(e); - } -} - onMounted(() => { void load().catch(() => { error.value = 'بارگذاری ناموفق بود.'; @@ -298,190 +290,154 @@ onMounted(() => { diff --git a/apps/web/src/views/treasury/TreasuryIncomesView.vue b/apps/web/src/views/treasury/TreasuryIncomesView.vue index 23eb27b..2eae861 100644 --- a/apps/web/src/views/treasury/TreasuryIncomesView.vue +++ b/apps/web/src/views/treasury/TreasuryIncomesView.vue @@ -6,14 +6,11 @@ import { Button as AButton, Card as ACard, Col as ACol, - Divider as ADivider, - Empty as AEmpty, Flex as AFlex, Form as AForm, FormItem as AFormItem, Grid, Input as AInput, - List as AList, Row as ARow, Segmented as ASegmented, Select as ASelect, @@ -31,15 +28,19 @@ import type { } from '@mento/shared'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import MoneyInput from '@/components/MoneyInput.vue'; +import TreasuryDocPhotoField from '@/components/treasury/TreasuryDocPhotoField.vue'; +import TreasuryDocumentsPanel from '@/components/treasury/TreasuryDocumentsPanel.vue'; import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue'; import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue'; import { useSettleDefault } from '@/components/treasury/useSettleDefault'; +import { + uploadTreasuryDocumentPhotos, + type TreasuryPendingPhoto, +} from '@/lib/treasury-doc-photos'; import { partyNetPosition, toman } from './treasury-format'; import { parseMoneyNumber } from '@/lib/money'; const { Text: ATypographyText } = Typography; -const AListItem = AList.Item; -const AListItemMeta = AList.Item.Meta; const ASelectOption = ASelect.Option; const screens = Grid.useBreakpoint(); const isMobile = computed(() => !screens.value.md); @@ -53,6 +54,7 @@ const accounts = ref([]); const error = ref(''); const ok = ref(''); const saving = ref(false); +const pendingPhotos = ref([]); const q = ref(''); function emptyForm() { @@ -156,17 +158,6 @@ function apiMessage(e: unknown) { return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.'; } -function docLine(item: FinancialDocumentDto) { - const parts = [ - item.reference ? `پیگیری ${item.reference}` : '', - item.partyName || 'بدون طرف حساب', - statusLabel[item.status] ?? item.status, - `مانده ${toman(item.remaining)}`, - ].filter(Boolean); - if (item.categoryName) parts.push(item.categoryName); - return parts.join(' · '); -} - async function load() { const [docs, cats, acc, bals, payables] = await Promise.all([ api.get('/treasury/documents', { params: { kind, q: q.value || undefined } }), @@ -201,6 +192,7 @@ async function load() { function resetForm() { settleMirror.reset(); form.value = emptyForm(); + pendingPhotos.value = []; error.value = ''; ok.value = ''; } @@ -266,12 +258,22 @@ async function createDoc() { notes: form.value.notes || null, bookedAt: form.value.bookedAt || undefined, }); + const photos = [...pendingPhotos.value]; + let attached = 0; + if (photos.length) { + try { + attached = await uploadTreasuryDocumentPhotos(data.id, photos); + } catch (e) { + error.value = apiMessage(e); + } + } resetForm(); - ok.value = collectsBalance + const base = collectsBalance ? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.` : data.reference ? `سند ثبت شد. شماره پیگیری: ${data.reference}` : 'سند ثبت شد.'; + ok.value = attached > 0 ? `${base} ${attached} عکس پیوست شد.` : base; await load(); } catch (e) { error.value = apiMessage(e); @@ -280,16 +282,6 @@ async function createDoc() { } } -async function voidDoc(id: string) { - if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return; - try { - await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' }); - await load(); - } catch (e) { - error.value = apiMessage(e); - } -} - onMounted(() => { void load().catch(() => { error.value = 'بارگذاری ناموفق بود.'; @@ -299,199 +291,164 @@ onMounted(() => { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0475a5d..00193ee 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -892,6 +892,7 @@ export type RolePermissionKey = | "treasury_reports" | "treasury_audit" | "treasury_categories" + | "treasury_documents_manage" | "behavior"; /** بخشی که مسئولش را می‌شود به کاربر دیگری داد */ @@ -947,6 +948,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{ { key: "treasury_reports", label: "مالی — گزارش" }, { key: "treasury_audit", label: "مالی — سوابق" }, { key: "treasury_categories", label: "مالی — دسته‌بندی" }, + { key: "treasury_documents_manage", label: "مالی — ویرایش و حذف اسناد" }, { key: "behavior", label: "شخصیت‌شناسی و دستیار فروش" }, ]; @@ -990,6 +992,15 @@ export function treasurySectionAllowed( return perms.includes(section); } +/** ویرایش/حذف اسناد مالی — مالک، مدیر، یا کلید اختصاصی در نقش/override */ +export function canManageTreasuryDocuments( + role: string | null | undefined, + permissions: readonly string[] | null | undefined, +): boolean { + if (role === "owner" || role === "admin") return true; + return (permissions ?? []).includes("treasury_documents_manage"); +} + export interface OrgRoleMemberDto { id: string; fullName: string; @@ -6801,7 +6812,7 @@ export interface FinancialDocumentDto { reference: string | null; notes: string | null; createdAt: string; - attachments?: Array<{ id: string; fileName: string }>; + attachments?: Array<{ id: string; fileName: string; mimeType?: string }>; } export interface FinancialMovementDto {