feat: add internal finance ledger for accounts, claims, and payroll

Keep booked income and expense separate from cash balances, with installments, recurring items, and reports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 18:49:49 +03:30
co-authored by Cursor
parent 61d17c5cea
commit ab538b8304
26 changed files with 3836 additions and 0 deletions
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
const links = [
{ to: '/finance', label: 'داشبورد', exact: true },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها' },
{ to: '/finance/payroll', label: 'حقوق' },
{ to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' },
];
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
return route.path === path || route.path.startsWith(`${path}/`);
}
</script>
<template>
<nav class="treasury-nav" aria-label="مدیریت مالی">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
:class="{ on: on(link.to, link.exact) }"
>
{{ link.label }}
</RouterLink>
</nav>
</template>
<style scoped>
.treasury-nav {
display: flex;
gap: 0.35rem;
overflow: auto;
padding-bottom: 0.25rem;
}
.treasury-nav a {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.4rem 0.8rem;
color: #475569;
text-decoration: none;
font-weight: 700;
font-size: 0.86rem;
background: #f8fafc;
}
.treasury-nav a.on {
background: #0f766e;
color: #fff;
}
</style>
+8
View File
@@ -354,6 +354,14 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'sky',
module: 'cheques',
},
{
to: '/finance',
title: 'مدیریت مالی',
desc: 'صندوق، درآمد و هزینه داخلی',
icon: 'dollar',
tint: 'mint',
admin: true,
},
{
to: '/reservations',
title: 'رزرو و نوبت‌دهی',
+60
View File
@@ -228,6 +228,66 @@ export const router = createRouter({
name: 'cheques',
component: () => import('@/views/ChequesView.vue'),
},
{
path: 'finance',
name: 'finance',
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts',
name: 'finance-accounts',
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/accounts/:id',
name: 'finance-account',
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/incomes',
name: 'finance-incomes',
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/expenses',
name: 'finance-expenses',
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/receivables',
name: 'finance-receivables',
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/payroll',
name: 'finance-payroll',
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/recurring',
name: 'finance-recurring',
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/reports',
name: 'finance-reports',
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'finance/categories',
name: 'finance-categories',
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'reservations',
name: 'reservations',
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const route = useRoute();
const account = ref<FinancialAccountDto | null>(null);
const items = ref<FinancialMovementDto[]>([]);
const error = ref('');
const stated = ref('');
const reason = ref('');
const note = ref('');
async function reconcile() {
error.value = '';
note.value = '';
try {
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
accountId: route.params.id,
statedBalance: stated.value,
});
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
} catch {
error.value = 'ثبت مغایرت ناموفق بود.';
}
}
async function adjust(direction: 'in' | 'out') {
error.value = '';
try {
await api.post('/treasury/adjustments', {
accountId: route.params.id,
amount: stated.value,
direction,
reason: reason.value,
});
window.location.reload();
} catch {
error.value = 'اصلاح موجودی ناموفق بود.';
}
}
onMounted(async () => {
try {
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
`/treasury/accounts/${route.params.id}/ledger`,
);
account.value = data.account;
items.value = data.items;
} catch {
error.value = 'گردش حساب بارگذاری نشد.';
}
});
</script>
<template>
<section class="page">
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="account" class="stats">
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
</div>
<div v-if="account" class="reconcile">
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
<p v-if="note" class="note">{{ note }}</p>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>تاریخ</th>
<th>شرح</th>
<th>نوع</th>
<th>ورودی</th>
<th>خروجی</th>
<th>مانده</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
<td>{{ row.memo || '—' }}</td>
<td>{{ row.kindLabel }}</td>
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
</tr>
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
</tbody>
</table>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
.stats span { color: #64748b; display: block; }
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
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 accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const openForm = ref(false);
const saving = ref(false);
const transferOpen = ref(false);
const form = ref({
name: '',
type: 'cash' as FinancialAccountType,
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
});
const transfer = ref({
fromAccountId: '',
toAccountId: '',
amount: '',
memo: '',
});
const active = computed(() => accounts.value.filter((row) => row.isActive));
const total = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.balance), 0),
);
const periodIn = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.periodIn), 0),
);
const periodOut = computed(() =>
active.value.reduce((sum, row) => sum + Number(row.periodOut), 0),
);
const typeLabel: Record<string, string> = {
bank: 'حساب بانکی',
cash: 'صندوق نقدی',
petty: 'تنخواه',
};
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 { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data;
}
async function createAccount() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/accounts', {
...form.value,
openingBalance: form.value.openingBalance || 0,
openingDate: form.value.openingDate || undefined,
});
openForm.value = false;
form.value = {
name: '',
type: 'cash',
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
};
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function sendTransfer() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/transfers', {
...transfer.value,
clientKey: crypto.randomUUID(),
});
transferOpen.value = false;
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حساب‌ها ناموفق بود.';
});
});
</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>
<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>
</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>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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 rows = ref<FinancialCategoryDto[]>([]);
const name = ref('');
const kind = ref<'income' | 'expense'>('expense');
const error = ref('');
async function load() {
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
rows.value = data;
}
async function add() {
error.value = '';
try {
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
name.value = '';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</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>
<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>
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } 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 props = defineProps<{ kind: 'income' | 'expense' }>();
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 = computed(() => (props.kind === 'income' ? 'دریافت‌ها و درآمدها' : 'پرداخت‌ها و هزینه‌ها'));
const settleLabel = computed(() => (props.kind === 'income' ? 'مبلغ دریافت‌شده' : 'مبلغ پرداخت‌شده'));
const statusLabel: Record<string, string> = {
open: props.kind === 'income' ? 'دریافت‌نشده' : 'پرداخت‌نشده',
partial: 'جزئی',
settled: props.kind === 'income' ? 'دریافت‌شده' : 'پرداخت‌شده',
void: 'باطل‌شده',
};
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: props.kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind: props.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: props.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);
}
}
watch(() => props.kind, () => {
void load();
});
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<section class="page">
<UiPageHeader :title="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.amount" /></UiField>
<UiField label="دسته‌بندی">
<select v-model="form.categoryId" class="select">
<option value="">بدون دسته</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></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="روش">
<select v-model="form.method" class="select">
<option value="cash">نقدی</option>
<option value="card_to_card">کارت‌به‌کارت</option>
<option value="pos">کارت‌خوان</option>
<option value="transfer">حواله</option>
<option value="other">سایر</option>
</select>
</UiField>
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
</div>
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
</UiCard>
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
<div class="list">
<article v-for="row in rows" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>
{{ statusLabel[row.status] }}
· مانده {{ toman(row.remaining) }}
<template v-if="row.categoryName"> · {{ row.categoryName }}</template>
</small>
</div>
<div class="side">
<b>{{ toman(row.amount) }}</b>
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
</div>
</article>
<p v-if="!rows.length" class="muted">سندی نیست.</p>
</div>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 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; }
.list { display: grid; gap: 0.55rem; }
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.list small, .muted { color: #64748b; }
.side { display: grid; justify-items: end; gap: 0.3rem; }
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
</script>
<template>
<TreasuryDocsView kind="expense" />
</template>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, ref } from '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 data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const cards = [
{ key: 'bookedIncome', label: 'درآمد ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
{ key: 'bookedExpense', label: 'هزینه ثبت‌شده', hint: 'تعهدی' },
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبت‌شده − هزینه ثبت‌شده' },
{ key: 'receivables', label: 'مطالبات وصول‌نشده', hint: 'درآمد بدون دریافت کامل' },
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
{ key: 'totalBalance', label: 'موجودی حساب‌ها', hint: 'صندوق و بانک' },
] as const;
onMounted(async () => {
try {
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
data.value = res.data;
} catch {
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
}
});
</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>
<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>
@@ -0,0 +1,6 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
</script>
<template>
<TreasuryDocsView kind="income" />
</template>
@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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';
type PayrollRow = {
id: string;
fullName: string;
period: string;
payable: string;
remaining: string;
status: string;
};
const rows = ref<PayrollRow[]>([]);
const users = ref<OrgUserDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
const pay = ref({ id: '', amount: '', accountId: '' });
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 [payroll, people, acc] = await Promise.all([
api.get<PayrollRow[]>('/treasury/payroll'),
api.get<OrgUserDto[]>('/users'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = payroll.data;
users.value = people.data.filter((row) => row.isActive !== false);
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createRow() {
error.value = '';
try {
await api.post('/treasury/payroll', form.value);
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
async function payRow() {
error.value = '';
try {
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
amount: pay.value.amount,
accountId: pay.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
pay.value = { id: '', amount: '', accountId: '' };
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
});
});
</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>
<strong>{{ row.fullName }}</strong>
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
</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>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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 rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const form = ref({
title: '',
partyName: '',
totalAmount: '',
prepaidAmount: '',
accountId: '',
count: '3',
interval: 'monthly',
startDate: '',
});
const collect = ref({ installmentId: '', receivableId: '', amount: '', accountId: '' });
const custom = ref(false);
const customLines = ref([{ amount: '', dueDate: '' }]);
const statusLabel: Record<string, string> = {
open: 'باز',
partial: 'جزئی',
settled: 'تسویه‌شده',
overdue: 'معوق',
void: 'باطل',
};
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 [list, acc] = await Promise.all([
api.get<FinancialReceivableDto[]>('/treasury/receivables'),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = list.data;
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createContract() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/receivables', {
title: form.value.title,
partyName: form.value.partyName || null,
totalAmount: form.value.totalAmount,
prepaidAmount: form.value.prepaidAmount || 0,
accountId: form.value.accountId || null,
...(custom.value
? { installments: customLines.value }
: {
count: Number(form.value.count),
interval: form.value.interval,
startDate: form.value.startDate || undefined,
}),
});
ok.value = 'قرارداد و برنامه اقساط ثبت شد.';
form.value.title = '';
form.value.totalAmount = '';
form.value.prepaidAmount = '';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function collectInstallment() {
if (!collect.value.installmentId) return;
saving.value = true;
error.value = '';
try {
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
amount: collect.value.amount,
accountId: collect.value.accountId,
clientKey: crypto.randomUUID(),
});
ok.value = 'دریافت قسط در همان حساب ثبت شد.';
collect.value = { installmentId: '', receivableId: '', amount: '', accountId: '' };
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری مطالبات ناموفق بود.';
});
});
</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>
<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>
<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>
</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>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
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';
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
const rows = ref<Row[]>([]);
const error = ref('');
const ok = ref('');
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
async function load() {
const { data } = await api.get<Row[]>('/treasury/recurring');
rows.value = data;
}
async function createItem() {
error.value = '';
try {
await api.post('/treasury/recurring', form.value);
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته می‌شود.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
: 'ثبت ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</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>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
</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>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '@/lib/api';
import { UiButton, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
type Period = {
bookedIncome: string;
bookedExpense: string;
operatingProfit: string;
cashIn: string;
cashOut: string;
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
const error = ref('');
const lines = [
['درآمد ثبت‌شده', 'bookedIncome'],
['هزینه ثبت‌شده', 'bookedExpense'],
['سود عملیاتی', 'operatingProfit'],
['ورود وجه', 'cashIn'],
['خروج وجه', 'cashOut'],
['خالص جریان نقدی', 'cashNet'],
] as const;
async function load() {
const { data } = await api.get<{
current: Period;
previous: Period;
payablesByMonth: Array<{ month: string; amount: string }>;
receivablesByParty: Array<{ name: string; amount: string }>;
}>('/treasury/reports');
current.value = data.current;
previous.value = data.previous;
payablesByMonth.value = data.payablesByMonth;
receivablesByParty.value = data.receivablesByParty;
}
async function download() {
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const link = document.createElement('a');
link.href = url;
link.download = 'treasury-report.xlsx';
link.click();
URL.revokeObjectURL(url);
}
onMounted(() => {
void load().catch(() => {
error.value = 'گزارش بارگذاری نشد.';
});
});
</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>
<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>
@@ -0,0 +1,5 @@
export function toman(value: string | number | null | undefined) {
const n = Number(value ?? 0);
if (!Number.isFinite(n)) return '۰';
return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`;
}