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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<FinancialDocumentDto> {
|
||||
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<FinancialDocumentDto> {
|
||||
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<string[]> {
|
||||
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<string[] | null> {
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Button as AButton, Upload as AUpload, message } from 'ant-design-vue';
|
||||
import type { UploadFile, UploadProps } from 'ant-design-vue';
|
||||
import { UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { FormFileTooLargeError, prepareFormFieldFile } from '@/lib/form-file-compress';
|
||||
import type { TreasuryPendingPhoto } from '@/lib/treasury-doc-photos';
|
||||
|
||||
export type { TreasuryPendingPhoto };
|
||||
|
||||
const PHOTO_LIMIT = 8;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
);
|
||||
|
||||
const model = defineModel<TreasuryPendingPhoto[]>({ default: () => [] });
|
||||
const busy = ref(false);
|
||||
|
||||
const fileList = computed<UploadFile[]>(() =>
|
||||
model.value.map((photo) => ({
|
||||
uid: photo.localId,
|
||||
name: photo.name,
|
||||
status: 'done',
|
||||
url: photo.dataUrl,
|
||||
})),
|
||||
);
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||
if (props.disabled || busy.value) return AUpload.LIST_IGNORE;
|
||||
if (model.value.length >= PHOTO_LIMIT) {
|
||||
message.warning(`حداکثر ${PHOTO_LIMIT} عکس برای هر سند.`);
|
||||
return AUpload.LIST_IGNORE;
|
||||
}
|
||||
const raw = file as File;
|
||||
if (!raw.type.startsWith('image/')) {
|
||||
message.error('فقط فایل تصویری مجاز است.');
|
||||
return AUpload.LIST_IGNORE;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const prepared = await prepareFormFieldFile(raw);
|
||||
model.value = [
|
||||
...model.value,
|
||||
{
|
||||
localId: crypto.randomUUID(),
|
||||
name: prepared.name,
|
||||
dataUrl: prepared.dataUrl,
|
||||
file: prepared.file,
|
||||
},
|
||||
];
|
||||
} catch (e) {
|
||||
if (e instanceof FormFileTooLargeError) {
|
||||
message.error('حجم عکس باید زیر ۲۰۰ کیلوبایت باشد');
|
||||
} else {
|
||||
message.error('فشردهسازی عکس ناموفق بود.');
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
return AUpload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
const onRemove: UploadProps['onRemove'] = (file) => {
|
||||
if (props.disabled || busy.value) return false;
|
||||
model.value = model.value.filter((photo) => photo.localId !== file.uid);
|
||||
return true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AUpload
|
||||
list-type="picture"
|
||||
accept="image/*"
|
||||
:file-list="fileList"
|
||||
:disabled="disabled || busy || model.length >= PHOTO_LIMIT"
|
||||
:before-upload="beforeUpload"
|
||||
:on-remove="onRemove"
|
||||
multiple
|
||||
>
|
||||
<AButton :disabled="disabled || busy || model.length >= PHOTO_LIMIT">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
{{ busy ? 'آمادهسازی…' : 'افزودن عکس' }}
|
||||
</AButton>
|
||||
</AUpload>
|
||||
</template>
|
||||
@@ -0,0 +1,594 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Image as AImage,
|
||||
Input as AInput,
|
||||
Modal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Tooltip as ATooltip,
|
||||
Typography,
|
||||
message,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
canManageTreasuryDocuments,
|
||||
type FinancialCategoryDto,
|
||||
type FinancialDocumentDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, formatLocalDateInput } from '@/lib/jalali';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: FinancialDocumentDto[];
|
||||
categories: FinancialCategoryDto[];
|
||||
statusLabel: Record<string, string>;
|
||||
q: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:q': [value: string];
|
||||
reload: [];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const ASelectOption = ASelect.Option;
|
||||
const ADescriptionsItem = ADescriptions.Item;
|
||||
const AImagePreviewGroup = AImage.PreviewGroup;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const isMobile = useTreasuryMobile();
|
||||
const canManage = computed(() =>
|
||||
canManageTreasuryDocuments(auth.user?.role, auth.user?.permissions),
|
||||
);
|
||||
|
||||
const methodLabel: Record<string, string> = {
|
||||
cash: 'نقدی',
|
||||
card_to_card: 'کارتبهکارت',
|
||||
pos: 'کارتخوان',
|
||||
transfer: 'حواله',
|
||||
other: 'سایر',
|
||||
};
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
||||
{ value: 'pos', label: 'کارتخوان' },
|
||||
{ value: 'transfer', label: 'حواله' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
];
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
open: 'default',
|
||||
partial: 'processing',
|
||||
settled: 'success',
|
||||
void: 'error',
|
||||
};
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<FinancialDocumentDto | null>(null);
|
||||
const previewUrls = ref<Record<string, string>>({});
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const editId = ref<string | null>(null);
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
notes: '',
|
||||
categoryId: undefined as string | undefined,
|
||||
method: undefined as string | undefined,
|
||||
bookedAt: '',
|
||||
amount: '',
|
||||
});
|
||||
const editAmountLocked = ref(true);
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||
}
|
||||
|
||||
function clearPreviews() {
|
||||
for (const url of Object.values(previewUrls.value)) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
previewUrls.value = {};
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearPreviews);
|
||||
|
||||
watch(detailOpen, (open) => {
|
||||
if (!open) {
|
||||
clearPreviews();
|
||||
detail.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAttachmentPreview(id: string, mimeType?: string) {
|
||||
if (previewUrls.value[id]) return;
|
||||
if (mimeType && !mimeType.startsWith('image/')) return;
|
||||
try {
|
||||
const { data } = await api.get<Blob>(`/treasury/attachments/${id}`, { responseType: 'blob' });
|
||||
if (!data || data.type.includes('json') || data.size === 0) return;
|
||||
if (data.type && !data.type.startsWith('image/')) return;
|
||||
previewUrls.value = { ...previewUrls.value, [id]: URL.createObjectURL(data) };
|
||||
} catch {
|
||||
/* ignore preview failures */
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: FinancialDocumentDto) {
|
||||
detailOpen.value = true;
|
||||
detailLoading.value = true;
|
||||
detail.value = row;
|
||||
clearPreviews();
|
||||
try {
|
||||
const { data } = await api.get<FinancialDocumentDto>(`/treasury/documents/${row.id}`);
|
||||
detail.value = data;
|
||||
await Promise.all(
|
||||
(data.attachments ?? []).map((att) => loadAttachmentPreview(att.id, att.mimeType)),
|
||||
);
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e));
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: FinancialDocumentDto) {
|
||||
editId.value = row.id;
|
||||
editForm.title = row.title;
|
||||
editForm.notes = row.notes ?? '';
|
||||
editForm.categoryId = row.categoryId ?? undefined;
|
||||
editForm.method = row.method ?? undefined;
|
||||
editForm.bookedAt = formatLocalDateInput(new Date(row.bookedAt));
|
||||
editForm.amount = String(row.amount ?? '');
|
||||
editAmountLocked.value = row.status !== 'open' || Number(row.settledAmount) > 0;
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId.value) return;
|
||||
const title = editForm.title.trim();
|
||||
if (title.length < 2) {
|
||||
message.warning('عنوان را وارد کنید.');
|
||||
return;
|
||||
}
|
||||
editSaving.value = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
title,
|
||||
notes: editForm.notes.trim() || null,
|
||||
categoryId: editForm.categoryId || null,
|
||||
method: editForm.method || null,
|
||||
bookedAt: editForm.bookedAt || undefined,
|
||||
};
|
||||
if (!editAmountLocked.value) {
|
||||
const amount = parseMoneyNumber(String(editForm.amount ?? ''), 0) ?? 0;
|
||||
if (amount <= 0) {
|
||||
message.warning('مبلغ معتبر نیست.');
|
||||
editSaving.value = false;
|
||||
return;
|
||||
}
|
||||
body.amount = amount;
|
||||
}
|
||||
await api.patch(`/treasury/documents/${editId.value}`, body);
|
||||
message.success('سند بهروز شد.');
|
||||
editOpen.value = false;
|
||||
emit('reload');
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e));
|
||||
} finally {
|
||||
editSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmVoid(row: FinancialDocumentDto) {
|
||||
Modal.confirm({
|
||||
title: 'ابطال سند',
|
||||
content: 'این سند و گردش متصل به آن باطل شود؟',
|
||||
okText: 'ابطال',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
try {
|
||||
await api.post(`/treasury/documents/${row.id}/void`, { reason: 'ابطال توسط کاربر' });
|
||||
message.success('سند باطل شد.');
|
||||
emit('reload');
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelete(row: FinancialDocumentDto) {
|
||||
Modal.confirm({
|
||||
title: 'حذف سند',
|
||||
content:
|
||||
row.status === 'void'
|
||||
? 'سند باطلشده برای همیشه حذف شود؟'
|
||||
: 'سند بدون گردش برای همیشه حذف شود؟ در صورت داشتن دریافت/پرداخت ابتدا ابطال کنید.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
try {
|
||||
await api.delete(`/treasury/documents/${row.id}`);
|
||||
message.success('سند حذف شد.');
|
||||
emit('reload');
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAttachment(id: string, fileName: string) {
|
||||
try {
|
||||
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);
|
||||
} catch (e) {
|
||||
message.error(apiMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
const cols = [
|
||||
{ title: 'پیگیری', dataIndex: 'reference', key: 'reference', width: 150 },
|
||||
{ title: 'عنوان', dataIndex: 'title', key: 'title' },
|
||||
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName', width: 140 },
|
||||
{ title: 'دسته', dataIndex: 'categoryName', key: 'categoryName', width: 120 },
|
||||
{ title: 'مبلغ', key: 'amount', width: 130 },
|
||||
{ title: 'مانده', key: 'remaining', width: 130 },
|
||||
{ title: 'وضعیت', key: 'status', width: 110 },
|
||||
{ title: 'تاریخ', key: 'bookedAt', width: 110 },
|
||||
{ title: 'عملیات', key: 'actions', width: canManage.value ? 160 : 80 },
|
||||
];
|
||||
return cols;
|
||||
});
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.q,
|
||||
set: (value: string) => emit('update:q', value),
|
||||
});
|
||||
|
||||
function onSearch() {
|
||||
emit('reload');
|
||||
}
|
||||
|
||||
function asDoc(record: unknown): FinancialDocumentDto {
|
||||
return record as FinancialDocumentDto;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card :bordered="false" title="فهرست اسناد">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="searchModel"
|
||||
allow-clear
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="onSearch"
|
||||
@change="onSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
|
||||
<a-table
|
||||
v-else-if="!isMobile"
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
:scroll="{ x: 1100 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'partyName'">
|
||||
{{ record.partyName || 'بدون طرف حساب' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'categoryName'">
|
||||
{{ record.categoryName || '—' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
{{ toman(record.amount) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remaining'">
|
||||
{{ toman(record.remaining) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusColor[record.status] || 'default'">
|
||||
{{ statusLabel[record.status] ?? record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'bookedAt'">
|
||||
{{ formatJalaliNumeric(new Date(record.bookedAt)) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space :size="4">
|
||||
<a-tooltip title="جزئیات">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="جزئیات"
|
||||
:style="{ color: token.colorInfo }"
|
||||
@click="openDetail(asDoc(record))"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="canManage && record.status !== 'void'" title="ویرایش">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="ویرایش"
|
||||
:style="{ color: token.colorPrimary }"
|
||||
@click="openEdit(asDoc(record))"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.status !== 'void'" title="ابطال">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="ابطال"
|
||||
:style="{ color: token.colorWarning }"
|
||||
@click="confirmVoid(asDoc(record))"
|
||||
>
|
||||
<template #icon><StopOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="canManage" title="حذف">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="حذف"
|
||||
:style="{ color: token.colorError }"
|
||||
@click="confirmDelete(asDoc(record))"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-card v-for="item in rows" :key="item.id" :bordered="true">
|
||||
<a-flex vertical :gap="8">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
|
||||
<a-typography-text strong>{{ item.title }}</a-typography-text>
|
||||
<a-tag :color="statusColor[item.status] || 'default'">
|
||||
{{ statusLabel[item.status] ?? item.status }}
|
||||
</a-tag>
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">
|
||||
{{ item.reference ? `پیگیری ${item.reference}` : 'بدون پیگیری' }}
|
||||
· {{ item.partyName || 'بدون طرف حساب' }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="item.categoryName" type="secondary">
|
||||
{{ item.categoryName }}
|
||||
</a-typography-text>
|
||||
<a-flex justify="space-between" wrap="wrap" :gap="8">
|
||||
<a-typography-text>مبلغ {{ toman(item.amount) }}</a-typography-text>
|
||||
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">
|
||||
{{ formatJalaliNumeric(new Date(item.bookedAt)) }}
|
||||
</a-typography-text>
|
||||
<a-space wrap :size="4">
|
||||
<a-tooltip title="جزئیات">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="جزئیات"
|
||||
:style="{ color: token.colorInfo }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="canManage && item.status !== 'void'" title="ویرایش">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="ویرایش"
|
||||
:style="{ color: token.colorPrimary }"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<template #icon><EditOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="item.status !== 'void'" title="ابطال">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="ابطال"
|
||||
:style="{ color: token.colorWarning }"
|
||||
@click="confirmVoid(item)"
|
||||
>
|
||||
<template #icon><StopOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="canManage" title="حذف">
|
||||
<a-button
|
||||
type="text"
|
||||
aria-label="حذف"
|
||||
:style="{ color: token.colorError }"
|
||||
@click="confirmDelete(item)"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="detailOpen"
|
||||
title="جزئیات سند"
|
||||
:footer="null"
|
||||
:width="isMobile ? '100%' : 720"
|
||||
destroy-on-close
|
||||
>
|
||||
<a-flex v-if="detailLoading && !detail" justify="center">
|
||||
<a-typography-text type="secondary">در حال بارگذاری…</a-typography-text>
|
||||
</a-flex>
|
||||
<template v-else-if="detail">
|
||||
<a-descriptions :column="1" bordered>
|
||||
<a-descriptions-item label="عنوان">{{ detail.title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="پیگیری">{{ detail.reference || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="طرف حساب">{{ detail.partyName || 'بدون طرف حساب' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="دسته">{{ detail.categoryName || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="حساب">{{ detail.accountName || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="روش">
|
||||
{{ detail.method ? methodLabel[detail.method] || detail.method : '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="مبلغ">{{ toman(detail.amount) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="تسویه">{{ toman(detail.settledAmount) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="مانده">{{ toman(detail.remaining) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت">
|
||||
{{ statusLabel[detail.status] ?? detail.status }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="تاریخ">
|
||||
{{ formatJalaliNumeric(new Date(detail.bookedAt)) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="یادداشت">{{ detail.notes || '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-flex vertical :gap="12" style="margin-top: 16px">
|
||||
<a-typography-text strong>پیوستها</a-typography-text>
|
||||
<a-empty
|
||||
v-if="!(detail.attachments && detail.attachments.length)"
|
||||
description="پیوستی نیست."
|
||||
/>
|
||||
<a-image-preview-group v-else>
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col
|
||||
v-for="att in detail.attachments"
|
||||
:key="att.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
>
|
||||
<a-card :bordered="true">
|
||||
<a-flex vertical :gap="8" align="center">
|
||||
<a-image
|
||||
v-if="previewUrls[att.id]"
|
||||
:src="previewUrls[att.id]"
|
||||
:alt="att.fileName"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-typography-text v-else type="secondary">{{ att.fileName }}</a-typography-text>
|
||||
<a-button type="link" @click="downloadAttachment(att.id, att.fileName)">
|
||||
دانلود
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-image-preview-group>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
title="ویرایش سند"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="editSaving"
|
||||
:width="isMobile ? '100%' : 560"
|
||||
destroy-on-close
|
||||
@ok="saveEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input v-model:value="editForm.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="تاریخ">
|
||||
<JalaliDateInput v-model="editForm.bookedAt" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="روش">
|
||||
<a-select
|
||||
v-model:value="editForm.method"
|
||||
allow-clear
|
||||
placeholder="روش"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="دستهبندی">
|
||||
<a-select
|
||||
v-model:value="editForm.categoryId"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="دسته"
|
||||
style="width: 100%"
|
||||
:options="categories.map((c) => ({ value: c.id, label: c.name }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="!editAmountLocked" :xs="24" :md="12">
|
||||
<a-form-item label="مبلغ">
|
||||
<MoneyInput v-model="editForm.amount" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-input v-model:value="editForm.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
|
||||
treasury_reports: ['treasury'],
|
||||
treasury_audit: ['treasury'],
|
||||
treasury_categories: ['treasury'],
|
||||
treasury_documents_manage: ['treasury'],
|
||||
behavior: ['behavior'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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<FinancialDocumentDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const photoBusy = ref(false);
|
||||
const pendingPhotos = ref<PendingPhoto[]>([]);
|
||||
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<FinancialDocumentDto[]>('/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<FinancialDocumentDto>('/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(() => {
|
||||
</UiField>
|
||||
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
|
||||
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||
<div class="photos">
|
||||
<div class="photos-head">
|
||||
<strong>عکس فاکتور / رسید</strong>
|
||||
<small>اختیاری · فشردهسازی خودکار · حداکثر ۲۰۰ کیلوبایت برای هر عکس · تا {{ PHOTO_LIMIT }} عکس</small>
|
||||
</div>
|
||||
<div v-if="pendingPhotos.length" class="thumbs">
|
||||
<figure v-for="photo in pendingPhotos" :key="photo.localId">
|
||||
<img :src="photo.dataUrl" :alt="photo.name" />
|
||||
<button type="button" :disabled="saving || photoBusy" @click="removePendingPhoto(photo.localId)">حذف</button>
|
||||
</figure>
|
||||
</div>
|
||||
<label class="add-photo" :class="{ busy: photoBusy || saving || pendingPhotos.length >= PHOTO_LIMIT }">
|
||||
{{ photoBusy ? 'در حال آمادهسازی…' : 'افزودن عکس' }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:disabled="saving || photoBusy || pendingPhotos.length >= PHOTO_LIMIT"
|
||||
@change="onPhotosPicked"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<UiButton :loading="saving || photoBusy" @click="createDoc">ثبت</UiButton>
|
||||
<UiButton variant="secondary" :disabled="saving || photoBusy" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||
</UiCard>
|
||||
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
|
||||
<div class="list">
|
||||
@@ -210,9 +321,9 @@ onMounted(() => {
|
||||
>
|
||||
{{ file.fileName }}
|
||||
</button>
|
||||
<label v-if="row.status !== 'void'" class="file">
|
||||
پیوست
|
||||
<input type="file" @change="upload(row.id, $event)" />
|
||||
<label v-if="row.status !== 'void'" class="file" :class="{ busy: photoBusy }">
|
||||
{{ photoBusy ? '…' : 'عکس پیوست' }}
|
||||
<input type="file" accept="image/*" :disabled="photoBusy || saving" @change="upload(row.id, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -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<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const pendingPhotos = ref<TreasuryPendingPhoto[]>([]);
|
||||
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<FinancialDocumentDto[]>('/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(() => {
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<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="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<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
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<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"
|
||||
:allow-other="!isSettlement"
|
||||
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
|
||||
/>
|
||||
</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-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
<a-card :bordered="false" title="سند جدید">
|
||||
<template #extra>
|
||||
<a-segmented v-model:value="entryMode" :options="entryOptions" />
|
||||
</template>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<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"
|
||||
:allow-other="!isSettlement"
|
||||
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
|
||||
/>
|
||||
</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-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item
|
||||
: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-col>
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="مانده این سند">
|
||||
<MoneyInput
|
||||
addon-after="تومان"
|
||||
:model-value="remainingAmount"
|
||||
:decimals="0"
|
||||
disabled
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="!isSettlement" :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
<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-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="حساب" name="accountId" :rules="accountRules">
|
||||
<a-select
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
: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-col>
|
||||
<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-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId" :rules="accountRules">
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select 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-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-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-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<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-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="تاریخ" name="bookedAt">
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="عکس فاکتور / رسید">
|
||||
<TreasuryDocPhotoField v-model="pendingPhotos" :disabled="saving" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-divider orientation="right">مانده پرداختنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداختشده" />
|
||||
<a-card :bordered="false" title="مانده پرداختنی">
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداختشده" />
|
||||
</a-card>
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template v-if="!isMobile" #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-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 #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<TreasuryDocumentsPanel
|
||||
v-model:q="q"
|
||||
:rows="rows"
|
||||
:categories="categories"
|
||||
:status-label="statusLabel"
|
||||
@reload="load"
|
||||
/>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -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<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const pendingPhotos = ref<TreasuryPendingPhoto[]>([]);
|
||||
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<FinancialDocumentDto[]>('/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(() => {
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<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="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<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
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<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"
|
||||
:allow-other="!isSettlement"
|
||||
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
|
||||
/>
|
||||
</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-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="!isSettlement" :xs="24" :md="8">
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item
|
||||
:label="settleLabel"
|
||||
name="settleAmount"
|
||||
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
|
||||
>
|
||||
<MoneyInput
|
||||
addon-after="تومان"
|
||||
v-model="form.settleAmount"
|
||||
:decimals="0"
|
||||
:disabled="!isSettlement && form.partyKind === 'other'"
|
||||
<a-card :bordered="false" title="سند جدید">
|
||||
<template #extra>
|
||||
<a-segmented v-model:value="entryMode" :options="entryOptions" />
|
||||
</template>
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<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"
|
||||
:allow-other="!isSettlement"
|
||||
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<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-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId" :rules="accountRules">
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select 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-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" :xs="24" :md="16">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
</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-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item
|
||||
: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-col>
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="مانده این سند">
|
||||
<MoneyInput
|
||||
addon-after="تومان"
|
||||
:model-value="remainingAmount"
|
||||
:decimals="0"
|
||||
disabled
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template v-if="!isMobile" #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-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 #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<a-col v-if="!isSettlement" :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="حساب" name="accountId" :rules="accountRules">
|
||||
<a-select
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
:label="account.name"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<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-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="تاریخ" name="bookedAt">
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8">
|
||||
<a-form-item label="عکس فاکتور / رسید">
|
||||
<TreasuryDocPhotoField v-model="pendingPhotos" :disabled="saving" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" title="مانده دریافتنی">
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
</a-card>
|
||||
|
||||
<TreasuryDocumentsPanel
|
||||
v-model:q="q"
|
||||
:rows="rows"
|
||||
:categories="categories"
|
||||
:status-label="statusLabel"
|
||||
@reload="load"
|
||||
/>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user