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,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>