Files
CRM_Montzar/apps/web/src/views/treasury/TreasuryAccountsView.vue
T
MahdiandCursor 1ccbefa7a3 fix(treasury): scope finance access and hide it when the module is off
Accounts can be edited, including the opening balance, and finance sections and accounts can be granted on a role or on one user. The finance menu stays hidden when the organization module is disabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:02:31 +03:30

448 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
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,
List as AList,
Row as ARow,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
Tag as ATag,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const error = ref('');
const ok = ref('');
const openForm = ref(false);
const editingId = ref<string | null>(null);
const responsibleKey = ref<string | undefined>(undefined);
const saving = ref(false);
const transferOpen = ref(false);
const emptyForm = () => ({
name: '',
type: 'cash' as FinancialAccountType,
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
});
const form = ref(emptyForm());
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 || 'عملیات ناموفق بود.';
}
const responsibleOptions = computed(() => {
const rows = users.value.map((user) => ({ id: user.id, fullName: user.fullName }));
const current = form.value.responsibleName.trim();
if (current && !rows.some((row) => row.fullName === current)) {
rows.unshift({ id: `name:${current}`, fullName: current });
}
return rows;
});
async function load() {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data;
try {
const usersRes = await api.get<OrgUserDto[]>('/users');
users.value = usersRes.data;
} catch {
users.value = [];
}
}
function resetForm() {
editingId.value = null;
responsibleKey.value = undefined;
form.value = emptyForm();
}
function startEdit(item: FinancialAccountDto) {
editingId.value = item.id;
form.value = {
name: item.name,
type: item.type,
bankName: item.bankName ?? '',
accountRef: item.accountRef ?? '',
responsibleName: item.responsibleName ?? '',
openingBalance: item.openingBalance,
openingDate: item.openingDate.slice(0, 10),
notes: item.notes ?? '',
};
const match = users.value.find((user) => user.fullName === item.responsibleName);
responsibleKey.value = match?.id ?? (item.responsibleName ? `name:${item.responsibleName}` : undefined);
openForm.value = true;
}
function responsibleNameFromKey() {
const key = responsibleKey.value;
if (!key) return null;
if (key.startsWith('name:')) return key.slice(5);
return users.value.find((user) => user.id === key)?.fullName ?? null;
}
function onFinishFailed() {
message.warning('لطفاً فیلدهای الزامی را کامل کنید.');
}
async function createAccount() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
const body = {
...form.value,
responsibleName: responsibleNameFromKey(),
openingBalance,
openingDate: form.value.openingDate || undefined,
};
if (editingId.value) {
await api.patch(`/treasury/accounts/${editingId.value}`, body);
ok.value = 'حساب به‌روز شد. موجودی از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.';
} else {
await api.post('/treasury/accounts', body);
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
}
openForm.value = false;
resetForm();
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function sendTransfer() {
saving.value = true;
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', {
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) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حساب‌ها ناموفق بود.';
});
});
</script>
<template>
<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-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مجموع موجودی حساب‌ها" :value="toman(total)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="ورودی این فهرست"
:value="toman(periodIn)"
:value-style="{ color: 'var(--ant-color-success)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="خروجی این فهرست"
:value="toman(periodOut)"
:value-style="{ color: 'var(--ant-color-error)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="حساب‌های فعال" :value="`${active.length} حساب`" />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-space>
<a-button
type="primary"
@click="
openForm = !openForm;
if (!openForm) resetForm();
"
>افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
<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"
: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" :rules="[{ required: true }]">
<a-select
v-model:value="form.type"
show-search
option-filter-prop="label"
style="width: 100%"
>
<a-select-option value="cash" label="صندوق نقدی">صندوق نقدی</a-select-option>
<a-select-option value="bank" label="حساب بانکی">حساب بانکی</a-select-option>
<a-select-option value="petty" label="تنخواه">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
<a-form-item label="نام بانک" name="bankName">
<a-input v-model:value="form.bankName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شماره یا شناسه" name="accountRef">
<a-input v-model:value="form.accountRef" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول">
<a-select
v-model:value="responsibleKey"
show-search
allow-clear
option-filter-prop="label"
style="width: 100%"
placeholder="انتخاب کاربر"
>
<a-select-option
v-for="user in responsibleOptions"
:key="user.id"
:value="user.id"
:label="user.fullName"
>
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="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">
<JalaliDateInput v-model="form.openingDate" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">
{{ editingId ? 'ذخیره تغییرات' : 'ثبت حساب' }}
</a-button>
</a-form>
</template>
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<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"
:rules="[{ required: true, message: 'حساب مبدأ الزامی است' }]"
>
<a-select
v-model:value="transfer.fromAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option
v-for="account in active"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="به حساب"
name="toAccountId"
:rules="[{ required: true, message: 'حساب مقصد الزامی است' }]"
>
<a-select
v-model:value="transfer.toAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option
v-for="account in active"
:key="`to-${account.id}`"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<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">
<a-form-item label="شرح" name="memo">
<a-input v-model:value="transfer.memo" />
</a-form-item>
</a-col>
</a-row>
<a-typography-text type="secondary">
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.
</a-typography-text>
<div>
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
</div>
</a-form>
</template>
<a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
<template #title>
{{ item.name }}
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }}
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
· موجودی اولیه {{ toman(item.openingBalance) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>