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:
@@ -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>
|
||||
Reference in New Issue
Block a user