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>
226 lines
8.0 KiB
Vue
226 lines
8.0 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import type { FinancialAccountDto, FinancialAccountType } 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 accounts = ref<FinancialAccountDto[]>([]);
|
||
const error = ref('');
|
||
const ok = ref('');
|
||
const openForm = ref(false);
|
||
const saving = ref(false);
|
||
const transferOpen = ref(false);
|
||
const form = ref({
|
||
name: '',
|
||
type: 'cash' as FinancialAccountType,
|
||
bankName: '',
|
||
accountRef: '',
|
||
responsibleName: '',
|
||
openingBalance: '',
|
||
openingDate: '',
|
||
notes: '',
|
||
});
|
||
const transfer = ref({
|
||
fromAccountId: '',
|
||
toAccountId: '',
|
||
amount: '',
|
||
memo: '',
|
||
});
|
||
|
||
const active = computed(() => accounts.value.filter((row) => row.isActive));
|
||
const total = computed(() =>
|
||
active.value.reduce((sum, row) => sum + Number(row.balance), 0),
|
||
);
|
||
const periodIn = computed(() =>
|
||
active.value.reduce((sum, row) => sum + Number(row.periodIn), 0),
|
||
);
|
||
const periodOut = computed(() =>
|
||
active.value.reduce((sum, row) => sum + Number(row.periodOut), 0),
|
||
);
|
||
|
||
const typeLabel: Record<string, string> = {
|
||
bank: 'حساب بانکی',
|
||
cash: 'صندوق نقدی',
|
||
petty: 'تنخواه',
|
||
};
|
||
|
||
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 { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
|
||
accounts.value = data;
|
||
}
|
||
|
||
async function createAccount() {
|
||
saving.value = true;
|
||
error.value = '';
|
||
ok.value = '';
|
||
try {
|
||
await api.post('/treasury/accounts', {
|
||
...form.value,
|
||
openingBalance: form.value.openingBalance || 0,
|
||
openingDate: form.value.openingDate || undefined,
|
||
});
|
||
openForm.value = false;
|
||
form.value = {
|
||
name: '',
|
||
type: 'cash',
|
||
bankName: '',
|
||
accountRef: '',
|
||
responsibleName: '',
|
||
openingBalance: '',
|
||
openingDate: '',
|
||
notes: '',
|
||
};
|
||
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiMessage(e);
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendTransfer() {
|
||
saving.value = true;
|
||
error.value = '';
|
||
ok.value = '';
|
||
try {
|
||
await api.post('/treasury/transfers', {
|
||
...transfer.value,
|
||
clientKey: crypto.randomUUID(),
|
||
});
|
||
transferOpen.value = false;
|
||
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
|
||
await load();
|
||
} catch (e) {
|
||
error.value = apiMessage(e);
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void load().catch(() => {
|
||
error.value = 'بارگذاری حسابها ناموفق بود.';
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page">
|
||
<UiPageHeader title="صندوق و حسابها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود." />
|
||
<TreasuryNav />
|
||
<p v-if="error" class="err">{{ error }}</p>
|
||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||
<div class="stats">
|
||
<article>
|
||
<span>مجموع موجودی حسابها</span>
|
||
<strong>{{ toman(total) }}</strong>
|
||
</article>
|
||
<article>
|
||
<span>ورودی این فهرست</span>
|
||
<strong class="in">{{ toman(periodIn) }}</strong>
|
||
</article>
|
||
<article>
|
||
<span>خروجی این فهرست</span>
|
||
<strong class="out">{{ toman(periodOut) }}</strong>
|
||
</article>
|
||
<article>
|
||
<span>حسابهای فعال</span>
|
||
<strong>{{ active.length }} حساب</strong>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="list">
|
||
<article v-for="account in accounts" :key="account.id" class="account">
|
||
<div>
|
||
<strong>{{ account.name }}</strong>
|
||
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
|
||
<p>مشاهده گردش حساب و جزئیات</p>
|
||
</div>
|
||
<div class="side">
|
||
<b>{{ toman(account.balance) }}</b>
|
||
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="actions">
|
||
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
|
||
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
|
||
</div>
|
||
|
||
<UiCard v-if="openForm" title="حساب جدید">
|
||
<div class="grid">
|
||
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
|
||
<UiField label="نوع">
|
||
<select v-model="form.type" class="select">
|
||
<option value="cash">صندوق نقدی</option>
|
||
<option value="bank">حساب بانکی</option>
|
||
<option value="petty">تنخواه</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
|
||
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
|
||
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
|
||
<UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
|
||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||
</div>
|
||
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
|
||
</UiCard>
|
||
|
||
<UiCard v-if="transferOpen" title="انتقال بین حسابها">
|
||
<div class="grid">
|
||
<UiField label="از حساب">
|
||
<select v-model="transfer.fromAccountId" class="select">
|
||
<option value="">انتخاب</option>
|
||
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField label="به حساب">
|
||
<select v-model="transfer.toAccountId" class="select">
|
||
<option value="">انتخاب</option>
|
||
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
|
||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||
</div>
|
||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
|
||
</UiCard>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page { display: grid; gap: 1rem; }
|
||
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
|
||
.stats article, .account {
|
||
background: #fff;
|
||
border: 1px solid #e7eef2;
|
||
border-radius: 16px;
|
||
padding: 0.9rem 1rem;
|
||
}
|
||
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
|
||
.in { color: #15803d; }
|
||
.out { color: #b91c1c; }
|
||
.list { display: grid; gap: 0.65rem; }
|
||
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||
.side { display: grid; justify-items: end; gap: 0.4rem; }
|
||
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
|
||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||
.err { color: #b91c1c; font-weight: 700; }
|
||
.ok { color: #0f766e; font-weight: 700; }
|
||
</style>
|