feat(treasury): separate receipts from income and fit finance on a phone

A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-10 12:28:27 +03:30
co-authored by Cursor
parent 49459707cb
commit d12d7b91cb
16 changed files with 626 additions and 537 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Segmented as ASegmented } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const scroller = ref<HTMLElement | null>(null);
const links = computed(() => {
const role = auth.user?.role;
@@ -41,6 +42,17 @@ const active = computed(() => {
return '/finance';
});
watch(
active,
async () => {
await nextTick();
scroller.value
?.querySelector('.ant-segmented-item-selected')
?.scrollIntoView({ inline: 'center', block: 'nearest' });
},
{ immediate: true },
);
function onChange(value: string | number) {
const next = String(value);
if (next && next !== route.path) void router.push(next);
@@ -49,6 +61,7 @@ function onChange(value: string | number) {
<template>
<div
ref="scroller"
:style="{
width: '100%',
overflowX: 'auto',
@@ -1,14 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Table as ATable } from 'ant-design-vue';
import {
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { toman } from '@/views/treasury/treasury-format';
import { useTreasuryMobile } from '@/views/treasury/useTreasuryMobile';
const props = defineProps<{
rows: TreasuryPartyBalanceDto[];
settledLabel: string;
}>();
const isMobile = useTreasuryMobile();
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const kindLabel: Record<string, string> = {
person: 'مشتری',
company: 'شرکت',
@@ -39,10 +49,43 @@ const columns = computed(() => [
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
},
]);
function kindOf(record: TreasuryPartyBalanceDto) {
return (record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی';
}
</script>
<template>
<a-list
v-if="isMobile"
:data-source="rows"
:locale="{ emptyText: 'مانده‌ای نیست.' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-flex vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.partyName }}</a-typography-text>
<a-typography-text type="secondary">{{ kindOf(item) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مبلغ کل</a-typography-text>
<a-typography-text>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">{{ settledLabel }}</a-typography-text>
<a-typography-text>{{ toman(item.settledAmount) }}</a-typography-text>
</a-flex>
<a-flex justify="space-between" align="center" :gap="8">
<a-typography-text type="secondary">مانده</a-typography-text>
<a-typography-text strong>{{ toman(item.remaining) }}</a-typography-text>
</a-flex>
</a-flex>
</a-list-item>
</template>
</a-list>
<a-table
v-else
:columns="columns"
:data-source="rows"
:pagination="false"
@@ -25,6 +25,11 @@ const ASelectOption = ASelect.Option;
const ASelectOptGroup = ASelect.OptGroup;
const { Text: ATypographyText } = Typography;
withDefaults(
defineProps<{ allowOther?: boolean; placeholder?: string }>(),
{ allowOther: true, placeholder: 'مشتری، شرکت، کاربر یا سایر' },
);
const kind = defineModel<FinancialPartyKind | undefined>('kind');
const partyId = defineModel<string | undefined>('partyId');
@@ -156,12 +161,12 @@ onMounted(() => {
:filter-option="false"
:loading="loading"
:value="selectValue"
placeholder="مشتری، شرکت، کاربر یا سایر"
:placeholder="placeholder"
style="width: 100%"
@search="onSearch"
@change="onChange"
>
<a-select-option value="other" label="سایر">سایر</a-select-option>
<a-select-option v-if="allowOther" value="other" label="سایر">سایر</a-select-option>
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
<a-select-option
v-for="row in peopleOptions"
@@ -16,12 +16,16 @@ import {
Space as ASpace,
Statistic as AStatistic,
Table as ATable,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
List as AList,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryPageChrome } from './treasuryPageChrome';
@@ -37,6 +41,8 @@ const reason = ref('');
const note = ref('');
const busy = ref(false);
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const columns: TableColumnsType<FinancialMovementDto> = [
{
title: 'تاریخ',
@@ -190,8 +196,29 @@ onUnmounted(() => {
<a-divider orientation="right">گردش حساب</a-divider>
<a-empty v-if="!items.length" description="گردشی ثبت نشده است." />
<a-list v-else-if="isMobile" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="formatJalaliNumeric(new Date(item.occurredAt))" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="شرح">{{ item.memo || '—' }}</a-descriptions-item>
<a-descriptions-item label="نوع">{{ item.kindLabel }}</a-descriptions-item>
<a-descriptions-item label="ورودی">
{{ item.direction === 'in' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="خروجی">
{{ item.direction === 'out' ? toman(item.amount) : '—' }}
</a-descriptions-item>
<a-descriptions-item label="مانده">
{{ item.balanceAfter ? toman(item.balanceAfter) : '—' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table
v-else
v-else-if="items.length"
:columns="columns"
:data-source="items"
:pagination="false"
@@ -27,9 +27,11 @@ import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@men
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -420,7 +422,7 @@ onMounted(() => {
<a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="accounts">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -23,8 +23,10 @@ import {
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -151,7 +153,7 @@ onMounted(() => {
<a-divider orientation="right">درآمد</a-divider>
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="incomeRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -176,7 +178,7 @@ onMounted(() => {
<a-divider orientation="right">هزینه</a-divider>
<a-empty v-if="!expenseRows.length" description="دسته هزینه‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="expenseRows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
@@ -14,11 +14,13 @@ import {
Input as AInput,
List as AList,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Typography,
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
@@ -26,18 +28,20 @@ import type {
FinancialPartyKind,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
import { partyNetPosition, toman } from './treasury-format';
import { parseMoneyNumber } from '@/lib/money';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const isMobile = useTreasuryMobile();
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const balances = ref<TreasuryPartyBalanceDto[]>([]);
@@ -58,10 +62,17 @@ const form = ref({
settleAmount: '',
method: 'transfer',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
});
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ پرداخت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'پرداخت', value: 'settlement' },
{ label: 'هزینه', value: 'accrual' },
];
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'جزئی',
@@ -134,46 +145,61 @@ async function createDoc() {
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
error.value = settlement ? 'مبلغ پرداخت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if (form.value.partyKind === 'other' && settleAmount !== amount) {
if (settlement && form.value.partyKind === 'other') {
error.value = 'برای پرداخت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ پرداخت‌شده باید با مبلغ کل یکی باشد.';
return;
}
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ پرداخت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const openBalance = partyKey
? Number(
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
const payable = partyKey
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance =
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0;
const receivable = partyKey
? Number(receivableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (payable > 0 || receivable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
settlement,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
settleAmount: settlement ? amount : settleAmount,
method: form.value.method,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
settleMirror.reset();
form.value = {
@@ -186,6 +212,7 @@ async function createDoc() {
settleAmount: '',
method: 'transfer',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
ok.value = collectsBalance
? `${toman(settleAmount)} از ماندهٔ طرف حساب کم شد.`
@@ -204,6 +231,9 @@ const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای پرداخت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
@@ -211,6 +241,33 @@ const partyRules = [
},
];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
return Math.max(0, total - settled);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
@@ -244,7 +301,10 @@ onMounted(() => {
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form
layout="vertical"
:model="form"
@@ -256,7 +316,12 @@ onMounted(() => {
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
<TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
@@ -273,7 +338,7 @@ onMounted(() => {
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item
label="مبلغ کل"
name="amount"
@@ -283,11 +348,25 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
<a-form-item
:label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
@@ -302,7 +381,7 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-form-item label="حساب" name="accountId" :rules="accountRules">
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
@@ -324,8 +403,18 @@ onMounted(() => {
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :span="24">
<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">ثبت</a-button>
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده پرداختنی</a-divider>
@@ -349,10 +438,10 @@ onMounted(() => {
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
@@ -363,7 +452,29 @@ onMounted(() => {
ابطال
</a-button>
</template>
<a-list-item-meta>
<a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
@@ -15,8 +15,10 @@ import {
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
@@ -75,7 +77,7 @@ onMounted(async () => {
/>
<a-list
v-else
item-layout="horizontal"
:item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingInstallments"
>
<template #renderItem="{ item }">
@@ -102,7 +104,7 @@ onMounted(async () => {
/>
<a-list
v-else
item-layout="horizontal"
:item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.upcomingPayables"
>
<template #renderItem="{ item }">
@@ -127,7 +129,7 @@ onMounted(async () => {
/>
<a-list
v-else
item-layout="horizontal"
:item-layout="isMobile ? 'vertical' : 'horizontal'"
:data-source="data.recent"
>
<template #renderItem="{ item }">
@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Col as ACol,
@@ -18,23 +17,9 @@ import {
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography,
message,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
TagOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
@@ -42,7 +27,6 @@ import type {
FinancialCategoryDto,
FinancialDocumentDto,
FinancialPartyKind,
TreasuryDashboardDto,
TreasuryPartyBalanceDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -57,7 +41,6 @@ const { Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
@@ -67,13 +50,10 @@ const balances = ref<TreasuryPartyBalanceDto[]>([]);
const payableBalances = ref<TreasuryPartyBalanceDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
return {
@@ -94,6 +74,12 @@ const form = ref(emptyForm());
const settleMirror = useSettleDefault(form);
const settleLabel = 'مبلغ دریافت‌شده';
const entryMode = ref<'settlement' | 'accrual'>('settlement');
const isSettlement = computed(() => entryMode.value === 'settlement');
const entryOptions = [
{ label: 'دریافت', value: 'settlement' },
{ label: 'درآمد', value: 'accrual' },
];
const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده',
partial: 'جزئی',
@@ -109,23 +95,13 @@ const methodOptions = [
{ value: 'other', label: 'سایر' },
];
const formTabOptions = [
{
value: 'main',
payload: 'main',
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
},
{
value: 'extra',
payload: 'extra',
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
},
];
const partyRules = [
{
validator: async () => {
if (!form.value.partyKind) throw new Error('طرف حساب الزامی است');
if (isSettlement.value && form.value.partyKind === 'other') {
throw new Error('برای دریافت، طرف حساب واقعی لازم است');
}
if (form.value.partyKind !== 'other' && !form.value.partyId) {
throw new Error('طرف حساب را انتخاب کنید');
}
@@ -133,6 +109,17 @@ const partyRules = [
},
];
const accountRules = [
{
validator: async () => {
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if ((isSettlement.value || settled > 0) && !form.value.accountId) {
throw new Error('حساب را انتخاب کنید');
}
},
},
];
const partyPosition = computed(() => {
const partyKind = form.value.partyKind;
const id = form.value.partyId;
@@ -143,6 +130,16 @@ const partyPosition = computed(() => {
return partyNetPosition(receivable, payable);
});
watch(entryMode, () => {
settleMirror.reset();
form.value.amount = '';
form.value.settleAmount = '';
if (isSettlement.value && form.value.partyKind === 'other') {
form.value.partyKind = undefined;
form.value.partyId = undefined;
}
});
const remainingAmount = computed(() => {
const total = parseMoneyNumber(String(form.value.amount ?? ''), 0) ?? 0;
const settled = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
@@ -171,7 +168,7 @@ function docLine(item: FinancialDocumentDto) {
}
async function load() {
const [docs, cats, acc, dash, bals, payables] = await Promise.all([
const [docs, cats, acc, bals, payables] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && (e.response?.status === 403 || e.response?.status === 404)) {
@@ -185,7 +182,6 @@ async function load() {
}
throw e;
}),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
throw e;
@@ -200,13 +196,11 @@ async function load() {
payableBalances.value = payables.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
settleMirror.reset();
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
ok.value = '';
}
@@ -216,44 +210,58 @@ async function createDoc() {
error.value = '';
ok.value = '';
try {
const amount = parseMoneyNumber(String(form.value.amount ?? ''), 0);
const settlement = isSettlement.value;
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
const amount = settlement
? settleAmount
: parseMoneyNumber(String(form.value.amount ?? ''), 0);
if (amount == null || amount <= 0) {
error.value = 'مبلغ کل معتبر نیست.';
error.value = settlement ? 'مبلغ دریافت‌شده معتبر نیست.' : 'مبلغ کل معتبر نیست.';
return;
}
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
error.value = 'طرف حساب را انتخاب کنید.';
return;
}
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
if (form.value.partyKind === 'other' && settleAmount !== amount) {
if (settlement && form.value.partyKind === 'other') {
error.value = 'برای دریافت، طرف حساب واقعی لازم است.';
return;
}
if (!settlement && form.value.partyKind === 'other' && settleAmount !== amount) {
error.value = 'برای سایر، مبلغ دریافت‌شده باید با مبلغ کل یکی باشد.';
return;
}
if (!settlement && settleAmount > amount) {
error.value = 'مبلغ دریافت‌شده از مبلغ کل بیشتر است.';
return;
}
if ((settlement || settleAmount > 0) && !form.value.accountId) {
error.value = 'حساب را انتخاب کنید.';
return;
}
const partyKey =
form.value.partyKind && form.value.partyKind !== 'other' && form.value.partyId
? `${form.value.partyKind}:${form.value.partyId}`
: '';
const openBalance = partyKey
? Number(
balances.value.find((row) => row.key === partyKey)?.documentRemaining ||
balances.value.find((row) => row.key === partyKey)?.remaining ||
0,
)
const receivable = partyKey
? Number(balances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance =
Boolean(partyKey) && settleAmount === amount && settleAmount > 0 && openBalance > 0;
const payable = partyKey
? Number(payableBalances.value.find((row) => row.key === partyKey)?.documentRemaining || 0)
: 0;
const collectsBalance = settlement && (receivable > 0 || payable > 0);
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
kind,
settlement,
title: form.value.title,
amount,
categoryId: form.value.categoryId || null,
categoryId: settlement ? null : form.value.categoryId || null,
partyKind: form.value.partyKind,
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
accountId: form.value.accountId || null,
settleAmount,
settleAmount: settlement ? amount : settleAmount,
method: form.value.method,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
@@ -272,16 +280,6 @@ async function createDoc() {
}
}
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await createDoc();
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
@@ -300,345 +298,15 @@ onMounted(() => {
</script>
<template>
<div
:style="
isMobile
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
: undefined
"
>
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
دریافت‌های این ماه
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.actualReceipts) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ArrowDownOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
در انتظار دریافت
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.receivables) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ClockCircleOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-segmented
v-model:value="formTab"
block
:options="formTabOptions"
:style="{ background: token.colorFillTertiary }"
/>
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
</a-col>
</a-row>
</a-form-item>
<a-form-item
label="عنوان درآمد"
name="title"
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
>
<a-input
v-model:value="form.title"
placeholder="مثلاً: دریافت قسط پروژه الف"
>
<template #prefix><FileTextOutlined /></template>
</a-input>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="24">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<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]">
<a-col :span="12">
<a-form-item
label="مبلغ کل"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<MoneyInput addon-after="تومان" v-model="form.amount" :decimals="0" placeholder="۰" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="settleLabel"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<MoneyInput addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="form.partyKind === 'other'"
placeholder="۰"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex
align="center"
justify="space-between"
:style="{
background: token.colorSuccessBg,
borderRadius: `${token.borderRadiusLG * 2}px`,
padding: `${token.paddingSM}px ${token.padding}px`,
marginBottom: `${token.margin}px`,
}"
>
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: token.colorSuccess,
display: 'inline-block',
}"
/>
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
</a-space>
<a-typography-text strong :style="{ color: token.colorSuccess }">
{{ toman(remainingAmount) }}
</a-typography-text>
</a-flex>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="تاریخ دریافت" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="accountId">
<template #label>
<a-space :size="6">
<BankOutlined />
<span>حساب مقصد</span>
</a-space>
</template>
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
style="width: 100%"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
:rows="4"
placeholder="توضیحات اختیاری…"
/>
</a-form-item>
</div>
</a-form>
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">مانده دریافتنی</a-divider>
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافت‌شده" />
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="عنوان یا شماره پیگیری"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-form>
<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-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
<div
:style="{
position: 'fixed',
left: 0,
right: 0,
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
zIndex: 90,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: `${token.paddingSM}px ${token.padding}px`,
boxShadow: token.boxShadowSecondary,
}"
>
<a-row :gutter="12">
<a-col :span="8">
<a-button block @click="resetForm">انصراف</a-button>
</a-col>
<a-col :span="16">
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
<template #icon><CheckOutlined /></template>
ثبت دریافت
</a-button>
</a-col>
</a-row>
</div>
</template>
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<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="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-flex align="center" justify="space-between" wrap="wrap" gap="small">
<a-typography-text strong>سند جدید</a-typography-text>
<a-segmented v-model:value="entryMode" :options="entryOptions" />
</a-flex>
<a-form
layout="vertical"
:model="form"
@@ -650,7 +318,12 @@ onMounted(() => {
<a-form-item label="طرف حساب" name="partyKind" :rules="partyRules">
<a-row :gutter="8" align="middle">
<a-col :span="partyPosition ? 16 : 24">
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
<TreasuryPartySelect
v-model:kind="form.partyKind"
v-model:party-id="form.partyId"
:allow-other="!isSettlement"
:placeholder="isSettlement ? 'مشتری، شرکت یا کاربر' : undefined"
/>
</a-col>
<a-col v-if="partyPosition" :span="8">
<a-typography-text :type="partyPosition.type">{{ partyPosition.text }}</a-typography-text>
@@ -667,7 +340,7 @@ onMounted(() => {
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item
label="مبلغ کل"
name="amount"
@@ -677,11 +350,25 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<MoneyInput addon-after="تومان" v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
<a-form-item
:label="settleLabel"
name="settleAmount"
:rules="isSettlement ? [{ required: true, message: 'مبلغ را وارد کنید' }] : []"
>
<MoneyInput
addon-after="تومان"
v-model="form.settleAmount"
:decimals="0"
:disabled="!isSettlement && form.partyKind === 'other'"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="مانده این سند">
<a-typography-text>{{ toman(remainingAmount) }}</a-typography-text>
</a-form-item>
</a-col>
<a-col v-if="!isSettlement" :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
@@ -696,7 +383,7 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-form-item label="حساب" name="accountId" :rules="accountRules">
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
@@ -733,13 +420,13 @@ onMounted(() => {
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-col v-if="!isSettlement" :xs="24" :md="16">
<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">ثبت</a-button>
<a-button type="primary" html-type="submit" :block="isMobile" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">مانده دریافتنی</a-divider>
@@ -762,10 +449,10 @@ onMounted(() => {
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<template v-if="!isMobile" #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
@@ -776,7 +463,29 @@ onMounted(() => {
ابطال
</a-button>
</template>
<a-list-item-meta>
<a-flex v-if="isMobile" vertical :gap="4" style="width: 100%">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-typography-text strong>{{ item.title }}</a-typography-text>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</a-flex>
<a-typography-text type="secondary">
{{ item.partyName || 'بدون طرف حساب' }} · {{ statusLabel[item.status] ?? item.status }}
</a-typography-text>
<a-typography-text v-if="item.reference" type="secondary">پیگیری {{ item.reference }}</a-typography-text>
<a-typography-text v-if="item.categoryName" type="secondary">{{ item.categoryName }}</a-typography-text>
<a-flex justify="space-between" align="center">
<a-typography-text>مانده {{ toman(item.remaining) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</a-flex>
</a-flex>
<a-list-item-meta v-else>
<template #title>{{ item.title }}</template>
<template #description>{{ docLine(item) }}</template>
</a-list-item-meta>
@@ -784,8 +493,5 @@ onMounted(() => {
</template>
</a-list>
</a-card>
</a-flex>
</template>
</div>
</a-flex>
</template>
+12 -7
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { Flex as AFlex, Typography, theme } from 'ant-design-vue';
import { Flex as AFlex, Grid, Typography, theme } from 'ant-design-vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { provideTreasuryPageChrome } from './treasuryPageChrome';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const route = useRoute();
const chrome = provideTreasuryPageChrome();
@@ -31,16 +33,19 @@ const description = computed(
(typeof route.meta.treasuryDescription === 'string' ? route.meta.treasuryDescription : ''),
);
/** Keep title+subtitle block height stable across tabs (one-line subtitle). */
const headerStyle = computed(() => ({
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
}));
const headerStyle = computed(() =>
isMobile.value
? undefined
: {
minHeight: `${token.value.controlHeightLG + token.value.fontSizeLG + token.value.marginXXS}px`,
},
);
</script>
<template>
<a-flex vertical :gap="token.margin" :style="{ width: '100%' }">
<a-flex vertical :gap="isMobile ? token.marginSM : token.margin" :style="{ width: '100%' }">
<div :style="headerStyle">
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-title :level="isMobile ? 4 : 3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text
type="secondary"
:ellipsis="description ? { tooltip: description } : false"
@@ -18,6 +18,7 @@ import {
Select as ASelect,
Space as ASpace,
Table as ATable,
List as AList,
Textarea as ATextarea,
Tooltip as ATooltip,
Typography,
@@ -49,6 +50,7 @@ const { useToken } = theme;
const { token } = useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const AListItem = AList.Item;
type PayrollPayment = {
id: string;
@@ -651,8 +653,24 @@ onMounted(() => {
</a-space>
</a-form>
<a-list v-if="isMobile && selectedPay?.payments.length" :data-source="selectedPay.payments">
<template #renderItem="{ item }">
<a-list-item>
<a-card style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="تاریخ">
{{ formatJalaliNumeric(new Date(item.occurredAt)) }}
</a-descriptions-item>
<a-descriptions-item label="حساب">{{ item.accountName }}</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
</a-descriptions>
<a-button type="link" @click="startEditPayment(item)">ویرایش</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table
v-if="selectedPay?.payments.length"
v-else-if="selectedPay?.payments.length"
:columns="paymentColumns"
:data-source="selectedPay.payments"
:pagination="false"
@@ -7,12 +7,15 @@ import {
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
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,
@@ -27,6 +30,7 @@ import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared'
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
@@ -87,6 +91,9 @@ const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: '', key: 'actions' },
];
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -365,7 +372,33 @@ onMounted(() => {
</div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex>
<a-list v-if="isMobile" :data-source="row.installments">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="`قسط ${item.position}`" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="سررسید">
{{ formatJalaliNumeric(new Date(item.dueDate)) }}
</a-descriptions-item>
<a-descriptions-item label="مبلغ">{{ toman(item.amount) }}</a-descriptions-item>
<a-descriptions-item label="مانده">{{ toman(item.remaining) }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">
{{ statusLabel[item.status] ?? item.status }}
</a-descriptions-item>
</a-descriptions>
<a-button
v-if="item.status !== 'settled'"
type="link"
@click="startCollect(row, item)"
>
دریافت
</a-button>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table
v-else
:columns="installmentColumns"
:data-source="row.installments"
:pagination="false"
@@ -22,10 +22,12 @@ import { api } from '@/lib/api';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
import { formatJalaliNumeric } from '@/lib/jalali';
import { parseMoneyNumber } from '@/lib/money';
const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
@@ -146,7 +148,7 @@ onMounted(() => {
<a-divider orientation="right">فهرست الگوها</a-divider>
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -9,6 +9,8 @@ import {
Empty as AEmpty,
Flex as AFlex,
List as AList,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Table as ATable,
Tabs as ATabs,
Typography,
@@ -20,8 +22,10 @@ import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { api } from '@/lib/api';
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
import { toman } from './treasury-format';
import { useTreasuryMobile } from './useTreasuryMobile';
const { Text: ATypographyText } = Typography;
const isMobile = useTreasuryMobile();
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ATabPane = ATabs.TabPane;
@@ -186,8 +190,20 @@ onMounted(() => {
<a-tabs v-model:activeKey="reportTab">
<a-tab-pane key="periods" tab="مقایسه دوره‌ها">
<a-flex vertical :gap="16">
<a-list v-if="isMobile && metricRows.length" :data-source="metricRows">
<template #renderItem="{ item }">
<a-list-item>
<a-card :title="item.label" style="width: 100%">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="این دوره">{{ item.current }}</a-descriptions-item>
<a-descriptions-item label="دوره قبل">{{ item.previous }}</a-descriptions-item>
</a-descriptions>
</a-card>
</a-list-item>
</template>
</a-list>
<a-table
v-if="metricRows.length"
v-else-if="metricRows.length"
:columns="metricColumns"
:data-source="metricRows"
:pagination="false"
@@ -197,7 +213,7 @@ onMounted(() => {
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="receivablesByParty">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -212,7 +228,7 @@ onMounted(() => {
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="payablesByMonth">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -228,7 +244,7 @@ onMounted(() => {
<template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
<a-list v-else :item-layout="isMobile ? 'vertical' : 'horizontal'" :data-source="current.expenseCategories">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
@@ -0,0 +1,7 @@
import { computed } from 'vue';
import { Grid } from 'ant-design-vue';
export function useTreasuryMobile() {
const screens = Grid.useBreakpoint();
return computed(() => !screens.value.md);
}