feat: add internal finance ledger for accounts, claims, and payroll
Keep booked income and expense separate from cash balances, with installments, recurring items, and reports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const links = [
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
{ to: '/finance/payroll', label: 'حقوق' },
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
];
|
||||
|
||||
function on(path: string, exact?: boolean) {
|
||||
if (exact) return route.path === path;
|
||||
return route.path === path || route.path.startsWith(`${path}/`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="treasury-nav" aria-label="مدیریت مالی">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
:class="{ on: on(link.to, link.exact) }"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.treasury-nav {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
overflow: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.treasury-nav a {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.treasury-nav a.on {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -354,6 +354,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'sky',
|
||||
module: 'cheques',
|
||||
},
|
||||
{
|
||||
to: '/finance',
|
||||
title: 'مدیریت مالی',
|
||||
desc: 'صندوق، درآمد و هزینه داخلی',
|
||||
icon: 'dollar',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/reservations',
|
||||
title: 'رزرو و نوبتدهی',
|
||||
|
||||
@@ -228,6 +228,66 @@ export const router = createRouter({
|
||||
name: 'cheques',
|
||||
component: () => import('@/views/ChequesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'finance',
|
||||
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/accounts',
|
||||
name: 'finance-accounts',
|
||||
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/accounts/:id',
|
||||
name: 'finance-account',
|
||||
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/incomes',
|
||||
name: 'finance-incomes',
|
||||
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/expenses',
|
||||
name: 'finance-expenses',
|
||||
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/receivables',
|
||||
name: 'finance-receivables',
|
||||
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/payroll',
|
||||
name: 'finance-payroll',
|
||||
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/recurring',
|
||||
name: 'finance-recurring',
|
||||
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/reports',
|
||||
name: 'finance-reports',
|
||||
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/categories',
|
||||
name: 'finance-categories',
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'reservations',
|
||||
name: 'reservations',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const route = useRoute();
|
||||
const account = ref<FinancialAccountDto | null>(null);
|
||||
const items = ref<FinancialMovementDto[]>([]);
|
||||
const error = ref('');
|
||||
const stated = ref('');
|
||||
const reason = ref('');
|
||||
const note = ref('');
|
||||
|
||||
async function reconcile() {
|
||||
error.value = '';
|
||||
note.value = '';
|
||||
try {
|
||||
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
|
||||
accountId: route.params.id,
|
||||
statedBalance: stated.value,
|
||||
});
|
||||
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
|
||||
} catch {
|
||||
error.value = 'ثبت مغایرت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust(direction: 'in' | 'out') {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/adjustments', {
|
||||
accountId: route.params.id,
|
||||
amount: stated.value,
|
||||
direction,
|
||||
reason: reason.value,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch {
|
||||
error.value = 'اصلاح موجودی ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
|
||||
`/treasury/accounts/${route.params.id}/ledger`,
|
||||
);
|
||||
account.value = data.account;
|
||||
items.value = data.items;
|
||||
} catch {
|
||||
error.value = 'گردش حساب بارگذاری نشد.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader :title="account?.name || 'حساب'" description="گردش قطعی این حساب" />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="account" class="stats">
|
||||
<article><span>موجودی فعلی</span><strong>{{ toman(account.balance) }}</strong></article>
|
||||
<article><span>مانده افتتاحیه</span><strong>{{ toman(account.openingBalance) }}</strong></article>
|
||||
<article><span>ورودی</span><strong>{{ toman(account.periodIn) }}</strong></article>
|
||||
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
|
||||
</div>
|
||||
<div v-if="account" class="reconcile">
|
||||
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
|
||||
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
|
||||
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('out')">اصلاح خروج</UiButton>
|
||||
<p v-if="note" class="note">{{ note }}</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>تاریخ</th>
|
||||
<th>شرح</th>
|
||||
<th>نوع</th>
|
||||
<th>ورودی</th>
|
||||
<th>خروجی</th>
|
||||
<th>مانده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td>{{ formatJalaliNumeric(new Date(row.occurredAt)) }}</td>
|
||||
<td>{{ row.memo || '—' }}</td>
|
||||
<td>{{ row.kindLabel }}</td>
|
||||
<td>{{ row.direction === 'in' ? toman(row.amount) : '—' }}</td>
|
||||
<td>{{ row.direction === 'out' ? toman(row.amount) : '—' }}</td>
|
||||
<td>{{ row.balanceAfter ? toman(row.balanceAfter) : '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items.length"><td colspan="6">گردشی ثبت نشده است.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; }
|
||||
.stats article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem; }
|
||||
.stats span { color: #64748b; display: block; }
|
||||
.table-wrap { overflow: auto; background: #fff; border: 1px solid #e7eef2; border-radius: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 0.7rem; text-align: right; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
.reconcile { display: grid; grid-template-columns: 1fr 1fr auto auto auto; gap: 0.6rem; align-items: end; }
|
||||
.note { grid-column: 1 / -1; color: #0f766e; font-weight: 700; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 800px) { .reconcile { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<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 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="موجودی اولیه"><UiInput v-model="form.openingBalance" /></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="مبلغ"><UiInput v-model="transfer.amount" /></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>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const name = ref('');
|
||||
const kind = ref<'income' | 'expense'>('expense');
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
|
||||
rows.value = data;
|
||||
}
|
||||
|
||||
async function add() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
|
||||
name.value = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="دستهبندیهای مالی" description="دستههای پیشفرض با اولین بازدید ساخته میشوند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="add">
|
||||
<UiField label="نام"><UiInput v-model="name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="kind" class="select">
|
||||
<option value="income">درآمد</option>
|
||||
<option value="expense">هزینه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiButton @click="add">افزودن</UiButton>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="row in rows" :key="row.id">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
li span { color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<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 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 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="مبلغ کل"><UiInput v-model="form.amount" /></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"><UiInput v-model="form.settleAmount" /></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>
|
||||
</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>
|
||||
</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 { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
</script>
|
||||
<template>
|
||||
<TreasuryDocsView kind="expense" />
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import { UiCard, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
const cards = [
|
||||
{ key: 'bookedIncome', label: 'درآمد ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualReceipts', label: 'دریافت واقعی', hint: 'ورود به حساب' },
|
||||
{ key: 'bookedExpense', label: 'هزینه ثبتشده', hint: 'تعهدی' },
|
||||
{ key: 'actualPayments', label: 'پرداخت واقعی', hint: 'خروج از حساب' },
|
||||
{ key: 'operatingProfit', label: 'سود یا زیان عملیاتی', hint: 'درآمد ثبتشده − هزینه ثبتشده' },
|
||||
{ key: 'receivables', label: 'مطالبات وصولنشده', hint: 'درآمد بدون دریافت کامل' },
|
||||
{ key: 'payables', label: 'تعهدات پرداخت', hint: 'هزینه بدون پرداخت کامل' },
|
||||
{ key: 'totalBalance', label: 'موجودی حسابها', hint: 'صندوق و بانک' },
|
||||
] as const;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get<TreasuryDashboardDto>('/treasury/dashboard');
|
||||
data.value = res.data;
|
||||
} catch {
|
||||
error.value = 'بارگذاری داشبورد مالی ناموفق بود.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="مدیریت مالی"
|
||||
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="data" class="stats">
|
||||
<article v-for="card in cards" :key="card.key">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ toman(data[card.key]) }}</strong>
|
||||
<small>{{ card.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="data" class="split">
|
||||
<UiCard title="اقساط نزدیک یا معوق">
|
||||
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingInstallments" :key="row.id">
|
||||
<div>
|
||||
<strong>قسط {{ row.position }}</strong>
|
||||
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
<UiCard title="پرداختهای آینده">
|
||||
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingPayables" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
<UiCard v-if="data" title="آخرین اسناد">
|
||||
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.recent" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stats article {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats span, .stats small, .muted { color: #64748b; }
|
||||
.stats strong { font-size: 1.05rem; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
|
||||
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
|
||||
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.list small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
</script>
|
||||
<template>
|
||||
<TreasuryDocsView kind="income" />
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
type PayrollRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
period: string;
|
||||
payable: string;
|
||||
remaining: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const rows = ref<PayrollRow[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const form = ref({ userId: '', period: '', baseAmount: '', benefits: '', bonus: '', deductions: '' });
|
||||
const pay = ref({ id: '', amount: '', accountId: '' });
|
||||
|
||||
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 [payroll, people, acc] = await Promise.all([
|
||||
api.get<PayrollRow[]>('/treasury/payroll'),
|
||||
api.get<OrgUserDto[]>('/users'),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = payroll.data;
|
||||
users.value = people.data.filter((row) => row.isActive !== false);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createRow() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/payroll', form.value);
|
||||
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function payRow() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
|
||||
amount: pay.value.amount,
|
||||
accountId: pay.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
|
||||
pay.value = { id: '', amount: '', accountId: '' };
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<UiCard title="ثبت دوره">
|
||||
<div class="grid">
|
||||
<UiField label="پرسنل">
|
||||
<select v-model="form.userId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
|
||||
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
|
||||
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
|
||||
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
|
||||
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(row.payable) }}</b>
|
||||
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
|
||||
</div>
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="pay.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton @click="payRow">ثبت پرداخت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 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; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.side { display: grid; justify-items: end; }
|
||||
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const rows = ref<FinancialReceivableDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const form = ref({
|
||||
title: '',
|
||||
partyName: '',
|
||||
totalAmount: '',
|
||||
prepaidAmount: '',
|
||||
accountId: '',
|
||||
count: '3',
|
||||
interval: 'monthly',
|
||||
startDate: '',
|
||||
});
|
||||
const collect = ref({ installmentId: '', receivableId: '', amount: '', accountId: '' });
|
||||
const custom = ref(false);
|
||||
const customLines = ref([{ amount: '', dueDate: '' }]);
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'باز',
|
||||
partial: 'جزئی',
|
||||
settled: 'تسویهشده',
|
||||
overdue: 'معوق',
|
||||
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 [list, acc] = await Promise.all([
|
||||
api.get<FinancialReceivableDto[]>('/treasury/receivables'),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = list.data;
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createContract() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/receivables', {
|
||||
title: form.value.title,
|
||||
partyName: form.value.partyName || null,
|
||||
totalAmount: form.value.totalAmount,
|
||||
prepaidAmount: form.value.prepaidAmount || 0,
|
||||
accountId: form.value.accountId || null,
|
||||
...(custom.value
|
||||
? { installments: customLines.value }
|
||||
: {
|
||||
count: Number(form.value.count),
|
||||
interval: form.value.interval,
|
||||
startDate: form.value.startDate || undefined,
|
||||
}),
|
||||
});
|
||||
ok.value = 'قرارداد و برنامه اقساط ثبت شد.';
|
||||
form.value.title = '';
|
||||
form.value.totalAmount = '';
|
||||
form.value.prepaidAmount = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectInstallment() {
|
||||
if (!collect.value.installmentId) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
|
||||
amount: collect.value.amount,
|
||||
accountId: collect.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'دریافت قسط در همان حساب ثبت شد.';
|
||||
collect.value = { installmentId: '', receivableId: '', amount: '', accountId: '' };
|
||||
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>
|
||||
<UiCard title="قرارداد جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
|
||||
<UiField label="پیشپرداخت"><UiInput v-model="form.prepaidAmount" /></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="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||||
<div v-if="custom" class="custom">
|
||||
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||||
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<article v-for="row in rows" :key="row.id" class="contract">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.totalAmount) }}</b>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>قسط</th>
|
||||
<th>سررسید</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مانده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in row.installments" :key="item.id">
|
||||
<td>{{ item.position }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
|
||||
<td>{{ toman(item.amount) }}</td>
|
||||
<td>{{ toman(item.remaining) }}</td>
|
||||
<td>{{ statusLabel[item.status] }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'settled'"
|
||||
type="button"
|
||||
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
|
||||
>
|
||||
دریافت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||||
<UiField label="حساب مقصد">
|
||||
<select v-model="collect.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 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; }
|
||||
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
|
||||
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.contract small { display: block; color: #64748b; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
|
||||
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
|
||||
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
|
||||
const rows = ref<Row[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<Row[]>('/treasury/recurring');
|
||||
rows.value = data;
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/recurring', form.value);
|
||||
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته میشود.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</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>
|
||||
<UiCard title="الگوی جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.kind" class="select">
|
||||
<option value="expense">هزینه</option>
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
<option value="yearly">سالانه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createItem">ثبت الگو</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 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; }
|
||||
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
bookedExpense: string;
|
||||
operatingProfit: string;
|
||||
cashIn: string;
|
||||
cashOut: string;
|
||||
cashNet: string;
|
||||
expenseCategories: Array<{ name: string; amount: string; share: string }>;
|
||||
};
|
||||
const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
|
||||
const error = ref('');
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
['هزینه ثبتشده', 'bookedExpense'],
|
||||
['سود عملیاتی', 'operatingProfit'],
|
||||
['ورود وجه', 'cashIn'],
|
||||
['خروج وجه', 'cashOut'],
|
||||
['خالص جریان نقدی', 'cashNet'],
|
||||
] as const;
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<{
|
||||
current: Period;
|
||||
previous: Period;
|
||||
payablesByMonth: Array<{ month: string; amount: string }>;
|
||||
receivablesByParty: Array<{ name: string; amount: string }>;
|
||||
}>('/treasury/reports');
|
||||
current.value = data.current;
|
||||
previous.value = data.previous;
|
||||
payablesByMonth.value = data.payablesByMonth;
|
||||
receivablesByParty.value = data.receivablesByParty;
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'treasury-report.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گزارش بارگذاری نشد.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
|
||||
<table v-if="current && previous">
|
||||
<thead>
|
||||
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in lines" :key="line[1]">
|
||||
<td>{{ line[0] }}</td>
|
||||
<td>{{ toman(current[line[1]]) }}</td>
|
||||
<td>{{ toman(previous[line[1]]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>مطالبات به تفکیک طرف حساب</h2>
|
||||
<ul>
|
||||
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2>تعهدات به تفکیک ماه</h2>
|
||||
<ul>
|
||||
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2 v-if="current">سهم هزینهها</h2>
|
||||
<ul v-if="current">
|
||||
<li v-for="row in current.expenseCategories" :key="row.name">
|
||||
<span>{{ row.name }}</span>
|
||||
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
h2 { margin: 0; font-size: 1rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
export function toman(value: string | number | null | undefined) {
|
||||
const n = Number(value ?? 0);
|
||||
if (!Number.isFinite(n)) return '۰';
|
||||
return `${new Intl.NumberFormat('fa-IR').format(Math.round(n))} تومان`;
|
||||
}
|
||||
Reference in New Issue
Block a user