Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryDocsView.vue
T
MahdiandCursor 96befd3867 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>
2026-10-18 19:59:24 +03:30

238 lines
8.9 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 MoneyInput from '@/components/MoneyInput.vue';
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 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;
const body = new FormData();
body.append('file', file);
try {
await api.post(`/treasury/documents/${id}/attachments`, body);
ok.value = 'پیوست ذخیره شد.';
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
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="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></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"><MoneyInput v-model="form.settleAmount" :decimals="0" /></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>
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</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>
<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)" />
</label>
</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, .file { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
.file { color: #0f766e; font-weight: 700; }
.file input { display: none; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>