Keep title and nav mounted in TreasuryLayout with stable header height, and finish Ant form chrome (MoneyInput, Jalali) on finance pages. Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
9.3 KiB
Vue
283 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue';
|
|
import axios from 'axios';
|
|
import {
|
|
Alert as AAlert,
|
|
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,
|
|
Select as ASelect,
|
|
Typography,
|
|
message,
|
|
} from 'ant-design-vue';
|
|
import { api } from '@/lib/api';
|
|
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
|
import MoneyInput from '@/components/MoneyInput.vue';
|
|
import { 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 kind = 'expense' as const;
|
|
const rows = ref<FinancialDocumentDto[]>([]);
|
|
const categories = ref<FinancialCategoryDto[]>([]);
|
|
const accounts = ref<FinancialAccountDto[]>([]);
|
|
const error = ref('');
|
|
const ok = ref('');
|
|
const saving = ref(false);
|
|
const q = ref('');
|
|
const form = ref({
|
|
title: '',
|
|
amount: '',
|
|
categoryId: '',
|
|
partyName: '',
|
|
accountId: '',
|
|
settleAmount: '',
|
|
method: 'transfer',
|
|
reference: '',
|
|
notes: '',
|
|
});
|
|
|
|
const settleLabel = 'مبلغ پرداختشده';
|
|
const statusLabel: Record<string, string> = {
|
|
open: 'پرداختنشده',
|
|
partial: 'جزئی',
|
|
settled: 'پرداختشده',
|
|
void: 'باطلشده',
|
|
};
|
|
|
|
const methodOptions = [
|
|
{ value: 'cash', label: 'نقدی' },
|
|
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
|
{ value: 'pos', label: 'کارتخوان' },
|
|
{ value: 'transfer', label: 'حواله' },
|
|
{ value: 'other', label: 'سایر' },
|
|
];
|
|
|
|
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 || 'ذخیره ناموفق بود.';
|
|
}
|
|
|
|
async function load() {
|
|
const [docs, cats, acc] = await Promise.all([
|
|
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
|
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
|
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
|
]);
|
|
rows.value = docs.data;
|
|
categories.value = cats.data.filter((row) => row.isActive);
|
|
accounts.value = acc.data.filter((row) => row.isActive);
|
|
}
|
|
|
|
function onFinishFailed() {
|
|
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
|
}
|
|
|
|
async function createDoc() {
|
|
saving.value = true;
|
|
error.value = '';
|
|
ok.value = '';
|
|
try {
|
|
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
|
|
if (amount == null || amount <= 0) {
|
|
error.value = 'مبلغ کل معتبر نیست.';
|
|
return;
|
|
}
|
|
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
|
await api.post('/treasury/documents', {
|
|
kind,
|
|
title: form.value.title,
|
|
amount,
|
|
categoryId: form.value.categoryId || null,
|
|
partyName: form.value.partyName || null,
|
|
accountId: form.value.accountId || null,
|
|
settleAmount,
|
|
method: form.value.method,
|
|
reference: form.value.reference || null,
|
|
notes: form.value.notes || null,
|
|
});
|
|
form.value = {
|
|
title: '',
|
|
amount: '',
|
|
categoryId: '',
|
|
partyName: '',
|
|
accountId: '',
|
|
settleAmount: '',
|
|
method: 'transfer',
|
|
reference: '',
|
|
notes: '',
|
|
};
|
|
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
|
await load();
|
|
} catch (e) {
|
|
error.value = apiMessage(e);
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
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 = 'بارگذاری ناموفق بود.';
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<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-card :bordered="false">
|
|
<a-divider orientation="right">سند جدید</a-divider>
|
|
<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="title"
|
|
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
|
>
|
|
<a-input v-model:value="form.title" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item
|
|
label="مبلغ کل"
|
|
name="amount"
|
|
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
|
>
|
|
<MoneyInput v-model="form.amount" :decimals="0" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item :label="settleLabel" name="settleAmount">
|
|
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item label="دستهبندی" name="categoryId">
|
|
<a-select
|
|
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">
|
|
{{ cat.name }}
|
|
</a-select-option>
|
|
</a-select>
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item label="طرف حساب" name="partyName">
|
|
<a-input v-model:value="form.partyName" />
|
|
</a-form-item>
|
|
</a-col>
|
|
<a-col :xs="24" :md="8">
|
|
<a-form-item label="حساب" name="accountId">
|
|
<a-select
|
|
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">
|
|
{{ 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 v-model:value="form.method" 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="8">
|
|
<a-form-item label="پیگیری" name="reference">
|
|
<a-input v-model:value="form.reference" />
|
|
</a-form-item>
|
|
</a-col>
|
|
</a-row>
|
|
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
|
</a-form>
|
|
|
|
<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="horizontal" :data-source="rows">
|
|
<template #renderItem="{ item }">
|
|
<a-list-item>
|
|
<template #actions>
|
|
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
|
<a-button
|
|
v-if="item.status !== 'void'"
|
|
type="link"
|
|
danger
|
|
@click="voidDoc(item.id)"
|
|
>
|
|
ابطال
|
|
</a-button>
|
|
</template>
|
|
<a-list-item-meta>
|
|
<template #title>{{ item.title }}</template>
|
|
<template #description>
|
|
{{ statusLabel[item.status] }}
|
|
· مانده {{ toman(item.remaining) }}
|
|
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
|
</template>
|
|
</a-list-item-meta>
|
|
</a-list-item>
|
|
</template>
|
|
</a-list>
|
|
</a-card>
|
|
</a-flex>
|
|
</template>
|