feat: gate treasury as an org module and group money inputs
Link optional invoice and cheque settlements to cash accounts, and let superadmin enable the finance module per organization. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const links = [
|
||||
const auth = useAuthStore();
|
||||
const canPayroll = computed(() =>
|
||||
auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll')),
|
||||
);
|
||||
const links = computed(() => [
|
||||
{ to: '/finance', label: 'داشبورد', exact: true },
|
||||
{ to: '/finance/incomes', label: 'دریافتها و درآمدها' },
|
||||
{ to: '/finance/expenses', label: 'پرداختها و هزینهها' },
|
||||
{ to: '/finance/receivables', label: 'اقساط و مطالبات' },
|
||||
{ to: '/finance/accounts', label: 'صندوق و حسابها' },
|
||||
{ to: '/finance/payroll', label: 'حقوق' },
|
||||
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده' },
|
||||
{ to: '/finance/reports', label: 'گزارش' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها' },
|
||||
];
|
||||
]);
|
||||
|
||||
function on(path: string, exact?: boolean) {
|
||||
if (exact) return route.path === path;
|
||||
|
||||
@@ -157,6 +157,11 @@ function cardAllowedForNav(card: {
|
||||
to?: string;
|
||||
}) {
|
||||
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
|
||||
const canTreasury = auth.user?.role === 'owner'
|
||||
|| auth.user?.role === 'admin'
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury'))
|
||||
|| Boolean(auth.user?.permissions?.includes('treasury_payroll'));
|
||||
if (card.to?.startsWith('/finance')) return canTreasury;
|
||||
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false;
|
||||
if (!moduleAllowed(card.module)) return false;
|
||||
if (
|
||||
|
||||
@@ -360,7 +360,7 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
desc: 'صندوق، درآمد و هزینه داخلی',
|
||||
icon: 'dollar',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'treasury',
|
||||
},
|
||||
{
|
||||
to: '/reservations',
|
||||
|
||||
@@ -557,6 +557,10 @@ router.beforeEach((to) => {
|
||||
) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
if (String(to.path).startsWith('/finance')) {
|
||||
const modules = auth.user?.enabledModules;
|
||||
if (modules && !modules.includes('treasury')) return { name: 'home' };
|
||||
}
|
||||
const roles = to.meta.roles as UserRole[] | undefined;
|
||||
if (roles?.length) {
|
||||
const role = auth.user?.role as UserRole | undefined;
|
||||
@@ -566,6 +570,12 @@ router.beforeEach((to) => {
|
||||
if (to.name === 'team-settings' && auth.user?.viewAs?.canOpenTeamSettings) {
|
||||
return true;
|
||||
}
|
||||
if (String(to.path).startsWith('/finance')) {
|
||||
const perms = auth.user?.permissions ?? [];
|
||||
const payroll = to.path.startsWith('/finance/payroll');
|
||||
if (payroll && perms.includes('treasury_payroll')) return true;
|
||||
if (!payroll && (perms.includes('treasury') || perms.includes('treasury_payroll'))) return true;
|
||||
}
|
||||
return { name: 'home' };
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -97,10 +97,46 @@ async function load() {
|
||||
|
||||
async function setStatus(row: ChequeDto, next: ChequeStatus) {
|
||||
if (row.status === next) return;
|
||||
let financialAccountId: string | undefined;
|
||||
if (next === 'cleared' && row.status !== 'cleared') {
|
||||
const accountsRes = await api
|
||||
.get<Array<{ id: string; name: string; isActive: boolean }>>('/treasury/accounts')
|
||||
.catch(() => null);
|
||||
if (!accountsRes) {
|
||||
savingId.value = row.id;
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, { status: next });
|
||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||
} catch {
|
||||
error.value = 'تغییر وضعیت چک ناموفق بود.';
|
||||
} finally {
|
||||
savingId.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const accounts = accountsRes.data;
|
||||
const active = accounts.filter((item) => item.isActive);
|
||||
if (!active.length) {
|
||||
error.value = 'برای وصول چک ابتدا یک حساب صندوق بسازید.';
|
||||
return;
|
||||
}
|
||||
if (active.length === 1) {
|
||||
financialAccountId = active[0]!.id;
|
||||
} else {
|
||||
const picked = window.prompt(`نام حساب صندوق را بنویسید: ${active.map((item) => item.name).join('، ')}`);
|
||||
const match = active.find((item) => item.name.trim() === picked?.trim());
|
||||
if (!match) {
|
||||
error.value = 'حساب صندوق انتخاب نشد.';
|
||||
return;
|
||||
}
|
||||
financialAccountId = match.id;
|
||||
}
|
||||
}
|
||||
savingId.value = row.id;
|
||||
try {
|
||||
const { data } = await api.patch<ChequeDto>(`/cheques/${row.id}`, {
|
||||
status: next,
|
||||
...(financialAccountId ? { financialAccountId } : {}),
|
||||
});
|
||||
items.value = items.value.map((item) => (item.id === row.id ? data : item));
|
||||
} catch {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CreateQuoteRequest,
|
||||
DealDto,
|
||||
DealLineItemInput,
|
||||
FinancialAccountDto,
|
||||
InvoiceDto,
|
||||
InvoiceInstallmentInput,
|
||||
InvoiceStatus,
|
||||
@@ -123,6 +124,8 @@ const lines = ref<LineDraft[]>([]);
|
||||
const installments = ref<InstallmentDraft[]>([]);
|
||||
const invoice = ref<InvoiceDto | null>(null);
|
||||
|
||||
const payAccountId = ref('');
|
||||
const financeAccounts = ref<FinancialAccountDto[]>([]);
|
||||
const payAmount = ref('');
|
||||
const payMethod = ref<PaymentMethod>('transfer');
|
||||
const payNotes = ref('');
|
||||
@@ -463,6 +466,8 @@ async function load() {
|
||||
error.value = '';
|
||||
mobileStep.value = 1;
|
||||
try {
|
||||
const accounts = await api.get<FinancialAccountDto[]>('/treasury/accounts').catch(() => null);
|
||||
financeAccounts.value = accounts?.data.filter((row) => row.isActive) ?? [];
|
||||
title.value = defaultTitle.value;
|
||||
status.value = 'draft';
|
||||
notes.value = '';
|
||||
@@ -740,6 +745,7 @@ async function submitPayment() {
|
||||
notes: payNotes.value || undefined,
|
||||
paidAt: payDate.value ? new Date(payDate.value).toISOString() : undefined,
|
||||
installmentId: payInstallmentId.value || null,
|
||||
financialAccountId: payAccountId.value || null,
|
||||
cheque:
|
||||
payMethod.value === 'check'
|
||||
? {
|
||||
@@ -1316,6 +1322,12 @@ onMounted(load);
|
||||
</option>
|
||||
</select>
|
||||
<input v-model="payNotes" placeholder="یادداشت پرداخت" />
|
||||
<select v-if="financeAccounts.length" v-model="payAccountId">
|
||||
<option value="">بدون اثر روی صندوق</option>
|
||||
<option v-for="account in financeAccounts" :key="account.id" :value="account.id">
|
||||
واریز به {{ account.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="payMethod === 'check'" class="cheque-fields">
|
||||
<input v-model="chequeNumber" placeholder="شماره چک" />
|
||||
<input v-model="chequeBank" placeholder="بانک" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialMovementDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -70,7 +71,7 @@ onMounted(async () => {
|
||||
<article><span>خروجی</span><strong>{{ toman(account.periodOut) }}</strong></article>
|
||||
</div>
|
||||
<div v-if="account" class="reconcile">
|
||||
<UiField label="موجودی واقعی"><UiInput v-model="stated" /></UiField>
|
||||
<UiField label="موجودی واقعی"><MoneyInput v-model="stated" :decimals="0" /></UiField>
|
||||
<UiField label="علت اصلاح"><UiInput v-model="reason" /></UiField>
|
||||
<UiButton variant="secondary" @click="reconcile">ثبت مغایرت</UiButton>
|
||||
<UiButton variant="secondary" @click="adjust('in')">اصلاح ورود</UiButton>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -171,7 +172,7 @@ onMounted(() => {
|
||||
<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="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
|
||||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||||
</div>
|
||||
@@ -192,7 +193,7 @@ onMounted(() => {
|
||||
<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="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
|
||||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||||
</div>
|
||||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -90,6 +91,19 @@ async function createDoc() {
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(id: string, event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/attachments`, body);
|
||||
ok.value = 'پیوست ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
@@ -120,7 +134,7 @@ onMounted(() => {
|
||||
<UiCard title="سند جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="مبلغ کل"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="مبلغ کل"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
|
||||
<UiField label="دستهبندی">
|
||||
<select v-model="form.categoryId" class="select">
|
||||
<option value="">بدون دسته</option>
|
||||
@@ -128,7 +142,7 @@ onMounted(() => {
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField :label="settleLabel"><UiInput v-model="form.settleAmount" /></UiField>
|
||||
<UiField :label="settleLabel"><MoneyInput v-model="form.settleAmount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب حساب</option>
|
||||
@@ -162,6 +176,10 @@ onMounted(() => {
|
||||
<div class="side">
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
<button v-if="row.status !== 'void'" type="button" @click="voidDoc(row.id)">ابطال</button>
|
||||
<label v-if="row.status !== 'void'" class="file">
|
||||
پیوست
|
||||
<input type="file" @change="upload(row.id, $event)" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!rows.length" class="muted">سندی نیست.</p>
|
||||
@@ -177,7 +195,9 @@ onMounted(() => {
|
||||
.list article { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.list small, .muted { color: #64748b; }
|
||||
.side { display: grid; justify-items: end; gap: 0.3rem; }
|
||||
.side button { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.side button, .file { border: 0; background: transparent; color: #b91c1c; cursor: pointer; font: inherit; }
|
||||
.file { color: #0f766e; font-weight: 700; }
|
||||
.file input { display: none; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
@@ -90,10 +91,10 @@ onMounted(() => {
|
||||
</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>
|
||||
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
|
||||
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
|
||||
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
|
||||
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
@@ -109,7 +110,7 @@ onMounted(() => {
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="pay.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -121,8 +122,8 @@ onMounted(() => {
|
||||
<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="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
|
||||
<UiField label="پیشپرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب پیشپرداخت">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
@@ -141,7 +142,7 @@ onMounted(() => {
|
||||
<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="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
@@ -191,7 +192,7 @@ onMounted(() => {
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><UiInput v-model="collect.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
@@ -51,7 +52,7 @@ onMounted(() => {
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><UiInput v-model="form.amount" /></UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
|
||||
Reference in New Issue
Block a user