Let cheque clearance pick a cash account and export income, expense, and installment lists to Excel. Co-authored-by: Cursor <cursoragent@cursor.com>
238 lines
8.9 KiB
Vue
238 lines
8.9 KiB
Vue
<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>
|