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