Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization. Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
4.8 KiB
Vue
122 lines
4.8 KiB
Vue
<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 MoneyInput from '@/components/MoneyInput.vue';
|
|
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="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></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>
|