feat(web): migrate finance treasury pages to Ant Design Vue
Replace legacy Ui* chrome on treasury routes with Ant layout, and polish mobile incomes stats colors plus nav visibility. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,7 +65,10 @@ const items: Array<{
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const hidden = computed(() => route.name === 'person-detail');
|
||||
/** صفحات با نوار اکشن ثابت موبایل — FAB نباید وسط صفحه رها شود */
|
||||
const hidden = computed(
|
||||
() => route.name === 'person-detail' || route.name === 'finance-incomes',
|
||||
);
|
||||
const isQuickCreateOpen = ref(false);
|
||||
const ready = ref(false);
|
||||
const fabPos = ref<FabPos>({
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Statistic as AStatistic,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
@@ -115,110 +139,176 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="صندوق و حسابها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div class="stats">
|
||||
<article>
|
||||
<span>مجموع موجودی حسابها</span>
|
||||
<strong>{{ toman(total) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>ورودی این فهرست</span>
|
||||
<strong class="in">{{ toman(periodIn) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>خروجی این فهرست</span>
|
||||
<strong class="out">{{ toman(periodOut) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>حسابهای فعال</span>
|
||||
<strong>{{ active.length }} حساب</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="account in accounts" :key="account.id" class="account">
|
||||
<div>
|
||||
<strong>{{ account.name }}</strong>
|
||||
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
|
||||
<p>مشاهده گردش حساب و جزئیات</p>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(account.balance) }}</b>
|
||||
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
|
||||
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="openForm" title="حساب جدید">
|
||||
<div class="grid">
|
||||
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.type" class="select">
|
||||
<option value="cash">صندوق نقدی</option>
|
||||
<option value="bank">حساب بانکی</option>
|
||||
<option value="petty">تنخواه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
|
||||
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
|
||||
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
|
||||
<UiField label="موجودی اولیه"><UiInput v-model="form.openingBalance" /></UiField>
|
||||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حسابها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="transferOpen" title="انتقال بین حسابها">
|
||||
<div class="grid">
|
||||
<UiField label="از حساب">
|
||||
<select v-model="transfer.fromAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="به حساب">
|
||||
<select v-model="transfer.toAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="transfer.amount" /></UiField>
|
||||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||||
</div>
|
||||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||||
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="مجموع موجودی حسابها" :value="toman(total)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic
|
||||
title="ورودی این فهرست"
|
||||
:value="toman(periodIn)"
|
||||
:value-style="{ color: 'var(--ant-color-success)' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic
|
||||
title="خروجی این فهرست"
|
||||
:value="toman(periodOut)"
|
||||
:value-style="{ color: 'var(--ant-color-error)' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="حسابهای فعال" :value="`${active.length} حساب`" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
|
||||
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
|
||||
</a-space>
|
||||
|
||||
<template v-if="openForm">
|
||||
<a-divider orientation="right">حساب جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createAccount">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام حساب" name="name">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="type">
|
||||
<a-select v-model:value="form.type" style="width: 100%">
|
||||
<a-select-option value="cash">صندوق نقدی</a-select-option>
|
||||
<a-select-option value="bank">حساب بانکی</a-select-option>
|
||||
<a-select-option value="petty">تنخواه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
|
||||
<a-form-item label="نام بانک" name="bankName">
|
||||
<a-input v-model:value="form.bankName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شماره یا شناسه" name="accountRef">
|
||||
<a-input v-model:value="form.accountRef" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مسئول" name="responsibleName">
|
||||
<a-input v-model:value="form.responsibleName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="موجودی اولیه" name="openingBalance">
|
||||
<a-input v-model:value="form.openingBalance" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع" name="openingDate">
|
||||
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<template v-if="transferOpen">
|
||||
<a-divider orientation="right">انتقال بین حسابها</a-divider>
|
||||
<a-form layout="vertical" @finish="sendTransfer">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="از حساب" name="fromAccountId">
|
||||
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="به حساب" name="toAccountId">
|
||||
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="transfer.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شرح" name="memo">
|
||||
<a-input v-model:value="transfer.memo" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-typography-text type="secondary">
|
||||
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">فهرست حسابها</a-divider>
|
||||
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="accounts">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
|
||||
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ item.name }}
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
|
||||
.stats article, .account {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
|
||||
.in { color: #15803d; }
|
||||
.out { color: #b91c1c; }
|
||||
.list { display: grid; gap: 0.65rem; }
|
||||
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||||
.side { display: grid; justify-items: end; gap: 0.4rem; }
|
||||
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const name = ref('');
|
||||
const kind = ref<'income' | 'expense'>('expense');
|
||||
@@ -35,36 +56,61 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="دستهبندیهای مالی" description="دستههای پیشفرض با اولین بازدید ساخته میشوند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="add">
|
||||
<UiField label="نام"><UiInput v-model="name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="kind" class="select">
|
||||
<option value="income">درآمد</option>
|
||||
<option value="expense">هزینه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiButton @click="add">افزودن</UiButton>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="row in rows" :key="row.id">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">دستهبندیهای مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دستههای پیشفرض با اولین بازدید ساخته میشوند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
li span { color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<a-form layout="vertical" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام" name="name">
|
||||
<a-input v-model:value="name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-select v-model:value="kind" style="width: 100%">
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="دستهای ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const kind = 'expense' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const form = ref({
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const title = 'پرداختها و هزینهها';
|
||||
const settleLabel = 'مبلغ پرداختشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'پرداختنشده',
|
||||
partial: 'جزئی',
|
||||
settled: 'پرداختشده',
|
||||
void: 'باطلشده',
|
||||
};
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
||||
{ value: 'pos', label: 'کارتخوان' },
|
||||
{ value: 'transfer', label: 'حواله' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
];
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
form.value = {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreasuryDocsView kind="expense" />
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<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-form layout="vertical" @finish="createDoc">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
|
||||
<a-select-option value="">بدون دسته</a-select-option>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
|
||||
<a-select-option value="">انتخاب حساب</a-select-option>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</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>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Statistic as AStatistic,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import { UiCard, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
@@ -31,82 +46,117 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="مدیریت مالی"
|
||||
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="data" class="stats">
|
||||
<article v-for="card in cards" :key="card.key">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ toman(data[card.key]) }}</strong>
|
||||
<small>{{ card.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="data" class="split">
|
||||
<UiCard title="اقساط نزدیک یا معوق">
|
||||
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingInstallments" :key="row.id">
|
||||
<div>
|
||||
<strong>قسط {{ row.position }}</strong>
|
||||
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
<UiCard title="پرداختهای آینده">
|
||||
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingPayables" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
<UiCard v-if="data" title="آخرین اسناد">
|
||||
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.recent" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stats article {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats span, .stats small, .muted { color: #64748b; }
|
||||
.stats strong { font-size: 1.05rem; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
|
||||
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
|
||||
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.list small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<template v-if="data">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:xl="6"
|
||||
>
|
||||
<a-card :bordered="false">
|
||||
<a-statistic :title="card.label" :value="toman(data[card.key])" />
|
||||
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingInstallments.length"
|
||||
description="قسط بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingInstallments"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>قسط {{ item.position }}</template>
|
||||
<template #description>
|
||||
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">پرداختهای آینده</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingPayables.length"
|
||||
description="تعهد پرداخت بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingPayables"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>مانده {{ toman(item.remaining) }}</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="right">آخرین اسناد</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.recent.length"
|
||||
description="هنوز سندی ثبت نشده است."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.recent"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,765 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Grid,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowLeftOutlined,
|
||||
BankOutlined,
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
CreditCardOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
NumberOutlined,
|
||||
PlusOutlined,
|
||||
TagOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, 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);
|
||||
|
||||
const kind = 'income' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
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 categoryModalOpen = ref(false);
|
||||
const newCategoryName = ref('');
|
||||
const categorySaving = ref(false);
|
||||
const mobileFormRef = ref<FormInstance>();
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: undefined as string | undefined,
|
||||
partyName: '',
|
||||
accountId: undefined as string | undefined,
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
bookedAt: formatLocalDateInput(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const pageTitle = 'دریافتها و درآمدها';
|
||||
const settleLabel = 'مبلغ دریافتشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'دریافتنشده',
|
||||
partial: 'جزئی',
|
||||
settled: 'دریافتشده',
|
||||
void: 'باطلشده',
|
||||
};
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
||||
{ value: 'pos', label: 'کارتخوان' },
|
||||
{ value: 'transfer', label: 'حواله' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
];
|
||||
|
||||
const formTabOptions = [
|
||||
{
|
||||
value: 'main',
|
||||
payload: 'main',
|
||||
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
|
||||
},
|
||||
{
|
||||
value: 'extra',
|
||||
payload: 'extra',
|
||||
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
|
||||
},
|
||||
];
|
||||
|
||||
const remainingAmount = computed(() => {
|
||||
const total = Number(form.value.amount) || 0;
|
||||
const settled = Number(form.value.settleAmount) || 0;
|
||||
return Math.max(0, total - settled);
|
||||
});
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc, dash] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
|
||||
]);
|
||||
rows.value = docs.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() {
|
||||
form.value = emptyForm();
|
||||
formTab.value = 'main';
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
bookedAt: form.value.bookedAt || undefined,
|
||||
});
|
||||
resetForm();
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategoryQuick() {
|
||||
const name = newCategoryName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return;
|
||||
}
|
||||
categorySaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
|
||||
name,
|
||||
kind,
|
||||
});
|
||||
await load();
|
||||
form.value.categoryId = data.id;
|
||||
newCategoryName.value = '';
|
||||
categoryModalOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
categorySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreasuryDocsView kind="income" />
|
||||
<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">
|
||||
<div>
|
||||
<a-flex align="center" :gap="token.marginXS">
|
||||
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
|
||||
ثبت دریافت جدید
|
||||
</a-typography-title>
|
||||
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<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="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="12">
|
||||
<a-form-item label="مشتری / طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item name="method">
|
||||
<template #label>
|
||||
<a-space :size="6">
|
||||
<CreditCardOutlined />
|
||||
<span>روش دریافت</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option
|
||||
v-for="opt in methodOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ 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-flex :gap="8">
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="flex: 1; min-width: 0"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" ghost @click="categoryModalOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="مبلغ کل (تومان)"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.amount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
:label="`${settleLabel} (تومان)`"
|
||||
name="settleAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.settleAmount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{{ 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="reference">
|
||||
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-textarea
|
||||
v-model:value="form.notes"
|
||||
:rows="4"
|
||||
placeholder="توضیحات اختیاری…"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<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>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</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">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<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-form layout="vertical" @finish="createDoc">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option
|
||||
v-for="opt in methodOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</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 :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</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>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
v-model:open="categoryModalOpen"
|
||||
title="دسته جدید"
|
||||
ok-text="افزودن"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="categorySaving"
|
||||
@ok="createCategoryQuick"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام دسته" required>
|
||||
<a-input
|
||||
v-model:value="newCategoryName"
|
||||
placeholder="مثلاً: فروش خدمات"
|
||||
@press-enter="createCategoryQuick"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type PayrollRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -68,6 +88,10 @@ async function payRow() {
|
||||
}
|
||||
}
|
||||
|
||||
function startPay(row: PayrollRow) {
|
||||
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری حقوق ناموفق بود.';
|
||||
@@ -76,59 +100,109 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را میبینند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="ثبت دوره">
|
||||
<div class="grid">
|
||||
<UiField label="پرسنل">
|
||||
<select v-model="form.userId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
|
||||
<UiField label="پایه"><UiInput v-model="form.baseAmount" /></UiField>
|
||||
<UiField label="مزایا"><UiInput v-model="form.benefits" /></UiField>
|
||||
<UiField label="پاداش"><UiInput v-model="form.bonus" /></UiField>
|
||||
<UiField label="کسورات"><UiInput v-model="form.deductions" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
|
||||
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را میبینند.</a-typography-text>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(row.payable) }}</b>
|
||||
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
|
||||
</div>
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="pay.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton @click="payRow">ثبت پرداخت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.side { display: grid; justify-items: end; }
|
||||
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<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-form layout="vertical" @finish="createRow">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="پرسنل" name="userId">
|
||||
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.fullName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="period">
|
||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پایه" name="baseAmount">
|
||||
<a-input v-model:value="form.baseAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مزایا" name="benefits">
|
||||
<a-input v-model:value="form.benefits" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پاداش" name="bonus">
|
||||
<a-input v-model:value="form.bonus" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="کسورات" name="deductions">
|
||||
<a-input v-model:value="form.deductions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
|
||||
</a-form>
|
||||
|
||||
<template v-if="pay.id">
|
||||
<a-divider orientation="right">پرداخت حقوق</a-divider>
|
||||
<a-form layout="vertical" @finish="payRow">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="pay.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="pay.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'settled' && item.status !== 'void'"
|
||||
type="link"
|
||||
@click="startPay(item)"
|
||||
>
|
||||
پرداخت
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.fullName }}</template>
|
||||
<template #description>
|
||||
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type InstallmentRow = FinancialReceivableDto['installments'][number];
|
||||
|
||||
const rows = ref<FinancialReceivableDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
@@ -35,6 +58,31 @@ const statusLabel: Record<string, string> = {
|
||||
void: 'باطل',
|
||||
};
|
||||
|
||||
const installmentColumns: TableColumnsType<InstallmentRow> = [
|
||||
{ title: 'قسط', dataIndex: 'position', key: 'position' },
|
||||
{
|
||||
title: 'سررسید',
|
||||
key: 'dueDate',
|
||||
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
|
||||
},
|
||||
{
|
||||
title: 'مبلغ',
|
||||
key: 'amount',
|
||||
customRender: ({ record }) => toman(record.amount),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'remaining',
|
||||
customRender: ({ record }) => toman(record.remaining),
|
||||
},
|
||||
{
|
||||
title: 'وضعیت',
|
||||
key: 'status',
|
||||
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
|
||||
},
|
||||
{ title: '', key: 'actions' },
|
||||
];
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -101,6 +149,15 @@ async function collectInstallment() {
|
||||
}
|
||||
}
|
||||
|
||||
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
|
||||
collect.value = {
|
||||
installmentId: item.id,
|
||||
receivableId: row.id,
|
||||
amount: item.remaining,
|
||||
accountId: accounts.value[0]?.id || '',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری مطالبات ناموفق بود.';
|
||||
@@ -109,112 +166,163 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="اقساط و مطالبات"
|
||||
description="دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="قرارداد جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.totalAmount" /></UiField>
|
||||
<UiField label="پیشپرداخت"><UiInput v-model="form.prepaidAmount" /></UiField>
|
||||
<UiField label="حساب پیشپرداخت">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||||
<div v-if="custom" class="custom">
|
||||
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||||
<UiField label="مبلغ قسط"><UiInput v-model="line.amount" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<article v-for="row in rows" :key="row.id" class="contract">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.totalAmount) }}</b>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>قسط</th>
|
||||
<th>سررسید</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مانده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in row.installments" :key="item.id">
|
||||
<td>{{ item.position }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
|
||||
<td>{{ toman(item.amount) }}</td>
|
||||
<td>{{ toman(item.remaining) }}</td>
|
||||
<td>{{ statusLabel[item.status] }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'settled'"
|
||||
type="button"
|
||||
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
|
||||
>
|
||||
دریافت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<TreasuryNav />
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||||
<UiField label="حساب مقصد">
|
||||
<select v-model="collect.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
<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-form layout="vertical" @finish="createContract">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="totalAmount">
|
||||
<a-input v-model:value="form.totalAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیشپرداخت" name="prepaidAmount">
|
||||
<a-input v-model:value="form.prepaidAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تعداد قسط" name="count">
|
||||
<a-input v-model:value="form.count" :disabled="custom" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24">
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<template v-if="custom">
|
||||
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="`مبلغ قسط ${index + 1}`">
|
||||
<a-input v-model:value="line.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سررسید">
|
||||
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
|
||||
</a-form>
|
||||
|
||||
<template v-if="collect.installmentId">
|
||||
<a-divider orientation="right">دریافت قسط</a-divider>
|
||||
<a-form layout="vertical" @finish="collectInstallment">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="collect.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب مقصد" name="accountId">
|
||||
<a-select v-model:value="collect.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">قراردادها</a-divider>
|
||||
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
|
||||
<template v-else>
|
||||
<div v-for="row in rows" :key="row.id">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
|
||||
<div>
|
||||
<a-typography-text strong>{{ row.title }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="installmentColumns"
|
||||
:data-source="row.installments"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: true }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
v-if="record.status !== 'settled'"
|
||||
type="link"
|
||||
@click="startCollect(row, record as InstallmentRow)"
|
||||
>
|
||||
دریافت
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-divider />
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
|
||||
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.contract small { display: block; color: #64748b; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
|
||||
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
|
||||
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
|
||||
const rows = ref<Row[]>([]);
|
||||
const error = ref('');
|
||||
@@ -37,48 +57,80 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="الگوی جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.kind" class="select">
|
||||
<option value="expense">هزینه</option>
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
<option value="yearly">سالانه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createItem">ثبت الگو</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<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-form layout="vertical" @finish="createItem">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-select v-model:value="form.interval" style="width: 100%">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
<a-select-option value="yearly">سالانه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست الگوها</a-divider>
|
||||
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
· {{ item.interval }}
|
||||
· بعدی {{ formatJalaliNumeric(new Date(item.nextDue)) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
bookedExpense: string;
|
||||
@@ -14,6 +30,14 @@ type Period = {
|
||||
cashNet: string;
|
||||
expenseCategories: Array<{ name: string; amount: string; share: string }>;
|
||||
};
|
||||
|
||||
type MetricRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
current: string;
|
||||
previous: string;
|
||||
};
|
||||
|
||||
const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
@@ -29,6 +53,22 @@ const lines = [
|
||||
['خالص جریان نقدی', 'cashNet'],
|
||||
] as const;
|
||||
|
||||
const metricColumns: TableColumnsType<MetricRow> = [
|
||||
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
|
||||
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
|
||||
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
|
||||
];
|
||||
|
||||
const metricRows = computed<MetricRow[]>(() => {
|
||||
if (!current.value || !previous.value) return [];
|
||||
return lines.map(([label, key]) => ({
|
||||
key,
|
||||
label,
|
||||
current: toman(current.value![key]),
|
||||
previous: toman(previous.value![key]),
|
||||
}));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<{
|
||||
current: Period;
|
||||
@@ -60,49 +100,85 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
|
||||
<table v-if="current && previous">
|
||||
<thead>
|
||||
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in lines" :key="line[1]">
|
||||
<td>{{ line[0] }}</td>
|
||||
<td>{{ toman(current[line[1]]) }}</td>
|
||||
<td>{{ toman(previous[line[1]]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>مطالبات به تفکیک طرف حساب</h2>
|
||||
<ul>
|
||||
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2>تعهدات به تفکیک ماه</h2>
|
||||
<ul>
|
||||
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2 v-if="current">سهم هزینهها</h2>
|
||||
<ul v-if="current">
|
||||
<li v-for="row in current.expenseCategories" :key="row.name">
|
||||
<span>{{ row.name }}</span>
|
||||
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button @click="download">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
h2 { margin: 0; font-size: 1rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مقایسه دورهها</a-divider>
|
||||
<a-table
|
||||
v-if="metricRows.length"
|
||||
:columns="metricColumns"
|
||||
:data-source="metricRows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
/>
|
||||
<a-empty v-else description="دادهای برای نمایش نیست." />
|
||||
|
||||
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
|
||||
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
|
||||
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.month }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<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">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>
|
||||
{{ toman(item.amount) }} · {{ item.share }}٪
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user