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:
2026-11-15 12:42:01 +03:30
co-authored by Cursor
parent 3696713443
commit 4409ded0ab
10 changed files with 1383 additions and 451 deletions
@@ -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>
+22
View File
@@ -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;
}
+1
View File
@@ -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'],
};
+179 -24
View File
@@ -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>