fix(finance): shared treasury layout so tab switches stop jumping
Keep title and nav mounted in TreasuryLayout with stable header height, and finish Ant form chrome (MoneyInput, Jalali) on finance pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,66 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const canPayroll = computed(() =>
|
||||
auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
|
||||
const canPayroll = computed(
|
||||
() =>
|
||||
auth.user?.role === 'owner' ||
|
||||
auth.user?.role === 'admin' ||
|
||||
Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
);
|
||||
|
||||
const links = computed(() => [
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/audit', label: 'سوابق' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
{ value: '/finance', label: 'داشبورد', exact: true },
|
||||
{ value: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ value: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ value: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ value: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
...(canPayroll.value ? [{ value: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ value: '/finance/reports', label: 'گزارش' },
|
||||
{ value: '/finance/audit', label: 'سوابق' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها' },
|
||||
]);
|
||||
|
||||
function on(path: string, exact?: boolean) {
|
||||
if (exact) return route.path === path;
|
||||
return route.path === path || route.path.startsWith(`${path}/`);
|
||||
const options = computed(() => links.value.map((l) => ({ value: l.value, label: l.label })));
|
||||
|
||||
const active = computed(() => {
|
||||
const path = route.path;
|
||||
for (const link of links.value) {
|
||||
if (link.exact) {
|
||||
if (path === link.value) return link.value;
|
||||
} else if (path === link.value || path.startsWith(`${link.value}/`)) {
|
||||
return link.value;
|
||||
}
|
||||
}
|
||||
return '/finance';
|
||||
});
|
||||
|
||||
function onChange(value: string | number) {
|
||||
const next = String(value);
|
||||
if (next && next !== route.path) void router.push(next);
|
||||
}
|
||||
</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>
|
||||
<div
|
||||
:style="{
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
minHeight: '36px',
|
||||
}"
|
||||
>
|
||||
<a-segmented
|
||||
:value="active"
|
||||
:options="options"
|
||||
dir="rtl"
|
||||
:style="{ minWidth: 'max-content' }"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+117
-62
@@ -241,69 +241,124 @@ export const router = createRouter({
|
||||
},
|
||||
{
|
||||
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/audit',
|
||||
name: 'finance-audit',
|
||||
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'finance/categories',
|
||||
name: 'finance-categories',
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
component: () => import('@/views/treasury/TreasuryLayout.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'finance',
|
||||
component: () => import('@/views/treasury/TreasuryHomeView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'مدیریت مالی',
|
||||
treasuryDescription:
|
||||
'دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
name: 'finance-accounts',
|
||||
component: () => import('@/views/treasury/TreasuryAccountsView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'صندوق و حسابها',
|
||||
treasuryDescription: 'موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'accounts/:id',
|
||||
name: 'finance-account',
|
||||
component: () => import('@/views/treasury/TreasuryAccountView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'جزئیات حساب',
|
||||
treasuryDescription: 'گردش قطعی این حساب',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'incomes',
|
||||
name: 'finance-incomes',
|
||||
component: () => import('@/views/treasury/TreasuryIncomesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'دریافتها و درآمدها',
|
||||
treasuryDescription:
|
||||
'ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'expenses',
|
||||
name: 'finance-expenses',
|
||||
component: () => import('@/views/treasury/TreasuryExpensesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'پرداختها و هزینهها',
|
||||
treasuryDescription:
|
||||
'ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'receivables',
|
||||
name: 'finance-receivables',
|
||||
component: () => import('@/views/treasury/TreasuryReceivablesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'اقساط و مطالبات',
|
||||
treasuryDescription:
|
||||
'دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'payroll',
|
||||
name: 'finance-payroll',
|
||||
component: () => import('@/views/treasury/TreasuryPayrollView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'حقوق و دستمزد',
|
||||
treasuryDescription: 'فقط مالک و مدیر این صفحه را میبینند.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'recurring',
|
||||
name: 'finance-recurring',
|
||||
component: () => import('@/views/treasury/TreasuryRecurringView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'درآمد و هزینه تکرارشونده',
|
||||
treasuryDescription: 'تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'finance-reports',
|
||||
component: () => import('@/views/treasury/TreasuryReportsView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'گزارش مالی',
|
||||
treasuryDescription: 'سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'finance-audit',
|
||||
component: () => import('@/views/treasury/TreasuryAuditView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'سوابق تغییرات مالی',
|
||||
treasuryDescription: 'تسویه سند و نشستن وجه در صندوق، با مقدار ثبتشده.',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'finance-categories',
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'دستهبندیهای مالی',
|
||||
treasuryDescription: 'دستههای پیشفرض با اولین بازدید ساخته میشوند.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'reservations',
|
||||
|
||||
@@ -1,121 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Statistic as AStatistic,
|
||||
Table as ATable,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
import { useTreasuryPageChrome } from './treasuryPageChrome';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const pageChrome = useTreasuryPageChrome();
|
||||
const account = ref<FinancialAccountDto | null>(null);
|
||||
const items = ref<FinancialMovementDto[]>([]);
|
||||
const error = ref('');
|
||||
const stated = ref('');
|
||||
const stated = ref<string | number | null>('');
|
||||
const reason = ref('');
|
||||
const note = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
const columns: TableColumnsType<FinancialMovementDto> = [
|
||||
{
|
||||
title: 'تاریخ',
|
||||
key: 'occurredAt',
|
||||
customRender: ({ record }) => formatJalaliNumeric(new Date(record.occurredAt)),
|
||||
},
|
||||
{
|
||||
title: 'شرح',
|
||||
key: 'memo',
|
||||
customRender: ({ record }) => record.memo || '—',
|
||||
},
|
||||
{ title: 'نوع', dataIndex: 'kindLabel', key: 'kindLabel' },
|
||||
{
|
||||
title: 'ورودی',
|
||||
key: 'in',
|
||||
customRender: ({ record }) => (record.direction === 'in' ? toman(record.amount) : '—'),
|
||||
},
|
||||
{
|
||||
title: 'خروجی',
|
||||
key: 'out',
|
||||
customRender: ({ record }) => (record.direction === 'out' ? toman(record.amount) : '—'),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'balanceAfter',
|
||||
customRender: ({ record }) => (record.balanceAfter ? toman(record.balanceAfter) : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<{ account: FinancialAccountDto; items: FinancialMovementDto[] }>(
|
||||
`/treasury/accounts/${route.params.id}/ledger`,
|
||||
);
|
||||
account.value = data.account;
|
||||
items.value = data.items;
|
||||
pageChrome?.set({
|
||||
title: data.account.name || 'جزئیات حساب',
|
||||
description: 'گردش قطعی این حساب',
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcile() {
|
||||
error.value = '';
|
||||
note.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.post<{ difference: string; systemBalance: string }>('/treasury/reconciliations', {
|
||||
accountId: route.params.id,
|
||||
statedBalance: stated.value,
|
||||
});
|
||||
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
|
||||
const { data } = await api.post<{ difference: string; systemBalance: string }>(
|
||||
'/treasury/reconciliations',
|
||||
{
|
||||
accountId: route.params.id,
|
||||
statedBalance: amount ?? 0,
|
||||
},
|
||||
);
|
||||
note.value = `اختلاف ${toman(data.difference)} نسبت به موجودی سیستم ${toman(data.systemBalance)}. موجودی خودکار عوض نشد.`;
|
||||
} catch {
|
||||
error.value = 'ثبت مغایرت ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust(direction: 'in' | 'out') {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(stated.value ?? ''), 0);
|
||||
await api.post('/treasury/adjustments', {
|
||||
accountId: route.params.id,
|
||||
amount: stated.value,
|
||||
amount: amount ?? 0,
|
||||
direction,
|
||||
reason: reason.value,
|
||||
});
|
||||
window.location.reload();
|
||||
await load();
|
||||
note.value = 'اصلاح موجودی ثبت شد.';
|
||||
} catch {
|
||||
error.value = 'اصلاح موجودی ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گردش حساب بارگذاری نشد.';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
pageChrome?.clear();
|
||||
});
|
||||
</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="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></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>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-space>
|
||||
<a-button type="link" @click="router.push('/finance/accounts')">بازگشت به حسابها</a-button>
|
||||
</a-space>
|
||||
|
||||
<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>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="note" type="success" show-icon :message="note" />
|
||||
|
||||
<a-row v-if="account" :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="موجودی فعلی" :value="toman(account.balance)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="مانده افتتاحیه" :value="toman(account.openingBalance)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="ورودی" :value="toman(account.periodIn)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="خروجی" :value="toman(account.periodOut)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card v-if="account" :bordered="false">
|
||||
<a-divider orientation="right">مغایرت و اصلاح</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="موجودی واقعی">
|
||||
<MoneyInput v-model="stated" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="علت اصلاح">
|
||||
<a-input v-model:value="reason" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label=" ">
|
||||
<a-space wrap>
|
||||
<a-button :loading="busy" @click="reconcile">ثبت مغایرت</a-button>
|
||||
<a-button :loading="busy" @click="adjust('in')">اصلاح ورود</a-button>
|
||||
<a-button :loading="busy" @click="adjust('out')">اصلاح خروج</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">گردش حساب</a-divider>
|
||||
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: true }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -20,13 +20,16 @@ import {
|
||||
Statistic as AStatistic,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
@@ -82,14 +85,19 @@ async function load() {
|
||||
accounts.value = data;
|
||||
}
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/accounts', {
|
||||
...form.value,
|
||||
openingBalance: form.value.openingBalance || 0,
|
||||
openingBalance,
|
||||
openingDate: form.value.openingDate || undefined,
|
||||
});
|
||||
openForm.value = false;
|
||||
@@ -117,11 +125,20 @@ async function sendTransfer() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(transfer.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ انتقال معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await api.post('/treasury/transfers', {
|
||||
...transfer.value,
|
||||
fromAccountId: transfer.value.fromAccountId,
|
||||
toAccountId: transfer.value.toAccountId,
|
||||
amount,
|
||||
memo: transfer.value.memo || undefined,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
transferOpen.value = false;
|
||||
transfer.value = { fromAccountId: '', toAccountId: '', amount: '', memo: '' };
|
||||
ok.value = 'انتقال ثبت شد و در درآمد یا هزینه نیامده است.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -139,17 +156,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حسابها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
@@ -192,15 +199,24 @@ onMounted(() => {
|
||||
|
||||
<template v-if="openForm">
|
||||
<a-divider orientation="right">حساب جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createAccount">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createAccount"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام حساب" name="name">
|
||||
<a-form-item
|
||||
label="نام حساب"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام حساب الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="type">
|
||||
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.type" style="width: 100%">
|
||||
<a-select-option value="cash">صندوق نقدی</a-select-option>
|
||||
<a-select-option value="bank">حساب بانکی</a-select-option>
|
||||
@@ -225,12 +241,12 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="موجودی اولیه" name="openingBalance">
|
||||
<a-input v-model:value="form.openingBalance" />
|
||||
<MoneyInput v-model="form.openingBalance" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع" name="openingDate">
|
||||
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
|
||||
<JalaliDateInput v-model="form.openingDate" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
@@ -245,12 +261,25 @@ onMounted(() => {
|
||||
|
||||
<template v-if="transferOpen">
|
||||
<a-divider orientation="right">انتقال بین حسابها</a-divider>
|
||||
<a-form layout="vertical" @finish="sendTransfer">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="transfer"
|
||||
@finish="sendTransfer"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="از حساب" name="fromAccountId">
|
||||
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-form-item
|
||||
label="از حساب"
|
||||
name="fromAccountId"
|
||||
:rules="[{ required: true, message: 'حساب مبدأ الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="transfer.fromAccountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
>
|
||||
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -258,18 +287,34 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="به حساب" name="toAccountId">
|
||||
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
|
||||
<a-form-item
|
||||
label="به حساب"
|
||||
name="toAccountId"
|
||||
:rules="[{ required: true, message: 'حساب مقصد الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="transfer.toAccountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in active"
|
||||
:key="`to-${account.id}`"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="transfer.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="transfer.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
@@ -309,6 +354,5 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const { Paragraph: ATypographyParagraph } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
action: string;
|
||||
@@ -32,26 +42,31 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="سوابق تغییرات مالی" description="تسویه سند و نشستن وجه در صندوق، با مقدار ثبتشده." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="!error && !rows.length" class="muted">سابقهای نیست.</p>
|
||||
<article v-for="row in rows" :key="row.id">
|
||||
<header>
|
||||
<strong>{{ actionLabel[row.action] ?? row.action }}</strong>
|
||||
<time>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</time>
|
||||
</header>
|
||||
<pre>{{ JSON.stringify(row.after ?? row.before, null, 2) }}</pre>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 0.8rem; }
|
||||
article { background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
header { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
time, .muted { color: #64748b; }
|
||||
pre { margin: 0.45rem 0 0; white-space: pre-wrap; font: inherit; color: #334155; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
<a-card :bordered="false">
|
||||
<a-empty v-if="!error && !rows.length" description="سابقهای نیست." />
|
||||
<a-list v-else item-layout="vertical" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ actionLabel[item.action] ?? item.action }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{ formatJalaliNumeric(new Date(item.createdAt)) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<a-typography-paragraph
|
||||
:copyable="true"
|
||||
:style="{ marginBottom: 0, whiteSpace: 'pre-wrap', fontFamily: 'inherit' }"
|
||||
>
|
||||
{{ JSON.stringify(item.after ?? item.before, null, 2) }}
|
||||
</a-typography-paragraph>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -16,20 +16,16 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const name = ref('');
|
||||
const kind = ref<'income' | 'expense'>('expense');
|
||||
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
|
||||
const error = ref('');
|
||||
|
||||
async function load() {
|
||||
@@ -40,8 +36,11 @@ async function load() {
|
||||
async function add() {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/treasury/categories', { name: name.value, kind: kind.value });
|
||||
name.value = '';
|
||||
await api.post('/treasury/categories', {
|
||||
name: form.value.name,
|
||||
kind: form.value.kind,
|
||||
});
|
||||
form.value.name = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
@@ -56,31 +55,25 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">دستهبندیهای مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دستههای پیشفرض با اولین بازدید ساخته میشوند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<a-form layout="vertical" @finish="add">
|
||||
<a-form layout="vertical" :model="form" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام" name="name">
|
||||
<a-input v-model:value="name" />
|
||||
<a-form-item
|
||||
label="نام"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-select v-model:value="kind" style="width: 100%">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
</a-select>
|
||||
@@ -111,6 +104,5 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
@@ -47,7 +49,6 @@ const form = ref({
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const title = 'پرداختها و هزینهها';
|
||||
const settleLabel = 'مبلغ پرداختشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'پرداختنشده',
|
||||
@@ -81,19 +82,29 @@ async function load() {
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
@@ -136,43 +147,50 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">سند جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createDoc">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
|
||||
<a-select-option value="">بدون دسته</a-select-option>
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
@@ -186,8 +204,12 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
|
||||
<a-select-option value="">انتخاب حساب</a-select-option>
|
||||
<a-select
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -256,6 +278,5 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
@@ -46,17 +45,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<template v-if="data">
|
||||
@@ -157,6 +146,5 @@ onMounted(async () => {
|
||||
</a-list>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
Space as ASpace,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
message,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowLeftOutlined,
|
||||
BankOutlined,
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
@@ -48,10 +48,11 @@ import type {
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
@@ -91,7 +92,6 @@ function emptyForm() {
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const pageTitle = 'دریافتها و درآمدها';
|
||||
const settleLabel = 'مبلغ دریافتشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'دریافتنشده',
|
||||
@@ -122,11 +122,15 @@ const formTabOptions = [
|
||||
];
|
||||
|
||||
const remainingAmount = computed(() => {
|
||||
const total = Number(form.value.amount) || 0;
|
||||
const settled = Number(form.value.settleAmount) || 0;
|
||||
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
|
||||
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
return Math.max(0, total - settled);
|
||||
});
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -158,14 +162,20 @@ async function createDoc() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
@@ -243,18 +253,6 @@ onMounted(() => {
|
||||
<!-- ─── Mobile app experience ─── -->
|
||||
<template v-if="isMobile">
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<div>
|
||||
<a-flex align="center" :gap="token.marginXS">
|
||||
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
|
||||
ثبت دریافت جدید
|
||||
</a-typography-title>
|
||||
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :span="12">
|
||||
<a-card
|
||||
@@ -418,14 +416,7 @@ onMounted(() => {
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.amount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
<MoneyInput v-model="form.amount" :decimals="0" placeholder="۰" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
@@ -434,14 +425,7 @@ onMounted(() => {
|
||||
name="settleAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.settleAmount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -596,35 +580,39 @@ onMounted(() => {
|
||||
<!-- ─── Desktop (unchanged interaction model) ─── -->
|
||||
<template v-else>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">سند جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createDoc">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createDoc"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { Flex as AFlex, Typography, theme } from 'ant-design-vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { provideTreasuryPageChrome } from './treasuryPageChrome';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const route = useRoute();
|
||||
const chrome = provideTreasuryPageChrome();
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
chrome.clear();
|
||||
const scroller = document.querySelector('main.content');
|
||||
if (scroller instanceof HTMLElement) scroller.scrollTop = 0;
|
||||
},
|
||||
);
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
chrome.override.value.title ??
|
||||
(typeof route.meta.treasuryTitle === 'string' ? route.meta.treasuryTitle : 'مدیریت مالی'),
|
||||
);
|
||||
|
||||
const description = computed(
|
||||
() =>
|
||||
chrome.override.value.description ??
|
||||
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
|
||||
);
|
||||
|
||||
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */
|
||||
const headerStyle = computed(() => ({
|
||||
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }">
|
||||
<div :style="headerStyle">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text
|
||||
type="secondary"
|
||||
:ellipsis="description ? { tooltip: description } : false"
|
||||
:style="{ display: 'block', margin: 0 }"
|
||||
>
|
||||
{{ description || '\u00a0' }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<RouterView />
|
||||
</a-flex>
|
||||
</template>
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
@@ -41,8 +43,16 @@ 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: '' });
|
||||
const saving = ref(false);
|
||||
const form = ref({
|
||||
userId: undefined as string | undefined,
|
||||
period: '',
|
||||
baseAmount: '',
|
||||
benefits: '',
|
||||
bonus: '',
|
||||
deductions: '',
|
||||
});
|
||||
const pay = ref({ id: '', amount: '', accountId: undefined as string | undefined });
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
@@ -50,6 +60,10 @@ function apiMessage(e: unknown) {
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
|
||||
}
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [payroll, people, acc] = await Promise.all([
|
||||
api.get<PayrollRow[]>('/treasury/payroll'),
|
||||
@@ -63,33 +77,66 @@ async function load() {
|
||||
|
||||
async function createRow() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/treasury/payroll', form.value);
|
||||
const baseAmount = parseMoneyNumber(String(form.value.baseAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/payroll', {
|
||||
userId: form.value.userId,
|
||||
period: form.value.period,
|
||||
baseAmount,
|
||||
benefits: parseMoneyNumber(String(form.value.benefits ?? ''), 0) ?? 0,
|
||||
bonus: parseMoneyNumber(String(form.value.bonus ?? ''), 0) ?? 0,
|
||||
deductions: parseMoneyNumber(String(form.value.deductions ?? ''), 0) ?? 0,
|
||||
});
|
||||
form.value = {
|
||||
userId: undefined,
|
||||
period: '',
|
||||
baseAmount: '',
|
||||
benefits: '',
|
||||
bonus: '',
|
||||
deductions: '',
|
||||
};
|
||||
ok.value = 'حقوق ثبت شد و تا پرداخت، موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function payRow() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(pay.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ پرداخت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await api.post(`/treasury/payroll/${pay.value.id}/pay`, {
|
||||
amount: pay.value.amount,
|
||||
amount,
|
||||
accountId: pay.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
ok.value = 'پرداخت حقوق از حساب ثبت شد.';
|
||||
pay.value = { id: '', amount: '', accountId: '' };
|
||||
pay.value = { id: '', amount: '', accountId: undefined };
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPay(row: PayrollRow) {
|
||||
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
|
||||
pay.value = {
|
||||
id: row.id,
|
||||
amount: row.remaining,
|
||||
accountId: accounts.value[0]?.id,
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -100,26 +147,31 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
|
||||
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را میبینند.</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">ثبت دوره</a-divider>
|
||||
<a-form layout="vertical" @finish="createRow">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createRow"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="پرسنل" name="userId">
|
||||
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-form-item
|
||||
label="پرسنل"
|
||||
name="userId"
|
||||
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="form.userId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
>
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.fullName }}
|
||||
</a-select-option>
|
||||
@@ -127,54 +179,84 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="period">
|
||||
<a-form-item
|
||||
label="دوره"
|
||||
name="period"
|
||||
:rules="[{ required: true, message: 'دوره الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پایه" name="baseAmount">
|
||||
<a-input v-model:value="form.baseAmount" />
|
||||
<a-form-item
|
||||
label="پایه"
|
||||
name="baseAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ پایه الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.baseAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مزایا" name="benefits">
|
||||
<a-input v-model:value="form.benefits" />
|
||||
<MoneyInput v-model="form.benefits" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پاداش" name="bonus">
|
||||
<a-input v-model:value="form.bonus" />
|
||||
<MoneyInput v-model="form.bonus" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="کسورات" name="deductions">
|
||||
<a-input v-model:value="form.deductions" />
|
||||
<MoneyInput v-model="form.deductions" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حقوق</a-button>
|
||||
</a-form>
|
||||
|
||||
<template v-if="pay.id">
|
||||
<a-divider orientation="right">پرداخت حقوق</a-divider>
|
||||
<a-form layout="vertical" @finish="payRow">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="pay"
|
||||
@finish="payRow"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="pay.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="pay.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="pay.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
<a-form-item
|
||||
label="حساب"
|
||||
name="accountId"
|
||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="pay.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت پرداخت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
@@ -203,6 +285,5 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -19,15 +19,18 @@ import {
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type InstallmentRow = FinancialReceivableDto['installments'][number];
|
||||
@@ -109,19 +112,35 @@ async function downloadExcel() {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
async function createContract() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
const totalAmount = parseMoneyNumber(String(form.value.totalAmount ?? ''), 0);
|
||||
if (totalAmount == null || totalAmount <= 0) {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const prepaidAmount = parseMoneyNumber(String(form.value.prepaidAmount ?? ''), 0) ?? 0;
|
||||
const installments = custom.value
|
||||
? customLines.value.map((line) => ({
|
||||
amount: parseMoneyNumber(String(line.amount ?? ''), 0) ?? 0,
|
||||
dueDate: line.dueDate,
|
||||
}))
|
||||
: undefined;
|
||||
await api.post('/treasury/receivables', {
|
||||
title: form.value.title,
|
||||
partyName: form.value.partyName || null,
|
||||
totalAmount: form.value.totalAmount,
|
||||
prepaidAmount: form.value.prepaidAmount || 0,
|
||||
totalAmount,
|
||||
prepaidAmount,
|
||||
accountId: form.value.accountId || null,
|
||||
...(custom.value
|
||||
? { installments: customLines.value }
|
||||
? { installments }
|
||||
: {
|
||||
count: Number(form.value.count),
|
||||
interval: form.value.interval,
|
||||
@@ -145,8 +164,13 @@ async function collectInstallment() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const amount = parseMoneyNumber(String(collect.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ دریافت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await api.post(`/treasury/installments/${collect.value.installmentId}/collect`, {
|
||||
amount: collect.value.amount,
|
||||
amount,
|
||||
accountId: collect.value.accountId,
|
||||
clientKey: crypto.randomUUID(),
|
||||
});
|
||||
@@ -177,26 +201,25 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">قرارداد جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createContract">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createContract"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
@@ -206,19 +229,27 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="totalAmount">
|
||||
<a-input v-model:value="form.totalAmount" />
|
||||
<a-form-item
|
||||
label="مبلغ کل"
|
||||
name="totalAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ کل الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.totalAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیشپرداخت" name="prepaidAmount">
|
||||
<a-input v-model:value="form.prepaidAmount" />
|
||||
<MoneyInput v-model="form.prepaidAmount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب"
|
||||
>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
@@ -240,7 +271,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
|
||||
<JalaliDateInput v-model="form.startDate" :disabled="custom" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24">
|
||||
@@ -254,12 +285,12 @@ onMounted(() => {
|
||||
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="`مبلغ قسط ${index + 1}`">
|
||||
<a-input v-model:value="line.amount" />
|
||||
<MoneyInput v-model="line.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سررسید">
|
||||
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
|
||||
<JalaliDateInput v-model="line.dueDate" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -276,17 +307,39 @@ onMounted(() => {
|
||||
|
||||
<template v-if="collect.installmentId">
|
||||
<a-divider orientation="right">دریافت قسط</a-divider>
|
||||
<a-form layout="vertical" @finish="collectInstallment">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="collect"
|
||||
@finish="collectInstallment"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="collect.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="collect.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب مقصد" name="accountId">
|
||||
<a-select v-model:value="collect.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
<a-form-item
|
||||
label="حساب مقصد"
|
||||
name="accountId"
|
||||
:rules="[{ required: true, message: 'حساب الزامی است' }]"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="collect.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
@@ -337,6 +390,5 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -16,21 +16,33 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
|
||||
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 saving = ref(false);
|
||||
const form = ref({ title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' });
|
||||
|
||||
async function load() {
|
||||
@@ -38,16 +50,34 @@ async function load() {
|
||||
rows.value = data;
|
||||
}
|
||||
|
||||
function onFinishFailed() {
|
||||
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
|
||||
}
|
||||
|
||||
async function createItem() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/treasury/recurring', form.value);
|
||||
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
|
||||
if (amount == null || amount <= 0) {
|
||||
error.value = 'مبلغ معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await api.post('/treasury/recurring', {
|
||||
...form.value,
|
||||
amount,
|
||||
startDate: form.value.startDate || undefined,
|
||||
});
|
||||
form.value = { title: '', kind: 'expense', amount: '', interval: 'monthly', startDate: '' };
|
||||
ok.value = 'الگو ثبت شد. در سررسید فقط سند باز ساخته میشود.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,31 +87,30 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">الگوی جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createItem">
|
||||
<a-form
|
||||
layout="vertical"
|
||||
:model="form"
|
||||
@finish="createItem"
|
||||
@finishFailed="onFinishFailed"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-form-item
|
||||
label="عنوان"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
@@ -89,12 +118,16 @@ onMounted(() => {
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
<a-form-item
|
||||
label="مبلغ"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ الزامی است' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.amount" :decimals="0" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
|
||||
<a-select v-model:value="form.interval" style="width: 100%">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
@@ -104,11 +137,11 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
|
||||
<JalaliDateInput v-model="form.startDate" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت الگو</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست الگوها</a-divider>
|
||||
@@ -131,6 +164,5 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
@@ -100,23 +99,14 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="flex-end">
|
||||
<a-button @click="download">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
@@ -179,6 +169,5 @@ onMounted(() => {
|
||||
</a-list>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { inject, provide, ref, type InjectionKey, type Ref } from 'vue';
|
||||
|
||||
export type TreasuryChromeOverride = {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
type TreasuryChromeApi = {
|
||||
override: Ref<TreasuryChromeOverride>;
|
||||
set: (next: TreasuryChromeOverride) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const KEY: InjectionKey<TreasuryChromeApi> = Symbol('treasuryPageChrome');
|
||||
|
||||
export function provideTreasuryPageChrome(): TreasuryChromeApi {
|
||||
const override = ref<TreasuryChromeOverride>({ title: null, description: null });
|
||||
const api: TreasuryChromeApi = {
|
||||
override,
|
||||
set(next) {
|
||||
override.value = next;
|
||||
},
|
||||
clear() {
|
||||
override.value = { title: null, description: null };
|
||||
},
|
||||
};
|
||||
provide(KEY, api);
|
||||
return api;
|
||||
}
|
||||
|
||||
export function useTreasuryPageChrome(): TreasuryChromeApi | null {
|
||||
return inject(KEY, null);
|
||||
}
|
||||
Reference in New Issue
Block a user