feat(web): migrate finance treasury pages to Ant Design Vue

Replace legacy Ui* chrome on treasury routes with Ant layout, and polish mobile incomes stats colors plus nav visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 21:06:18 +03:30
co-authored by Cursor
parent b25b5b125a
commit 8fdd5147a5
10 changed files with 1985 additions and 472 deletions
+4 -1
View File
@@ -65,7 +65,10 @@ const items: Array<{
const route = useRoute();
const router = useRouter();
const hidden = computed(() => route.name === 'person-detail');
/** صفحات با نوار اکشن ثابت موبایل — FAB نباید وسط صفحه رها شود */
const hidden = computed(
() => route.name === 'person-detail' || route.name === 'finance-incomes',
);
const isQuickCreateOpen = ref(false);
const ready = ref(false);
const fabPos = ref<FabPos>({
@@ -1,12 +1,36 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
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,
Space as ASpace,
Statistic as AStatistic,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
@@ -115,110 +139,176 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="صندوق و حساب‌ها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div class="stats">
<article>
<span>مجموع موجودی حساب‌ها</span>
<strong>{{ toman(total) }}</strong>
</article>
<article>
<span>ورودی این فهرست</span>
<strong class="in">{{ toman(periodIn) }}</strong>
</article>
<article>
<span>خروجی این فهرست</span>
<strong class="out">{{ toman(periodOut) }}</strong>
</article>
<article>
<span>حساب‌های فعال</span>
<strong>{{ active.length }} حساب</strong>
</article>
</div>
<div class="list">
<article v-for="account in accounts" :key="account.id" class="account">
<div>
<strong>{{ account.name }}</strong>
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
<p>مشاهده گردش حساب و جزئیات</p>
</div>
<div class="side">
<b>{{ toman(account.balance) }}</b>
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
</div>
</article>
</div>
<div class="actions">
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
</div>
<UiCard v-if="openForm" title="حساب جدید">
<div class="grid">
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
<UiField label="نوع">
<select v-model="form.type" class="select">
<option value="cash">صندوق نقدی</option>
<option value="bank">حساب بانکی</option>
<option value="petty">تنخواه</option>
</select>
</UiField>
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField>
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حساب‌ها</a-typography-title>
<a-typography-text type="secondary">
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.
</a-typography-text>
</div>
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
</UiCard>
<UiCard v-if="transferOpen" title="انتقال بین حساب‌ها">
<div class="grid">
<UiField label="از حساب">
<select v-model="transfer.fromAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="به حساب">
<select v-model="transfer.toAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField>
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
</div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
</UiCard>
</section>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مجموع موجودی حساب‌ها" :value="toman(total)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="ورودی این فهرست"
:value="toman(periodIn)"
:value-style="{ color: 'var(--ant-color-success)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="خروجی این فهرست"
:value="toman(periodOut)"
:value-style="{ color: 'var(--ant-color-error)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="حساب‌های فعال" :value="`${active.length} حساب`" />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-space>
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-form layout="vertical" @finish="createAccount">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام حساب" name="name">
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
<a-select-option value="petty">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
<a-form-item label="نام بانک" name="bankName">
<a-input v-model:value="form.bankName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شماره یا شناسه" name="accountRef">
<a-input v-model:value="form.accountRef" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName">
<a-input v-model:value="form.responsibleName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance">
<a-input v-model:value="form.openingBalance" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع" name="openingDate">
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<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>
</template>
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<a-form layout="vertical" @finish="sendTransfer">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="از حساب" name="fromAccountId">
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="به حساب" name="toAccountId">
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="`to-${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="amount">
<a-input v-model:value="transfer.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شرح" name="memo">
<a-input v-model:value="transfer.memo" />
</a-form-item>
</a-col>
</a-row>
<a-typography-text type="secondary">
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.
</a-typography-text>
<div>
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
</div>
</a-form>
</template>
<a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
<template #title>
{{ item.name }}
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
.stats article, .account {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
}
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
.in { color: #15803d; }
.out { color: #b91c1c; }
.list { display: grid; gap: 0.65rem; }
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.side { display: grid; justify-items: end; gap: 0.4rem; }
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,11 +1,32 @@
<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,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]);
const name = ref('');
const kind = ref<'income' | 'expense'>('expense');
@@ -35,36 +56,61 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="دسته‌بندی‌های مالی" description="دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div class="add">
<UiField label="نام"><UiInput v-model="name" /></UiField>
<UiField label="نوع">
<select v-model="kind" class="select">
<option value="income">درآمد</option>
<option value="expense">هزینه</option>
</select>
</UiField>
<UiButton @click="add">افزودن</UiButton>
</div>
<ul>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.name }}</strong>
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
</li>
</ul>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">دسته‌بندی‌های مالی</a-typography-title>
<a-typography-text type="secondary">
دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.
</a-typography-text>
</div>
<style scoped>
.page { display: grid; gap: 1rem; }
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
li span { color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-form layout="vertical" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام" name="name">
<a-input v-model:value="name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">فهرست</a-divider>
<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-tag :color="item.kind === 'income' ? 'success' : 'default'">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
</a-tag>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,6 +1,261 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
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,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, 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 title = 'پرداخت‌ها و هزینه‌ها';
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);
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
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>
<TreasuryDocsView kind="expense" />
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<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" @finish="createDoc">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</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 value="">بدون دسته</a-select-option>
<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 value="">انتخاب حساب</a-select-option>
<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>
</div>
</template>
+129 -79
View File
@@ -1,11 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Row as ARow,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import { UiCard, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
@@ -31,82 +46,117 @@ onMounted(async () => {
</script>
<template>
<section class="page">
<UiPageHeader
title="مدیریت مالی"
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="data" class="stats">
<article v-for="card in cards" :key="card.key">
<span>{{ card.label }}</span>
<strong>{{ toman(data[card.key]) }}</strong>
<small>{{ card.hint }}</small>
</article>
</div>
<div v-if="data" class="split">
<UiCard title="اقساط نزدیک یا معوق">
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingInstallments" :key="row.id">
<div>
<strong>قسط {{ row.position }}</strong>
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
<UiCard title="پرداخت‌های آینده">
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingPayables" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</div>
<UiCard v-if="data" title="آخرین اسناد">
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in data.recent" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
<a-typography-text type="secondary">
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
</a-typography-text>
</div>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.stats article {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.stats span, .stats small, .muted { color: #64748b; }
.stats strong { font-size: 1.05rem; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
.list small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="data">
<a-row :gutter="[16, 16]">
<a-col
v-for="card in cards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:xl="6"
>
<a-card :bordered="false">
<a-statistic :title="card.label" :value="toman(data[card.key])" />
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
<a-empty
v-if="!data.upcomingInstallments.length"
description="قسط بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingInstallments"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>قسط {{ item.position }}</template>
<template #description>
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
<a-col :xs="24" :lg="12">
<a-divider orientation="right">پرداخت‌های آینده</a-divider>
<a-empty
v-if="!data.upcomingPayables.length"
description="تعهد پرداخت بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingPayables"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>مانده {{ toman(item.remaining) }}</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
</a-row>
<a-divider orientation="right">آخرین اسناد</a-divider>
<a-empty
v-if="!data.recent.length"
description="هنوز سندی ثبت نشده است."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.recent"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</template>
</a-flex>
</div>
</template>
@@ -1,6 +1,765 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
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,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
ArrowLeftOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
TreasuryDashboardDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, 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 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,
partyName: '',
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const pageTitle = 'دریافت‌ها و درآمدها';
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 = Number(form.value.amount) || 0;
const settled = Number(form.value.settleAmount) || 0;
return Math.max(0, total - settled);
});
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, dash] = 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),
]);
rows.value = docs.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() {
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
ok.value = '';
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
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>
<TreasuryDocsView kind="income" />
<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">
<div>
<a-flex align="center" :gap="token.marginXS">
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
ثبت دریافت جدید
</a-typography-title>
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
</a-flex>
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
</div>
<TreasuryNav />
<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="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
</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 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-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select
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">
{{ 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: 'مبلغ کل را وارد کنید' }]"
>
<a-input
v-model:value="form.amount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="`${settleLabel} (تومان)`"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<a-input
v-model:value="form.settleAmount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</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
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"
>
{{ 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>
<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>
{{ 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>
<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">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<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" @finish="createDoc">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</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="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>
<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>
<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>
@@ -1,12 +1,32 @@
<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,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type PayrollRow = {
id: string;
fullName: string;
@@ -68,6 +88,10 @@ async function payRow() {
}
}
function startPay(row: PayrollRow) {
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -76,59 +100,109 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را می‌بینند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="ثبت دوره">
<div class="grid">
<UiField label="پرسنل">
<select v-model="form.userId" class="select">
<option value="">انتخاب</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
</div>
<UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.fullName }}</strong>
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را می‌بینند.</a-typography-text>
</div>
<div class="side">
<b>{{ toman(row.payable) }}</b>
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
</div>
</article>
<UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
<UiField label="حساب">
<select v-model="pay.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton @click="payRow">ثبت پرداخت</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.side { display: grid; justify-items: end; }
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<TreasuryNav />
<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" @finish="createRow">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="پرسنل" name="userId">
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="period">
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پایه" name="baseAmount">
<a-input v-model:value="form.baseAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مزایا" name="benefits">
<a-input v-model:value="form.benefits" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="bonus">
<a-input v-model:value="form.bonus" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کسورات" name="deductions">
<a-input v-model:value="form.deductions" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
</a-form>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<a-form layout="vertical" @finish="payRow">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="pay.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="pay.accountId" style="width: 100%">
<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-row>
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
</a-form>
</template>
<a-divider orientation="right">فهرست</a-divider>
<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.payable) }}</a-typography-text>
<a-button
v-if="item.status !== 'settled' && item.status !== 'void'"
type="link"
@click="startPay(item)"
>
پرداخت
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.fullName }}</template>
<template #description>
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,13 +1,36 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
const rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -35,6 +58,31 @@ const statusLabel: Record<string, string> = {
void: 'باطل',
};
const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: 'قسط', dataIndex: 'position', key: 'position' },
{
title: 'سررسید',
key: 'dueDate',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
},
{
title: 'مبلغ',
key: 'amount',
customRender: ({ record }) => toman(record.amount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }) => toman(record.remaining),
},
{
title: 'وضعیت',
key: 'status',
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
},
{ title: '', key: 'actions' },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -101,6 +149,15 @@ async function collectInstallment() {
}
}
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
collect.value = {
installmentId: item.id,
receivableId: row.id,
amount: item.remaining,
accountId: accounts.value[0]?.id || '',
};
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری مطالبات ناموفق بود.';
@@ -109,112 +166,163 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader
title="اقساط و مطالبات"
description="دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="قرارداد جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
<UiField label="پیش‌پرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
<UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
<a-typography-text type="secondary">
دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.
</a-typography-text>
</div>
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom">
<div v-for="(line, index) in customLines" :key="index" class="grid">
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
</div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
</div>
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="contract">
<header>
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.totalAmount) }}</b>
</header>
<table>
<thead>
<tr>
<th>قسط</th>
<th>سررسید</th>
<th>مبلغ</th>
<th>مانده</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.installments" :key="item.id">
<td>{{ item.position }}</td>
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
<td>{{ toman(item.amount) }}</td>
<td>{{ toman(item.remaining) }}</td>
<td>{{ statusLabel[item.status] }}</td>
<td>
<button
v-if="item.status !== 'settled'"
type="button"
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
>
دریافت
</button>
</td>
</tr>
</tbody>
</table>
</article>
<TreasuryNav />
<UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid">
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
<UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
</UiCard>
</section>
<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" @finish="createContract">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<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="totalAmount">
<a-input v-model:value="form.totalAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<a-input v-model:value="form.prepaidAmount" />
</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 value="">انتخاب</a-select-option>
<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="count">
<a-input v-model:value="form.count" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24">
<a-form-item>
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
</a-form-item>
</a-col>
</a-row>
<template v-if="custom">
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`">
<a-input v-model:value="line.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سررسید">
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
</a-form-item>
</template>
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
</a-form>
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form layout="vertical" @finish="collectInstallment">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="collect.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب مقصد" name="accountId">
<a-select v-model:value="collect.accountId" style="width: 100%">
<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-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
</a-form>
</template>
<a-divider orientation="right">قراردادها</a-divider>
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
<template v-else>
<div v-for="row in rows" :key="row.id">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
<div>
<a-typography-text strong>{{ row.title }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
</a-typography-text>
</div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex>
<a-table
:columns="installmentColumns"
:data-source="row.installments"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="record.status !== 'settled'"
type="link"
@click="startCollect(row, record as InstallmentRow)"
>
دریافت
</a-button>
</template>
</template>
</a-table>
<a-divider />
</div>
</template>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
.contract small { display: block; color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,12 +1,32 @@
<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,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
const rows = ref<Row[]>([]);
const error = ref('');
@@ -37,48 +57,80 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="الگوی جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="نوع">
<select v-model="form.kind" class="select">
<option value="expense">هزینه</option>
<option value="income">درآمد</option>
</select>
</UiField>
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
<option value="yearly">سالانه</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
</div>
<UiButton @click="createItem">ثبت الگو</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
<a-typography-text type="secondary">
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.
</a-typography-text>
</div>
<b>{{ toman(row.amount) }}</b>
</article>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<TreasuryNav />
<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" @finish="createItem">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select-option value="yearly">سالانه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
</a-form>
<a-divider orientation="right">فهرست الگوها</a-divider>
<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>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
· {{ item.interval }}
· بعدی {{ formatJalaliNumeric(new Date(item.nextDue)) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,10 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { UiButton, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type Period = {
bookedIncome: string;
bookedExpense: string;
@@ -14,6 +30,14 @@ type Period = {
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
type MetricRow = {
key: string;
label: string;
current: string;
previous: string;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
@@ -29,6 +53,22 @@ const lines = [
['خالص جریان نقدی', 'cashNet'],
] as const;
const metricColumns: TableColumnsType<MetricRow> = [
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
];
const metricRows = computed<MetricRow[]>(() => {
if (!current.value || !previous.value) return [];
return lines.map(([label, key]) => ({
key,
label,
current: toman(current.value![key]),
previous: toman(previous.value![key]),
}));
});
async function load() {
const { data } = await api.get<{
current: Period;
@@ -60,49 +100,85 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
<table v-if="current && previous">
<thead>
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
</thead>
<tbody>
<tr v-for="line in lines" :key="line[1]">
<td>{{ line[0] }}</td>
<td>{{ toman(current[line[1]]) }}</td>
<td>{{ toman(previous[line[1]]) }}</td>
</tr>
</tbody>
</table>
<h2>مطالبات به تفکیک طرف حساب</h2>
<ul>
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
</ul>
<h2>تعهدات به تفکیک ماه</h2>
<ul>
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
</ul>
<h2 v-if="current">سهم هزینه‌ها</h2>
<ul v-if="current">
<li v-for="row in current.expenseCategories" :key="row.name">
<span>{{ row.name }}</span>
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
</li>
</ul>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
<a-typography-text type="secondary">
سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.
</a-typography-text>
</div>
<a-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<style scoped>
.page { display: grid; gap: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
h2 { margin: 0; font-size: 1rem; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">مقایسه دوره‌ها</a-divider>
<a-table
v-if="metricRows.length"
:columns="metricColumns"
:data-source="metricRows"
:pagination="false"
row-key="key"
/>
<a-empty v-else description="داده‌ای برای نمایش نیست." />
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.month }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>
{{ toman(item.amount) }} · {{ item.share }}٪
</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</a-card>
</a-flex>
</div>
</template>