Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryAccountsView.vue
T

226 lines
8.0 KiB
Vue
Raw Normal View History

<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>