feat(treasury): number documents and manage categories on their tab
Save a system tracking number on each income and expense, reuse the full contact dialogs for a new party, and create, rename, or delete categories only from the categories tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -43,6 +43,20 @@ export class TreasuryController {
|
||||
return this.treasury.createCategory(user, body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; isActive?: boolean },
|
||||
) {
|
||||
return this.treasury.updateCategory(user, id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.treasury.deleteCategory(user, id);
|
||||
}
|
||||
|
||||
@Get('accounts')
|
||||
accounts(
|
||||
@CurrentUser() user: Actor,
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from '@mento/shared';
|
||||
import { CompaniesService } from '../contacts/companies.service';
|
||||
import { PeopleService } from '../contacts/people.service';
|
||||
import { dateToJalali } from '../common/jalali';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { StorageQuotaService } from '../billing/storage-quota.service';
|
||||
@@ -119,19 +120,68 @@ export class TreasuryService {
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const name = input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
name,
|
||||
kind: input.kind,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
input: { name?: string; isActive?: boolean },
|
||||
): Promise<FinancialCategoryDto> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
const name = input.name == null ? current.name : input.name.trim();
|
||||
if (name.length < 2) throw new BadRequestException('نام دستهبندی کوتاه است.');
|
||||
try {
|
||||
const row = await this.prisma.financialCategory.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
name,
|
||||
...(input.isActive == null ? {} : { isActive: input.isActive }),
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind as FinancialCategoryDto['kind'],
|
||||
isActive: row.isActive,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('این نام برای همین نوع دسته قبلاً ثبت شده است.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCategory(actor: Actor, id: string): Promise<{ ok: true }> {
|
||||
const current = await this.prisma.financialCategory.findFirst({
|
||||
where: { id, organizationId: actor.organizationId },
|
||||
});
|
||||
if (!current) throw new NotFoundException('دستهبندی پیدا نشد.');
|
||||
await this.prisma.financialCategory.delete({ where: { id: current.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listAccounts(
|
||||
@@ -511,6 +561,13 @@ export class TreasuryService {
|
||||
}
|
||||
const bookedAt = startOfDay(input.bookedAt) ?? new Date();
|
||||
const doc = await this.prisma.$transaction(async (tx) => {
|
||||
const reference = await this.nextDocumentReference(
|
||||
tx,
|
||||
actor.organizationId,
|
||||
input.kind,
|
||||
input.bookedAt,
|
||||
bookedAt,
|
||||
);
|
||||
const created = await tx.financialDocument.create({
|
||||
data: {
|
||||
organizationId: actor.organizationId,
|
||||
@@ -528,7 +585,7 @@ export class TreasuryService {
|
||||
accountId: input.accountId || null,
|
||||
method: input.method || null,
|
||||
status: settle.equals(0) ? 'open' : settle.equals(amount) ? 'settled' : 'partial',
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
notes: input.notes?.trim() || null,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
@@ -544,7 +601,7 @@ export class TreasuryService {
|
||||
kind: input.kind === 'income' ? 'receipt' : 'payment',
|
||||
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
|
||||
memo: title,
|
||||
reference: input.reference?.trim() || null,
|
||||
reference,
|
||||
createdById: actor.userId,
|
||||
},
|
||||
});
|
||||
@@ -557,6 +614,32 @@ export class TreasuryService {
|
||||
return this.toDocumentDto(doc);
|
||||
}
|
||||
|
||||
/** شماره پیگیری روزانه: DR-14050813-0001 برای درآمد و PD-… برای هزینه. */
|
||||
private async nextDocumentReference(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
kind: 'income' | 'expense',
|
||||
bookedAtInput: string | undefined,
|
||||
bookedAt: Date,
|
||||
) {
|
||||
const match = bookedAtInput?.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
const when = match
|
||||
? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
||||
: bookedAt;
|
||||
const { jy, jm, jd } = dateToJalali(when);
|
||||
const ymd = `${jy}${String(jm).padStart(2, '0')}${String(jd).padStart(2, '0')}`;
|
||||
const prefix = `${kind === 'income' ? 'DR' : 'PD'}-${ymd}-`;
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`finref:${organizationId}:${prefix}`})::bigint)`;
|
||||
const latest = await tx.financialDocument.findFirst({
|
||||
where: { organizationId, reference: { startsWith: prefix } },
|
||||
orderBy: { reference: 'desc' },
|
||||
select: { reference: true },
|
||||
});
|
||||
const current = Number(latest?.reference?.slice(prefix.length));
|
||||
const next = Number.isFinite(current) && current > 0 ? current + 1 : 1;
|
||||
return `${prefix}${String(next).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async searchParties(actor: Actor, q?: string): Promise<TreasuryPartySearchDto> {
|
||||
const term = q?.trim() ?? '';
|
||||
const contains = term ? { contains: term, mode: 'insensitive' as const } : undefined;
|
||||
|
||||
@@ -22,7 +22,7 @@ const links = computed(() => {
|
||||
{ value: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||
{ value: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||
{ value: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||
{ value: '/finance/categories', label: 'دستهبندیها', section: 'treasury_categories' },
|
||||
{ value: '/finance/categories', label: 'دستهبندی', section: 'treasury_categories' },
|
||||
];
|
||||
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||
});
|
||||
|
||||
@@ -4,16 +4,15 @@ import axios from 'axios';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { FinancialPartyKind, TreasuryPartySearchDto } from '@mento/shared';
|
||||
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
|
||||
const VNodes = defineComponent({
|
||||
props: { vnodes: { type: Object, required: true } },
|
||||
@@ -36,12 +35,9 @@ const companies = ref<Choice[]>([]);
|
||||
const users = ref<Choice[]>([]);
|
||||
const labels = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const selectOpen = ref(false);
|
||||
const personOpen = ref(false);
|
||||
const companyOpen = ref(false);
|
||||
const personSaving = ref(false);
|
||||
const companySaving = ref(false);
|
||||
const personForm = ref({ firstName: '', lastName: '' });
|
||||
const companyName = ref('');
|
||||
|
||||
const selectValue = computed(() => {
|
||||
if (kind.value === 'other') return 'other';
|
||||
@@ -112,57 +108,37 @@ function onChange(value: unknown) {
|
||||
partyId.value = id;
|
||||
}
|
||||
|
||||
function savePerson() {
|
||||
const firstName = personForm.value.firstName.trim();
|
||||
const lastName = personForm.value.lastName.trim();
|
||||
if (`${firstName} ${lastName}`.trim().length < 2) {
|
||||
message.warning('نام شخص را وارد کنید.');
|
||||
return Promise.reject(new Error('invalid'));
|
||||
}
|
||||
personSaving.value = true;
|
||||
return api
|
||||
.post<{ id: string; name: string }>('/treasury/parties/person', { firstName, lastName })
|
||||
.then(({ data }) => {
|
||||
people.value = [{ id: data.id, name: data.name }, ...people.value.filter((row) => row.id !== data.id)];
|
||||
labels.value[`person:${data.id}`] = data.name;
|
||||
kind.value = 'person';
|
||||
partyId.value = data.id;
|
||||
personForm.value = { firstName: '', lastName: '' };
|
||||
message.success('شخص در مشتریان ثبت شد.');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e)) message.error(apiMessage(e));
|
||||
return Promise.reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
personSaving.value = false;
|
||||
});
|
||||
function onDropdown(open: boolean) {
|
||||
selectOpen.value = open;
|
||||
}
|
||||
|
||||
function saveCompany() {
|
||||
const name = companyName.value.trim();
|
||||
if (name.length < 2) {
|
||||
message.warning('نام شرکت را وارد کنید.');
|
||||
return Promise.reject(new Error('invalid'));
|
||||
}
|
||||
companySaving.value = true;
|
||||
return api
|
||||
.post<{ id: string; name: string }>('/treasury/parties/company', { name })
|
||||
.then(({ data }) => {
|
||||
companies.value = [{ id: data.id, name: data.name }, ...companies.value.filter((row) => row.id !== data.id)];
|
||||
labels.value[`company:${data.id}`] = data.name;
|
||||
kind.value = 'company';
|
||||
partyId.value = data.id;
|
||||
companyName.value = '';
|
||||
message.success('شرکت در شرکتها ثبت شد.');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e)) message.error(apiMessage(e));
|
||||
return Promise.reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
companySaving.value = false;
|
||||
});
|
||||
function openPersonForm() {
|
||||
selectOpen.value = false;
|
||||
personOpen.value = true;
|
||||
}
|
||||
|
||||
function openCompanyForm() {
|
||||
selectOpen.value = false;
|
||||
companyOpen.value = true;
|
||||
}
|
||||
|
||||
function onPersonCreated(person?: PersonDto) {
|
||||
personOpen.value = false;
|
||||
if (!person?.id) return;
|
||||
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
|
||||
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
|
||||
labels.value[`person:${person.id}`] = name;
|
||||
kind.value = 'person';
|
||||
partyId.value = person.id;
|
||||
}
|
||||
|
||||
function onCompanyCreated(company?: CompanyDto) {
|
||||
companyOpen.value = false;
|
||||
if (!company?.id) return;
|
||||
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
|
||||
labels.value[`company:${company.id}`] = company.name;
|
||||
kind.value = 'company';
|
||||
partyId.value = company.id;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -173,7 +149,9 @@ onMounted(() => {
|
||||
<template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-select
|
||||
:open="selectOpen"
|
||||
show-search
|
||||
@dropdownVisibleChange="onDropdown"
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:loading="loading"
|
||||
@@ -217,8 +195,8 @@ onMounted(() => {
|
||||
<template #dropdownRender="{ menuNode }">
|
||||
<v-nodes :vnodes="menuNode" />
|
||||
<a-flex @mousedown.prevent>
|
||||
<a-button type="link" @click="personOpen = true">شخص جدید</a-button>
|
||||
<a-button type="link" @click="companyOpen = true">شرکت جدید</a-button>
|
||||
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
|
||||
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-select>
|
||||
@@ -227,36 +205,21 @@ onMounted(() => {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-modal
|
||||
v-model:open="personOpen"
|
||||
title="شخص جدید"
|
||||
ok-text="ثبت در مشتریان"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="personSaving"
|
||||
@ok="savePerson"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="personForm.firstName" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام خانوادگی">
|
||||
<a-input v-model:value="personForm.lastName" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
|
||||
<PersonFormView
|
||||
v-if="personOpen"
|
||||
embedded
|
||||
@saved="onPersonCreated"
|
||||
@cancel="personOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="companyOpen"
|
||||
title="شرکت جدید"
|
||||
ok-text="ثبت در شرکتها"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="companySaving"
|
||||
@ok="saveCompany"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام شرکت" required>
|
||||
<a-input v-model:value="companyName" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
|
||||
<CompanyFormView
|
||||
v-if="companyOpen"
|
||||
embedded
|
||||
@saved="onCompanyCreated"
|
||||
@cancel="companyOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
</template>
|
||||
|
||||
@@ -369,8 +369,8 @@ export const router = createRouter({
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'دستهبندیهای مالی',
|
||||
treasuryDescription: 'دستههای پیشفرض با اولین بازدید ساخته میشوند.',
|
||||
treasuryTitle: 'دستهبندی',
|
||||
treasuryDescription: 'دستههای درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -27,7 +27,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>();
|
||||
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -152,7 +152,7 @@ async function submit() {
|
||||
if (isNew.value) {
|
||||
const { data } = await api.post<CompanyDto>('/companies', payload());
|
||||
if (props.embedded) {
|
||||
emit('saved');
|
||||
emit('saved', data);
|
||||
return;
|
||||
}
|
||||
await router.replace({ name: 'company-detail', params: { id: data.id } });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -13,20 +13,38 @@ import {
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Popconfirm as APopconfirm,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
|
||||
const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
|
||||
const error = ref('');
|
||||
const saving = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const edit = ref({ id: '', name: '', isActive: true });
|
||||
|
||||
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
|
||||
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
|
||||
|
||||
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<FinancialCategoryDto[]>('/treasury/categories');
|
||||
@@ -35,6 +53,7 @@ async function load() {
|
||||
|
||||
async function add() {
|
||||
error.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/treasury/categories', {
|
||||
name: form.value.name,
|
||||
@@ -43,66 +62,160 @@ async function add() {
|
||||
form.value.name = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(item: FinancialCategoryDto) {
|
||||
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function remove(item: FinancialCategoryDto) {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/treasury/categories/${item.id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
const name = edit.value.name.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return Promise.reject(new Error('invalid'));
|
||||
}
|
||||
editSaving.value = true;
|
||||
error.value = '';
|
||||
return api
|
||||
.patch(`/treasury/categories/${edit.value.id}`, {
|
||||
name,
|
||||
isActive: edit.value.isActive,
|
||||
})
|
||||
.then(() => load())
|
||||
.catch((e: unknown) => {
|
||||
error.value = apiMessage(e);
|
||||
return Promise.reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
editSaving.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
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="error" type="error" show-icon :message="error" />
|
||||
<a-typography-text type="secondary">
|
||||
دستهها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده میشوند.
|
||||
</a-typography-text>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<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"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">دسته جدید</a-divider>
|
||||
<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"
|
||||
: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" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">درآمد</a-divider>
|
||||
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="دستهای ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<a-divider orientation="right">هزینه</a-divider>
|
||||
<a-empty v-if="!expenseRows.length" description="دسته هزینهای نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
title="ویرایش دسته"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="editSaving"
|
||||
@ok="saveEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="edit.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال در فرمها">
|
||||
<a-switch v-model:checked="edit.isActive" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,6 @@ const form = ref({
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
const settleMirror = useSettleDefault(form);
|
||||
@@ -85,10 +84,11 @@ function apiMessage(e: unknown) {
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
];
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -132,7 +132,7 @@ async function createDoc() {
|
||||
error.value = 'برای سایر، مبلغ پرداختشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
await api.post('/treasury/documents', {
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
@@ -144,7 +144,6 @@ async function createDoc() {
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
settleMirror.reset();
|
||||
@@ -157,10 +156,11 @@ async function createDoc() {
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -265,11 +265,6 @@ onMounted(() => {
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
@@ -285,7 +280,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Grid,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
@@ -34,8 +33,6 @@ import {
|
||||
CreditCardOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
NumberOutlined,
|
||||
PlusOutlined,
|
||||
TagOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -75,9 +72,6 @@ const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const formTab = ref<'main' | 'extra'>('main');
|
||||
const categoryModalOpen = ref(false);
|
||||
const newCategoryName = ref('');
|
||||
const categorySaving = ref(false);
|
||||
const mobileFormRef = ref<FormInstance>();
|
||||
|
||||
function emptyForm() {
|
||||
@@ -90,7 +84,6 @@ function emptyForm() {
|
||||
accountId: undefined as string | undefined,
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
bookedAt: formatLocalDateInput(new Date()),
|
||||
};
|
||||
@@ -146,10 +139,11 @@ function apiMessage(e: unknown) {
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
];
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -199,7 +193,7 @@ async function createDoc() {
|
||||
error.value = 'برای سایر، مبلغ دریافتشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
await api.post('/treasury/documents', {
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
@@ -211,12 +205,13 @@ async function createDoc() {
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
bookedAt: form.value.bookedAt || undefined,
|
||||
});
|
||||
resetForm();
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -245,30 +240,6 @@ async function voidDoc(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategoryQuick() {
|
||||
const name = newCategoryName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return;
|
||||
}
|
||||
categorySaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
|
||||
name,
|
||||
kind,
|
||||
});
|
||||
await load();
|
||||
form.value.categoryId = data.id;
|
||||
newCategoryName.value = '';
|
||||
categoryModalOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
categorySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
@@ -425,21 +396,16 @@ onMounted(() => {
|
||||
<span>دستهبندی درآمد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-flex :gap="8">
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="flex: 1; min-width: 0"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" ghost @click="categoryModalOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
@@ -530,12 +496,6 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-show="formTab === 'extra'">
|
||||
<a-form-item label="شماره پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-textarea
|
||||
v-model:value="form.notes"
|
||||
@@ -556,7 +516,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -709,11 +669,6 @@ onMounted(() => {
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
@@ -733,7 +688,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -768,23 +723,5 @@ onMounted(() => {
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
v-model:open="categoryModalOpen"
|
||||
title="دسته جدید"
|
||||
ok-text="افزودن"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="categorySaving"
|
||||
@ok="createCategoryQuick"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام دسته" required>
|
||||
<a-input
|
||||
v-model:value="newCategoryName"
|
||||
placeholder="مثلاً: فروش خدمات"
|
||||
@press-enter="createCategoryQuick"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -843,7 +843,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
{ key: "treasury_recurring", label: "مالی — تکرارشونده" },
|
||||
{ key: "treasury_reports", label: "مالی — گزارش" },
|
||||
{ key: "treasury_audit", label: "مالی — سوابق" },
|
||||
{ key: "treasury_categories", label: "مالی — دستهبندیها" },
|
||||
{ key: "treasury_categories", label: "مالی — دستهبندی" },
|
||||
{ key: "behavior", label: "شخصیتشناسی و دستیار فروش" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user