feat: add treasury audit, attachment download, and installment SMS
Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const links = computed(() => [
|
||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/audit', label: 'سوابق' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
]);
|
||||
|
||||
|
||||
@@ -282,6 +282,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/audit',
|
||||
name: 'finance-audit',
|
||||
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/categories',
|
||||
name: 'finance-categories',
|
||||
|
||||
@@ -27,6 +27,10 @@ const items = ref<ChequeDto[]>([]);
|
||||
const status = ref('');
|
||||
const q = ref('');
|
||||
const savingId = ref('');
|
||||
const clearOpen = ref(false);
|
||||
const clearRow = ref<ChequeDto | null>(null);
|
||||
const clearAccountId = ref('');
|
||||
const clearAccounts = ref<Array<{ id: string; name: string }>>([]);
|
||||
const tab = ref<ChequeDirection>('in');
|
||||
|
||||
const formOpen = ref(false);
|
||||
@@ -95,9 +99,26 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmClear() {
|
||||
if (!clearRow.value || !clearAccountId.value) return;
|
||||
savingId.value = clearRow.value.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${clearRow.value.id}`, {
|
||||
status: 'cleared',
|
||||
financialAccountId: clearAccountId.value,
|
||||
});
|
||||
items.value = items.value.map((item) => (item.id === data.id ? data : item));
|
||||
clearOpen.value = false;
|
||||
} catch {
|
||||
error.value = 'وصول چک ناموفق بود.';
|
||||
} finally {
|
||||
savingId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
||||
if (row.status === next) return;
|
||||
let financialAccountId: string | undefined;
|
||||
if (next === 'cleared' && row.status !== 'cleared') {
|
||||
const accountsRes = await api
|
||||
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
|
||||
@@ -114,30 +135,20 @@ async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const accounts = accountsRes.data;
|
||||
const active = accounts.filter((item) => item.isActive);
|
||||
const active = accountsRes.data.filter((item) => item.isActive);
|
||||
if (!active.length) {
|
||||
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
|
||||
return;
|
||||
}
|
||||
if (active.length === 1) {
|
||||
financialAccountId = active[0]!.id;
|
||||
} else {
|
||||
const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`);
|
||||
const match = active.find((item) => item.name.trim() === picked?.trim());
|
||||
if (!match) {
|
||||
error.value = 'حساب صندوق انتخاب نشد.';
|
||||
return;
|
||||
}
|
||||
financialAccountId = match.id;
|
||||
}
|
||||
clearAccounts.value = active.map((item) => ({ id: item.id, name: item.name }));
|
||||
clearAccountId.value = active[0]!.id;
|
||||
clearRow.value = row;
|
||||
clearOpen.value = true;
|
||||
return;
|
||||
}
|
||||
savingId.value = row.id;
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
|
||||
status: next,
|
||||
...(financialAccountId ? { financialAccountId } : {}),
|
||||
});
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
|
||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||
} catch {
|
||||
error.value = 'تغییر وضعیت چک ناموفق بود.';
|
||||
@@ -326,6 +337,24 @@ onMounted(load);
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<UiModal :open="clearOpen" title="حساب وصول چک" @close="clearOpen = false">
|
||||
<p class="hint">چک {{ clearRow?.number }} در کدام حساب صندوق بنشیند؟</p>
|
||||
<label class="field">
|
||||
<span>حساب</span>
|
||||
<select v-model="clearAccountId">
|
||||
<option v-for="account in clearAccounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<UiButton
|
||||
:loading="savingId === clearRow?.id"
|
||||
@click="confirmClear"
|
||||
>
|
||||
ثبت وصول
|
||||
</UiButton>
|
||||
</UiModal>
|
||||
|
||||
<UiModal
|
||||
:open="formOpen"
|
||||
:title="isIncomingForm ? 'دریافت چک' : 'پرداخت چک'"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
createdAt: string;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
};
|
||||
|
||||
const rows = ref<Row[]>([]);
|
||||
const error = ref('');
|
||||
const actionLabel: Record<string, string> = {
|
||||
settle: 'تسویه سند',
|
||||
cash_post: 'نشستن وجه در حساب',
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get<Row[]>('/treasury/audit');
|
||||
rows.value = data;
|
||||
} catch {
|
||||
error.value = 'سوابق بارگذاری نشد.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="سوابق تغییرات مالی" description="تسویه سند و نشستن وجه در صندوق، با مقدار ثبتشده." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="!error && !rows.length" class="muted">سابقهای نیست.</p>
|
||||
<article v-for="row in rows" :key="row.id">
|
||||
<header>
|
||||
<strong>{{ actionLabel[row.action] ?? row.action }}</strong>
|
||||
<time>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</time>
|
||||
</header>
|
||||
<pre>{{ JSON.stringify(row.after ?? row.before, null, 2) }}</pre>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 0.8rem; }
|
||||
article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
header { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
time, .muted { color: #64748b; }
|
||||
pre { margin: 0.45rem 0 0; white-space: pre-wrap; font: inherit; color: #334155; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
@@ -91,6 +91,29 @@ async function createDoc() {
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadExcel() {
|
||||
const { data } = await api.get<Blob>('/treasury/documents/export', {
|
||||
params: { kind: props.kind },
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = props.kind === 'income' ? 'incomes.xlsx' : 'expenses.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function downloadAttachment(id: string, fileName: string) {
|
||||
const { data } = await api.get<Blob>(`/treasury/attachments/${id}`, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function upload(id: string, event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
@@ -99,6 +122,7 @@ async function upload(id: string, event: Event) {
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/attachments`, body);
|
||||
ok.value = 'پیوست ذخیره شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
@@ -161,6 +185,7 @@ onMounted(() => {
|
||||
<UiField label="پیگیری"><UiInput v-model="form.reference" /></UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createDoc">ثبت</UiButton>
|
||||
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||
</UiCard>
|
||||
<UiField label="جستجو"><UiInput v-model="q" @change="load" /></UiField>
|
||||
<div class="list">
|
||||
@@ -176,6 +201,15 @@ onMounted(() => {
|
||||
<div class="side">
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
||||
<button
|
||||
v-for="file in row.attachments ?? []"
|
||||
:key="file.id"
|
||||
type="button"
|
||||
class="file"
|
||||
@click="downloadAttachment(file.id, file.fileName)"
|
||||
>
|
||||
{{ file.fileName }}
|
||||
</button>
|
||||
<label v-if="row.status !== 'void'" class="file">
|
||||
پیوست
|
||||
<input type="file" @change="upload(row.id, $event)" />
|
||||
|
||||
@@ -51,6 +51,16 @@ async function load() {
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function downloadExcel() {
|
||||
const { data } = await api.get<Blob>('/treasury/receivables/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'installments.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function createContract() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
@@ -148,6 +158,7 @@ onMounted(() => {
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<article v-for="row in rows" :key="row.id" class="contract">
|
||||
|
||||
Reference in New Issue
Block a user