feat(treasury): show who still owes on the finance pages
Partial receipts and payments stay tied to a customer, company, or user, and the open balance is listed on the income and expense screens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Table as ATable } from 'ant-design-vue';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: TreasuryPartyBalanceDto[];
|
||||
settledLabel: string;
|
||||
}>();
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
|
||||
{
|
||||
title: 'نوع',
|
||||
key: 'partyKind',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
|
||||
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
|
||||
},
|
||||
{
|
||||
title: 'مبلغ کل',
|
||||
key: 'amount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
|
||||
},
|
||||
{
|
||||
title: props.settledLabel,
|
||||
key: 'settledAmount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'remaining',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||
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 { api } from '@/lib/api';
|
||||
|
||||
const VNodes = defineComponent({
|
||||
props: { vnodes: { type: Object, required: true } },
|
||||
render() {
|
||||
return this.vnodes;
|
||||
},
|
||||
});
|
||||
|
||||
const ASelectOption = ASelect.Option;
|
||||
const ASelectOptGroup = ASelect.OptGroup;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||
const partyId = defineModel<string | undefined>('partyId');
|
||||
|
||||
type Choice = { id: string; name: string };
|
||||
|
||||
const people = ref<Choice[]>([]);
|
||||
const companies = ref<Choice[]>([]);
|
||||
const users = ref<Choice[]>([]);
|
||||
const labels = ref<Record<string, string>>({});
|
||||
const loading = 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';
|
||||
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
|
||||
if (kind.value !== group || !partyId.value) return list;
|
||||
if (list.some((row) => row.id === partyId.value)) return list;
|
||||
const name = labels.value[`${group}:${partyId.value}`] || 'انتخابشده';
|
||||
return [{ id: partyId.value, name }, ...list];
|
||||
}
|
||||
|
||||
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
|
||||
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
|
||||
const userOptions = computed(() => withCurrent(users.value, 'user'));
|
||||
|
||||
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 || 'ثبت ناموفق بود.';
|
||||
}
|
||||
|
||||
let timer = 0;
|
||||
function onSearch(value: string) {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void load(value);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function load(q = '') {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
|
||||
params: { q: q.trim() || undefined },
|
||||
});
|
||||
people.value = data.people;
|
||||
companies.value = data.companies;
|
||||
users.value = data.users;
|
||||
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
|
||||
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
|
||||
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
|
||||
} catch (e) {
|
||||
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(value: unknown) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
kind.value = undefined;
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (value === 'other') {
|
||||
kind.value = 'other';
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
const splitAt = value.indexOf(':');
|
||||
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
|
||||
const id = value.slice(splitAt + 1);
|
||||
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
|
||||
kind.value = nextKind;
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-select
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:loading="loading"
|
||||
:value="selectValue"
|
||||
placeholder="مشتری، شرکت، کاربر یا سایر"
|
||||
style="width: 100%"
|
||||
@search="onSearch"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-select-option value="other" label="سایر">سایر</a-select-option>
|
||||
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||
<a-select-option
|
||||
v-for="row in peopleOptions"
|
||||
:key="`person:${row.id}`"
|
||||
:value="`person:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="companyOptions.length" label="شرکتها">
|
||||
<a-select-option
|
||||
v-for="row in companyOptions"
|
||||
:key="`company:${row.id}`"
|
||||
:value="`company:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="userOptions.length" label="کاربران">
|
||||
<a-select-option
|
||||
v-for="row in userOptions"
|
||||
:key="`user:${row.id}`"
|
||||
:value="`user:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<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-flex>
|
||||
</template>
|
||||
</a-select>
|
||||
<a-typography-text v-if="kind === 'other'" type="secondary">
|
||||
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمیماند.
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
|
||||
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی میکند تا کاربر خودش آن را عوض کند. */
|
||||
export function useSettleDefault(
|
||||
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
|
||||
) {
|
||||
const dirty = ref(false);
|
||||
let mirroring = false;
|
||||
|
||||
function locked() {
|
||||
return form.value.partyKind === 'other';
|
||||
}
|
||||
|
||||
function copy() {
|
||||
mirroring = true;
|
||||
form.value.settleAmount = form.value.amount;
|
||||
mirroring = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value.amount,
|
||||
() => {
|
||||
if (locked() || !dirty.value) copy();
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.settleAmount,
|
||||
() => {
|
||||
if (!mirroring && !locked()) dirty.value = true;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.partyKind,
|
||||
(kind) => {
|
||||
if (kind === 'other') {
|
||||
dirty.value = false;
|
||||
copy();
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
return {
|
||||
reset() {
|
||||
dirty.value = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user