Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryIncomesView.vue
T

791 lines
28 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
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,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography,
message,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
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 { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
return {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyKind: undefined as FinancialPartyKind | undefined,
partyId: undefined as string | undefined,
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
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: 'سایر' },
];
const formTabOptions = [
{
value: 'main',
payload: 'main',
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
},
{
value: 'extra',
payload: 'extra',
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
},
];
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
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 docLine(item: FinancialDocumentDto) {
const parts = [
item.partyName || 'بدون طرف حساب',
statusLabel[item.status] ?? item.status,
`مانده ${toman(item.remaining)}`,
];
if (item.categoryName) parts.push(item.categoryName);
return parts.join(' · ');
}
async function load() {
const [docs, cats, acc, dash, bals] = 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'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
}),
]);
rows.value = docs.data;
balances.value = bals.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
settleMirror.reset();
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
ok.value = '';
}
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;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if (form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await 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);
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<div
:style="
isMobile
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
: undefined
"
>
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
دریافت‌های این ماه
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.actualReceipts) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ArrowDownOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
در انتظار دریافت
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.receivables) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ClockCircleOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-segmented
v-model:value="formTab"
block
:options="formTabOptions"
:style="{ background: token.colorFillTertiary }"
/>
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item
label="عنوان درآمد"
name="title"
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
>
<a-input
v-model:value="form.title"
placeholder="مثلاً: دریافت قسط پروژه الف"
>
<template #prefix><FileTextOutlined /></template>
</a-input>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="طرف حساب" name="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<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-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
>
<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-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item
label="مبلغ کل (تومان)"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="`${settleLabel} (تومان)`"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex
align="center"
justify="space-between"
:style="{
background: token.colorSuccessBg,
borderRadius: `${token.borderRadiusLG * 2}px`,
padding: `${token.paddingSM}px ${token.padding}px`,
marginBottom: `${token.margin}px`,
}"
>
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: token.colorSuccess,
display: 'inline-block',
}"
/>
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
</a-space>
<a-typography-text strong :style="{ color: token.colorSuccess }">
{{ toman(remainingAmount) }}
</a-typography-text>
</a-flex>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="تاریخ دریافت" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="accountId">
<template #label>
<a-space :size="6">
<BankOutlined />
<span>حساب مقصد</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
style="width: 100%"
>
<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-row>
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
:rows="4"
placeholder="توضیحات اختیاری…"
/>
</a-form-item>
</div>
</a-form>
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</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>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
<div
:style="{
position: 'fixed',
left: 0,
right: 0,
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
zIndex: 90,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: `${token.paddingSM}px ${token.padding}px`,
boxShadow: token.boxShadowSecondary,
}"
>
<a-row :gutter="12">
<a-col :span="8">
<a-button block @click="resetForm">انصراف</a-button>
</a-col>
<a-col :span="16">
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
<template #icon><CheckOutlined /></template>
ثبت دریافت
</a-button>
</a-col>
</a-row>
</div>
</template>
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<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" :disabled="form.partyKind === 'other'" />
</a-form-item>
</a-col>
<a-col :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="partyKind">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<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 :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :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" :loading="saving">ثبت</a-button>
</a-form>
<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-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>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>