merge: bring latest origin/main into dev (design local, logic main)
Keep the local Ant screens and icon pack. Take treasury parties, routine self-service, Tahesab auto-sync, public lead registration, and contact merge from main.
This commit is contained in:
@@ -264,10 +264,12 @@ defineExpose({ refresh: load });
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -279,8 +281,9 @@ defineExpose({ refresh: load });
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, 100%);
|
||||
max-height: min(90vh, 640px);
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -76,11 +76,13 @@ const priorityFlag = computed(() =>
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||
opacity: props.dragging ? 0.45 : 1,
|
||||
opacity: props.dragging ? 0.4 : 1,
|
||||
transform: props.dragging ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: token.value.boxShadowTertiary,
|
||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import AddDealButton from './AddDealButton.vue';
|
||||
import DealCard from './DealCard.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -51,6 +51,8 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const bodyEl = ref<HTMLElement | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const renaming = ref(false);
|
||||
const draft = ref('');
|
||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
||||
function trackDrag(event: DragEvent, index: number | null) {
|
||||
event.preventDefault();
|
||||
dragScrollColumn(bodyEl.value, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
|
||||
event.stopPropagation();
|
||||
trackDrag(event, pointerDropIndex(index, event));
|
||||
emit('dragOver', stageId, event);
|
||||
}
|
||||
|
||||
function onColumnDrop(event: DragEvent) {
|
||||
const at = insertAt.value ?? undefined;
|
||||
clearDragUi();
|
||||
emit('drop', props.stage.id, event, at);
|
||||
}
|
||||
|
||||
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
emit('drop', stageId, event, pointerDropIndex(index, event));
|
||||
const at = insertAt.value ?? pointerDropIndex(index, event);
|
||||
clearDragUi();
|
||||
emit('drop', stageId, event, at);
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function startRename() {
|
||||
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<template>
|
||||
<section
|
||||
:style="columnStyle"
|
||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="emit('drop', stage.id, $event)"
|
||||
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="onColumnDrop"
|
||||
>
|
||||
<div :style="headStyle">
|
||||
<span
|
||||
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div
|
||||
v-for="(deal, index) in stage.deals"
|
||||
:key="deal.id"
|
||||
@dragover="onCardDragOver(stage.id, $event)"
|
||||
@dragover="onCardDragOver(stage.id, index, $event)"
|
||||
@drop="onCardDrop(stage.id, index, $event)"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === index)"
|
||||
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
|
||||
/>
|
||||
<DealCard
|
||||
:deal="deal"
|
||||
:accent="stage.color"
|
||||
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
@menu="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
|
||||
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
|
||||
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-if="!stage.deals.length"
|
||||
type="secondary"
|
||||
|
||||
@@ -23,7 +23,7 @@ const links = computed(() => {
|
||||
{ to: '/finance/recurring', label: 'تکرارشونده', section: 'treasury_recurring' },
|
||||
{ to: '/finance/reports', label: 'گزارش', section: 'treasury_reports' },
|
||||
{ to: '/finance/audit', label: 'سوابق', section: 'treasury_audit' },
|
||||
{ to: '/finance/categories', label: 'دستهبندیها', section: 'treasury_categories' },
|
||||
{ to: '/finance/categories', label: 'دستهبندی', section: 'treasury_categories' },
|
||||
];
|
||||
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Table as ATable } from 'ant-design-vue';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { toman } from '@/views/treasury/treasury-format';
|
||||
|
||||
const props = defineProps<{
|
||||
rows: TreasuryPartyBalanceDto[];
|
||||
settledLabel: string;
|
||||
}>();
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: 'طرف حساب', dataIndex: 'partyName', key: 'partyName' },
|
||||
{
|
||||
title: 'نوع',
|
||||
key: 'partyKind',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) =>
|
||||
(record.partyKind && kindLabel[record.partyKind]) || 'ثبت قبلی',
|
||||
},
|
||||
{
|
||||
title: 'مبلغ کل',
|
||||
key: 'amount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.amount),
|
||||
},
|
||||
{
|
||||
title: props.settledLabel,
|
||||
key: 'settledAmount',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.settledAmount),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'remaining',
|
||||
customRender: ({ record }: { record: TreasuryPartyBalanceDto }) => toman(record.remaining),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
:locale="{ emptyText: 'ماندهای نیست.' }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import type { CompanyDto, FinancialPartyKind, PersonDto, TreasuryPartySearchDto } from '@mento/shared';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
|
||||
const VNodes = defineComponent({
|
||||
props: { vnodes: { type: Object, required: true } },
|
||||
render() {
|
||||
return this.vnodes;
|
||||
},
|
||||
});
|
||||
|
||||
const ASelectOption = ASelect.Option;
|
||||
const ASelectOptGroup = ASelect.OptGroup;
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const kind = defineModel<FinancialPartyKind | undefined>('kind');
|
||||
const partyId = defineModel<string | undefined>('partyId');
|
||||
|
||||
type Choice = { id: string; name: string };
|
||||
|
||||
const people = ref<Choice[]>([]);
|
||||
const companies = ref<Choice[]>([]);
|
||||
const users = ref<Choice[]>([]);
|
||||
const labels = ref<Record<string, string>>({});
|
||||
const loading = ref(false);
|
||||
const selectOpen = ref(false);
|
||||
const personOpen = ref(false);
|
||||
const companyOpen = ref(false);
|
||||
|
||||
const selectValue = computed(() => {
|
||||
if (kind.value === 'other') return 'other';
|
||||
if (kind.value && partyId.value) return `${kind.value}:${partyId.value}`;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
function withCurrent(list: Choice[], group: 'person' | 'company' | 'user') {
|
||||
if (kind.value !== group || !partyId.value) return list;
|
||||
if (list.some((row) => row.id === partyId.value)) return list;
|
||||
const name = labels.value[`${group}:${partyId.value}`] || 'انتخابشده';
|
||||
return [{ id: partyId.value, name }, ...list];
|
||||
}
|
||||
|
||||
const peopleOptions = computed(() => withCurrent(people.value, 'person'));
|
||||
const companyOptions = computed(() => withCurrent(companies.value, 'company'));
|
||||
const userOptions = computed(() => withCurrent(users.value, 'user'));
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ثبت ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق بود.';
|
||||
}
|
||||
|
||||
let timer = 0;
|
||||
function onSearch(value: string) {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void load(value);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function load(q = '') {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<TreasuryPartySearchDto>('/treasury/parties', {
|
||||
params: { q: q.trim() || undefined },
|
||||
});
|
||||
people.value = data.people;
|
||||
companies.value = data.companies;
|
||||
users.value = data.users;
|
||||
for (const row of data.people) labels.value[`person:${row.id}`] = row.name;
|
||||
for (const row of data.companies) labels.value[`company:${row.id}`] = row.name;
|
||||
for (const row of data.users) labels.value[`user:${row.id}`] = row.name;
|
||||
} catch (e) {
|
||||
if (!(axios.isAxiosError(e) && e.response?.status === 404)) message.error(apiMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(value: unknown) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
kind.value = undefined;
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
if (value === 'other') {
|
||||
kind.value = 'other';
|
||||
partyId.value = undefined;
|
||||
return;
|
||||
}
|
||||
const splitAt = value.indexOf(':');
|
||||
const nextKind = value.slice(0, splitAt) as FinancialPartyKind;
|
||||
const id = value.slice(splitAt + 1);
|
||||
if (nextKind !== 'person' && nextKind !== 'company' && nextKind !== 'user') return;
|
||||
kind.value = nextKind;
|
||||
partyId.value = id;
|
||||
}
|
||||
|
||||
function onDropdown(open: boolean) {
|
||||
selectOpen.value = open;
|
||||
}
|
||||
|
||||
function openPersonForm() {
|
||||
selectOpen.value = false;
|
||||
personOpen.value = true;
|
||||
}
|
||||
|
||||
function openCompanyForm() {
|
||||
selectOpen.value = false;
|
||||
companyOpen.value = true;
|
||||
}
|
||||
|
||||
function onPersonCreated(person?: PersonDto) {
|
||||
personOpen.value = false;
|
||||
if (!person?.id) return;
|
||||
const name = person.fullName?.trim() || `${person.firstName} ${person.lastName}`.trim();
|
||||
people.value = [{ id: person.id, name }, ...people.value.filter((row) => row.id !== person.id)];
|
||||
labels.value[`person:${person.id}`] = name;
|
||||
kind.value = 'person';
|
||||
partyId.value = person.id;
|
||||
}
|
||||
|
||||
function onCompanyCreated(company?: CompanyDto) {
|
||||
companyOpen.value = false;
|
||||
if (!company?.id) return;
|
||||
companies.value = [{ id: company.id, name: company.name }, ...companies.value.filter((row) => row.id !== company.id)];
|
||||
labels.value[`company:${company.id}`] = company.name;
|
||||
kind.value = 'company';
|
||||
partyId.value = company.id;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-select
|
||||
:open="selectOpen"
|
||||
show-search
|
||||
@dropdownVisibleChange="onDropdown"
|
||||
allow-clear
|
||||
:filter-option="false"
|
||||
:loading="loading"
|
||||
:value="selectValue"
|
||||
placeholder="مشتری، شرکت، کاربر یا سایر"
|
||||
style="width: 100%"
|
||||
@search="onSearch"
|
||||
@change="onChange"
|
||||
>
|
||||
<a-select-option value="other" label="سایر">سایر</a-select-option>
|
||||
<a-select-opt-group v-if="peopleOptions.length" label="مشتریان">
|
||||
<a-select-option
|
||||
v-for="row in peopleOptions"
|
||||
:key="`person:${row.id}`"
|
||||
:value="`person:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="companyOptions.length" label="شرکتها">
|
||||
<a-select-option
|
||||
v-for="row in companyOptions"
|
||||
:key="`company:${row.id}`"
|
||||
:value="`company:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group v-if="userOptions.length" label="کاربران">
|
||||
<a-select-option
|
||||
v-for="row in userOptions"
|
||||
:key="`user:${row.id}`"
|
||||
:value="`user:${row.id}`"
|
||||
:label="row.name"
|
||||
>
|
||||
{{ row.name }}
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<template #dropdownRender="{ menuNode }">
|
||||
<v-nodes :vnodes="menuNode" />
|
||||
<a-flex @mousedown.prevent>
|
||||
<a-button type="link" @click="openPersonForm">شخص جدید</a-button>
|
||||
<a-button type="link" @click="openCompanyForm">شرکت جدید</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-select>
|
||||
<a-typography-text v-if="kind === 'other'" type="secondary">
|
||||
برای سایر، مبلغ دریافت یا پرداخت با مبلغ کل یکی است و مانده نمیماند.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<UiModal :open="personOpen" title="شخص جدید" @close="personOpen = false">
|
||||
<PersonFormView
|
||||
v-if="personOpen"
|
||||
embedded
|
||||
@saved="onPersonCreated"
|
||||
@cancel="personOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="companyOpen" title="شرکت جدید" @close="companyOpen = false">
|
||||
<CompanyFormView
|
||||
v-if="companyOpen"
|
||||
embedded
|
||||
@saved="onCompanyCreated"
|
||||
@cancel="companyOpen = false"
|
||||
/>
|
||||
</UiModal>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref, watch, type Ref } from 'vue';
|
||||
|
||||
/** مبلغ دریافت/پرداخت را با مبلغ کل یکی میکند تا کاربر خودش آن را عوض کند. */
|
||||
export function useSettleDefault(
|
||||
form: Ref<{ amount: string; settleAmount: string; partyKind?: string }>,
|
||||
) {
|
||||
const dirty = ref(false);
|
||||
let mirroring = false;
|
||||
|
||||
function locked() {
|
||||
return form.value.partyKind === 'other';
|
||||
}
|
||||
|
||||
function copy() {
|
||||
mirroring = true;
|
||||
form.value.settleAmount = form.value.amount;
|
||||
mirroring = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.value.amount,
|
||||
() => {
|
||||
if (locked() || !dirty.value) copy();
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.settleAmount,
|
||||
() => {
|
||||
if (!mirroring && !locked()) dirty.value = true;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.value.partyKind,
|
||||
(kind) => {
|
||||
if (kind === 'other') {
|
||||
dirty.value = false;
|
||||
copy();
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
return {
|
||||
reset() {
|
||||
dirty.value = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -431,6 +431,43 @@ export function pointerDropIndex(index: number, event: DragEvent): number {
|
||||
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
||||
}
|
||||
|
||||
const DRAG_SCROLL_EDGE = 72;
|
||||
const DRAG_SCROLL_MAX = 16;
|
||||
let dragScrollEl: HTMLElement | null = null;
|
||||
let dragScrollY = 0;
|
||||
let dragScrollRaf = 0;
|
||||
|
||||
function dragScrollTick() {
|
||||
dragScrollRaf = 0;
|
||||
const el = dragScrollEl;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let delta = 0;
|
||||
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
|
||||
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
|
||||
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
|
||||
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
|
||||
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||
}
|
||||
if (!delta || el.scrollHeight <= el.clientHeight) return;
|
||||
el.scrollTop += delta;
|
||||
dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||
}
|
||||
|
||||
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت میکند تا مقصد دیده شود. */
|
||||
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
|
||||
dragScrollEl = el;
|
||||
dragScrollY = clientY;
|
||||
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||
}
|
||||
|
||||
export function stopDragScroll() {
|
||||
dragScrollEl = null;
|
||||
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
|
||||
dragScrollRaf = 0;
|
||||
}
|
||||
|
||||
export function moveStageToIndex(
|
||||
stages: PipelineStage[],
|
||||
stageId: string,
|
||||
|
||||
@@ -369,8 +369,8 @@ export const router = createRouter({
|
||||
component: () => import('@/views/treasury/TreasuryCategoriesView.vue'),
|
||||
meta: {
|
||||
roles: ['owner', 'admin'] as UserRole[],
|
||||
treasuryTitle: 'دستهبندیهای مالی',
|
||||
treasuryDescription: 'دستههای پیشفرض با اولین بازدید ساخته میشوند.',
|
||||
treasuryTitle: 'دستهبندی',
|
||||
treasuryDescription: 'دستههای درآمد و هزینهٔ همین محیط؛ ساخته و ویرایش فقط از اینجا.',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -40,7 +40,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{ embedded: false },
|
||||
);
|
||||
const emit = defineEmits<{ saved: []; cancel: [] }>();
|
||||
const emit = defineEmits<{ saved: [company?: CompanyDto]; cancel: [] }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -165,7 +165,7 @@ async function submit() {
|
||||
if (isNew.value) {
|
||||
const { data } = await api.post<CompanyDto>('/companies', payload());
|
||||
if (props.embedded) {
|
||||
emit('saved');
|
||||
emit('saved', data);
|
||||
return;
|
||||
}
|
||||
await router.replace({ name: 'company-detail', params: { id: data.id } });
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||
import { CrmSkeletonCard } from '@/components/skeleton';
|
||||
@@ -63,6 +63,8 @@ const addUnqualified = ref(false);
|
||||
const deleteTarget = ref<LeadStageDto | null>(null);
|
||||
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const bodyEls = new Map<string, HTMLElement>();
|
||||
const dropStageId = ref<string | null>(null);
|
||||
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
||||
let suppressCardClick = false;
|
||||
@@ -469,17 +471,44 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function setBodyEl(stageId: string, el: unknown) {
|
||||
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
|
||||
else bodyEls.delete(stageId);
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
window.setTimeout(() => {
|
||||
suppressCardClick = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
function onDragOver(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
dropStageId.value = stageId;
|
||||
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
@@ -488,8 +517,10 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.stopPropagation();
|
||||
const leadId =
|
||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||
const at = index ?? insertAt.value ?? undefined;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
if (!leadId || moving.value) return;
|
||||
|
||||
const fromCol = columns.value.find((c) =>
|
||||
@@ -502,14 +533,14 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
if (!lead) return;
|
||||
|
||||
const same = fromCol.stage.id === stageId;
|
||||
if (same && index == null) return;
|
||||
if (same && at == null) return;
|
||||
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||||
let insertAt = index == null || index < 0 ? 0 : index;
|
||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||
if (same && insertAt === fromIndex) return;
|
||||
let insertIndex = at == null || at < 0 ? 0 : at;
|
||||
if (same && at != null && at > fromIndex) insertIndex -= 1;
|
||||
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
|
||||
if (same && insertIndex === fromIndex) return;
|
||||
const nextLeads = [...without];
|
||||
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
|
||||
const prev = columns.value.map((c) => ({
|
||||
...c,
|
||||
@@ -534,7 +565,7 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
moving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
|
||||
} catch {
|
||||
columns.value = prev;
|
||||
error.value = 'جابهجایی لید ناموفق بود.';
|
||||
@@ -668,24 +699,34 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<div :style="columnBodyStyle">
|
||||
<div
|
||||
:ref="(el) => setBodyEl(col.stage.id, el)"
|
||||
:style="columnBodyStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(lead, index) in col.leads"
|
||||
:key="lead.id"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event)"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
|
||||
/>
|
||||
<a-card
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="cardBodyStyle"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
opacity: draggingId === lead.id ? 0.45 : 1,
|
||||
opacity: draggingId === lead.id ? 0.4 : 1,
|
||||
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
flexShrink: 0,
|
||||
margin: 0,
|
||||
background: token.colorBgContainer,
|
||||
boxShadow: token.boxShadow,
|
||||
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
|
||||
}"
|
||||
@click="openLead(lead)"
|
||||
>
|
||||
@@ -816,6 +857,11 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
|
||||
/>
|
||||
|
||||
<a-typography-text
|
||||
v-if="!col.leads.length"
|
||||
|
||||
@@ -249,17 +249,19 @@ onMounted(() => {
|
||||
<code>X-Api-Key: mk_…</code>
|
||||
یا
|
||||
<code>Authorization: Bearer mk_…</code>
|
||||
— مسیرها:
|
||||
— خواندن:
|
||||
<code>/api/public/v1/people</code>
|
||||
و
|
||||
<code>/api/public/v1/deals</code>
|
||||
— ثبت لید از سایت بیرونی:
|
||||
<code>POST /api/public/v1/leads</code>
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-form layout="vertical" @finish="createKey">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام کلید" required>
|
||||
<a-input v-model:value="newKeyName" placeholder="مثلاً اتصال سایت" />
|
||||
<a-input v-model:value="newKeyName" placeholder="مثلاً نام سایت" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
UpdateTahesabConnectionRequest,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -103,6 +103,91 @@ const syncTablePageSize = ref(50);
|
||||
const syncTablePage = ref(1);
|
||||
|
||||
const syncOnlyNew = ref(false);
|
||||
const autoCount = ref('0');
|
||||
const autoHours = ref<string[]>([]);
|
||||
const autoSyncSaving = ref(false);
|
||||
const AUTO_HOUR_PRESETS: Record<number, number[]> = {
|
||||
1: [8],
|
||||
2: [8, 14],
|
||||
3: [8, 12, 16],
|
||||
4: [8, 12, 16, 20],
|
||||
5: [8, 11, 14, 17, 20],
|
||||
6: [8, 10, 12, 14, 16, 20],
|
||||
};
|
||||
|
||||
const selectedConnection = computed(() =>
|
||||
items.value.find((row) => row.id === sync.connectionId),
|
||||
);
|
||||
|
||||
function hourLabel(hour: number) {
|
||||
return `${toFaDigits(String(hour).padStart(2, '0'))}:۰۰`;
|
||||
}
|
||||
|
||||
const autoCountOptions = [
|
||||
{ value: '0', label: 'خاموش' },
|
||||
...Array.from({ length: 6 }, (_, index) => ({
|
||||
value: String(index + 1),
|
||||
label: `${toFaDigits(index + 1)} بار`,
|
||||
})),
|
||||
];
|
||||
|
||||
const hourOptions = Array.from({ length: 24 }, (_, hour) => ({
|
||||
value: String(hour),
|
||||
label: hourLabel(hour),
|
||||
}));
|
||||
|
||||
function fillAutoSync() {
|
||||
const hours = [...(selectedConnection.value?.customerSyncHours ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 6);
|
||||
autoCount.value = String(hours.length);
|
||||
autoHours.value = hours.map(String);
|
||||
}
|
||||
|
||||
function onAutoCount(value: string) {
|
||||
const count = Math.min(6, Math.max(0, Number(value) || 0));
|
||||
const preset = AUTO_HOUR_PRESETS[count] ?? [];
|
||||
const next = autoHours.value.slice(0, count);
|
||||
while (next.length < count) {
|
||||
next.push(String(preset[next.length] ?? 8));
|
||||
}
|
||||
autoCount.value = String(count);
|
||||
autoHours.value = next;
|
||||
}
|
||||
|
||||
function setAutoHour(index: number, value: string) {
|
||||
autoHours.value[index] = value;
|
||||
}
|
||||
|
||||
async function saveAutoSync() {
|
||||
if (!sync.connectionId) return;
|
||||
const hours = autoHours.value.map((hour) => Number(hour));
|
||||
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
|
||||
error.value = 'ساعت باید بین ۰ و ۲۳ باشد.';
|
||||
return;
|
||||
}
|
||||
if (new Set(hours).size !== hours.length) {
|
||||
error.value = 'ساعتها نباید تکراری باشند.';
|
||||
return;
|
||||
}
|
||||
autoSyncSaving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/integrations/tahesab/connections/${sync.connectionId}`, {
|
||||
customerSyncHours: hours,
|
||||
});
|
||||
ok.value = hours.length
|
||||
? 'زمانبندی بروزرسانی ذخیره شد.'
|
||||
: 'بروزرسانی خودکار خاموش شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'ذخیره زمانبندی ناموفق بود');
|
||||
} finally {
|
||||
autoSyncSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const syncVisibleResults = computed(() =>
|
||||
syncOnlyNew.value
|
||||
? syncResults.value.filter((r) => !r.alreadyLinked)
|
||||
@@ -115,6 +200,7 @@ const syncPagedResults = computed(() => {
|
||||
const start = (syncTablePage.value - 1) * syncTablePageSize.value;
|
||||
return syncVisibleResults.value.slice(start, start + syncTablePageSize.value);
|
||||
});
|
||||
watch(() => sync.connectionId, () => fillAutoSync());
|
||||
watch([syncOnlyNew, syncTablePageSize], () => {
|
||||
syncTablePage.value = 1;
|
||||
});
|
||||
@@ -207,6 +293,7 @@ async function load() {
|
||||
if (!editingId.value) form.isDefault = data.length === 0;
|
||||
if (!sync.connectionId && data[0]) sync.connectionId = data[0].id;
|
||||
if (!productSync.connectionId && data[0]) productSync.connectionId = data[0].id;
|
||||
fillAutoSync();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
@@ -964,6 +1051,53 @@ onMounted(load);
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-card type="inner" title="بروزرسانی خودکار">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعتها، به وقت تهران،
|
||||
مشتریان تازه را وارد میکند و نام مشتریهای قبلی را هم با تهحساب تازه میکند. اگر شماره یکی باشد
|
||||
همان مشتری میماند، نام قبلی حفظ میشود و شماره به شکل ۰۹۱۲ ذخیره میشود. خاموش یعنی فقط ورود دستی.
|
||||
</a-typography-text>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تعداد در روز">
|
||||
<a-select
|
||||
:value="autoCount"
|
||||
:options="autoCountOptions"
|
||||
:disabled="!sync.connectionId || autoSyncSaving"
|
||||
@update:value="(value) => onAutoCount(String(value ?? '0'))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-for="(_, index) in autoHours" :key="index" :xs="24" :md="8">
|
||||
<a-form-item :label="`ساعت ${toFaDigits(index + 1)}`">
|
||||
<a-select
|
||||
:value="autoHours[index]"
|
||||
:options="hourOptions"
|
||||
:disabled="autoSyncSaving"
|
||||
@update:value="(value) => setAutoHour(index, String(value ?? '8'))"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button :loading="autoSyncSaving" :disabled="!sync.connectionId" @click="saveAutoSync">
|
||||
ذخیره زمانبندی
|
||||
</a-button>
|
||||
<a-typography-text v-if="selectedConnection?.customerSyncLastRunAt" type="secondary">
|
||||
آخرین اجرا:
|
||||
{{ formatJalaliDateTime(new Date(selectedConnection.customerSyncLastRunAt)) }}
|
||||
<template v-if="selectedConnection.customerSyncLastSummary">
|
||||
— {{ toFaDigits(selectedConnection.customerSyncLastSummary) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="selectedConnection?.customerSyncLastError" type="danger">
|
||||
آخرین خطا: {{ selectedConnection.customerSyncLastError }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-card type="inner" title="۱) وارد کردن دستهای با بازه کد مشتری">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-typography-text type="secondary">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -30,13 +30,18 @@ import RecurrencePicker, { type RecurrenceValue } from '@/components/routines/Re
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isManager = computed(() => !!shell.context.value?.isManager);
|
||||
const selfId = computed(() => auth.user?.id ?? '');
|
||||
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
|
||||
|
||||
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
|
||||
const isEdit = computed(() => !!id.value);
|
||||
@@ -76,6 +81,8 @@ const recurrence = ref<RecurrenceValue>({
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const loaded = ref<RoutineDto | null>(null);
|
||||
const foreign = ref(false);
|
||||
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const entityLinks = Object.entries(ROUTINE_ENTITY_LINK_LABELS) as Array<[RoutineEntityLink, string]>;
|
||||
@@ -140,10 +147,14 @@ function fill(r: RoutineDto) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!id.value) return;
|
||||
if (!id.value) {
|
||||
if (selfId.value) form.assigneeId = selfId.value;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
|
||||
loaded.value = data;
|
||||
fill(data);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'روتین پیدا نشد.');
|
||||
@@ -152,6 +163,23 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => shell.context.value, loaded, selfId],
|
||||
() => {
|
||||
const ctx = shell.context.value;
|
||||
const data = loaded.value;
|
||||
if (!ctx || !data || ctx.isManager) {
|
||||
foreign.value = false;
|
||||
return;
|
||||
}
|
||||
foreign.value = data.createdById !== selfId.value;
|
||||
if (foreign.value) {
|
||||
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش میتواند تغییرش دهد.';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const users = computed(() => shell.context.value?.users ?? []);
|
||||
const teams = computed(() => shell.context.value?.teams ?? []);
|
||||
const categories = computed(() => shell.context.value?.categories ?? []);
|
||||
@@ -198,6 +226,11 @@ const summary = computed(() => {
|
||||
});
|
||||
|
||||
function validate(): string | null {
|
||||
if (!isManager.value) {
|
||||
form.target = 'user';
|
||||
form.assigneeId = selfId.value;
|
||||
form.teamId = '';
|
||||
}
|
||||
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
|
||||
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
|
||||
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
|
||||
@@ -249,7 +282,7 @@ async function save() {
|
||||
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
|
||||
else await api.post('/routines', body);
|
||||
shell.notifyChanged();
|
||||
void router.push('/routines/manage');
|
||||
void router.push(backTo.value);
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
|
||||
} finally {
|
||||
@@ -263,7 +296,7 @@ async function save() {
|
||||
<a-flex vertical :gap="4">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink to="/routines/manage">مدیریت روتینها</RouterLink>
|
||||
<RouterLink :to="backTo">{{ isManager ? 'مدیریت روتینها' : 'روتینهای من' }}</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
@@ -275,7 +308,9 @@ async function save() {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<CrmSkeleton :loading="loading">
|
||||
<a-alert v-if="foreign" type="error" show-icon :message="error" />
|
||||
|
||||
<CrmSkeleton v-if="!foreign" :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-row :gutter="[16, 16]">
|
||||
@@ -323,6 +358,10 @@ async function save() {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" title="مسئول">
|
||||
<a-typography-text v-if="!isManager" type="secondary">
|
||||
این روتین فقط برای خودتان ساخته میشود.
|
||||
</a-typography-text>
|
||||
<template v-else>
|
||||
<a-form-item>
|
||||
<a-radio-group v-model:value="form.target" button-style="solid">
|
||||
<a-radio-button value="user">یک کاربر</a-radio-button>
|
||||
@@ -354,6 +393,7 @@ async function save() {
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" title="زمانبندی و تکرار">
|
||||
@@ -484,7 +524,7 @@ async function save() {
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">
|
||||
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد روتین' }}
|
||||
</a-button>
|
||||
<a-button block :disabled="saving" @click="router.push('/routines/manage')">
|
||||
<a-button block :disabled="saving" @click="router.push(backTo)">
|
||||
انصراف
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineDto } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
@@ -35,6 +37,8 @@ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyPa
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const shell = inject(ROUTINES_CTX)!;
|
||||
const auth = useAuthStore();
|
||||
const meId = computed(() => auth.user?.id ?? '');
|
||||
|
||||
const items = ref<RoutineDto[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -90,7 +94,7 @@ const TONE_COLORS: Record<string, string> = {
|
||||
<a-row :gutter="[16, 12]" align="middle">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-typography-text type="secondary">
|
||||
روتینهایی که به شما یا تیم شما سپرده شدهاند. کارهای هر روز از روی اینها ساخته میشوند.
|
||||
روتینهای شما. کارهای هر روز از روی اینها ساخته میشوند. با «روتین جدید» میتوانید برای خودتان برنامه تکراری بسازید.
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -116,7 +120,7 @@ const TONE_COLORS: Record<string, string> = {
|
||||
:description="
|
||||
q
|
||||
? 'روتینی با این عنوان پیدا نشد'
|
||||
: 'هنوز روتینی برای شما تعریف نشده؛ روتینها توسط مدیر تیم تعریف میشوند و هر روز بهصورت خودکار در «کارهای امروز» ظاهر میشوند.'
|
||||
: 'هنوز روتینی برای شما نیست. با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده میشود.'
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -170,9 +174,14 @@ const TONE_COLORS: Record<string, string> = {
|
||||
:show-info="false"
|
||||
:stroke-color="TONE_COLORS[percentTone(r.executionRate)]"
|
||||
/>
|
||||
<a-typography-text type="secondary">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</a-typography-text>
|
||||
<a-flex justify="space-between" align="center" :gap="8">
|
||||
<a-typography-text type="secondary">
|
||||
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
|
||||
</a-typography-text>
|
||||
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`">
|
||||
ویرایش
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
@@ -98,7 +98,7 @@ const tabs: Tab[] = [
|
||||
|
||||
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
|
||||
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
|
||||
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
|
||||
|
||||
watch(
|
||||
[() => route.name, context],
|
||||
@@ -145,7 +145,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
|
||||
>
|
||||
<template #icon><BellOutlined /></template>
|
||||
</a-button>
|
||||
<a-button v-if="isManager" @click="router.push('/routines/manage/new')">
|
||||
<a-button @click="router.push('/routines/manage/new')">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
روتین جدید
|
||||
</a-button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
@@ -13,20 +13,38 @@ import {
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Popconfirm as APopconfirm,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const form = ref({ name: '', kind: 'expense' as 'income' | 'expense' });
|
||||
const form = ref({ name: '', kind: 'income' as 'income' | 'expense' });
|
||||
const error = ref('');
|
||||
const saving = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editSaving = ref(false);
|
||||
const edit = ref({ id: '', name: '', isActive: true });
|
||||
|
||||
const incomeRows = computed(() => rows.value.filter((row) => row.kind === 'income'));
|
||||
const expenseRows = computed(() => rows.value.filter((row) => row.kind === 'expense'));
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<FinancialCategoryDto[]>('/treasury/categories');
|
||||
@@ -35,6 +53,7 @@ async function load() {
|
||||
|
||||
async function add() {
|
||||
error.value = '';
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/treasury/categories', {
|
||||
name: form.value.name,
|
||||
@@ -43,66 +62,160 @@ async function add() {
|
||||
form.value.name = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String((e.response?.data as { message?: string })?.message ?? 'ثبت ناموفق بود.')
|
||||
: 'ثبت ناموفق بود.';
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(item: FinancialCategoryDto) {
|
||||
edit.value = { id: item.id, name: item.name, isActive: item.isActive };
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function remove(item: FinancialCategoryDto) {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/treasury/categories/${item.id}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
const name = edit.value.name.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return Promise.reject(new Error('invalid'));
|
||||
}
|
||||
editSaving.value = true;
|
||||
error.value = '';
|
||||
return api
|
||||
.patch(`/treasury/categories/${edit.value.id}`, {
|
||||
name,
|
||||
isActive: edit.value.isActive,
|
||||
})
|
||||
.then(() => load())
|
||||
.catch((e: unknown) => {
|
||||
error.value = apiMessage(e);
|
||||
return Promise.reject(e);
|
||||
})
|
||||
.finally(() => {
|
||||
editSaving.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-typography-text type="secondary">
|
||||
دستهها فقط برای همین محیط هستند و در فرم درآمد و هزینه نمایش داده میشوند.
|
||||
</a-typography-text>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<a-form layout="vertical" :model="form" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نام"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">دسته جدید</a-divider>
|
||||
<a-form layout="vertical" :model="form" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item
|
||||
label="نام"
|
||||
name="name"
|
||||
:rules="[{ required: true, message: 'نام الزامی است' }]"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block :loading="saving">افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">درآمد</a-divider>
|
||||
<a-empty v-if="!incomeRows.length" description="دسته درآمدی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="incomeRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
|
||||
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
|
||||
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</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="!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-divider orientation="right">هزینه</a-divider>
|
||||
<a-empty v-if="!expenseRows.length" description="دسته هزینهای نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="expenseRows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
<a-button type="link" @click="openEdit(item)">ویرایش</a-button>
|
||||
<a-popconfirm
|
||||
title="این دسته حذف شود؟ سندهای قبلی بدون دسته میمانند."
|
||||
ok-text="حذف"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ danger: true }"
|
||||
@confirm="remove(item)"
|
||||
>
|
||||
<a-button type="link" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editOpen"
|
||||
title="ویرایش دسته"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="editSaving"
|
||||
@ok="saveEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="edit.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="فعال در فرمها">
|
||||
<a-switch v-model:checked="edit.isActive" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -19,8 +19,17 @@ import {
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryPartyBalanceDto,
|
||||
} from '@mento/shared';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
|
||||
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
@@ -31,6 +40,7 @@ const ASelectOption = ASelect.Option;
|
||||
|
||||
const kind = 'expense' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
@@ -41,13 +51,14 @@ const form = ref({
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
partyKind: undefined as FinancialPartyKind | undefined,
|
||||
partyId: undefined as string | undefined,
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
const settleMirror = useSettleDefault(form);
|
||||
|
||||
const settleLabel = 'مبلغ پرداختشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -71,13 +82,29 @@ function apiMessage(e: unknown) {
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc] = await Promise.all([
|
||||
const [docs, cats, acc, bals] = 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<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
balances.value = bals.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
@@ -96,31 +123,44 @@ async function createDoc() {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||
error.value = 'طرف حساب را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||
error.value = 'برای سایر، مبلغ پرداختشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
partyKind: form.value.partyKind,
|
||||
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
|
||||
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
|
||||
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
settleMirror.reset();
|
||||
form.value = {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
partyKind: undefined,
|
||||
partyId: undefined,
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -180,7 +220,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -198,8 +238,8 @@ onMounted(() => {
|
||||
</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 label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -225,15 +265,13 @@ onMounted(() => {
|
||||
</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>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="پرداختشده" />
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
@@ -242,7 +280,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -268,11 +306,7 @@ onMounted(() => {
|
||||
</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>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Grid,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
@@ -34,10 +33,7 @@ import {
|
||||
CreditCardOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
NumberOutlined,
|
||||
PlusOutlined,
|
||||
TagOutlined,
|
||||
UserOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
@@ -45,10 +41,15 @@ import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
FinancialPartyKind,
|
||||
TreasuryDashboardDto,
|
||||
TreasuryPartyBalanceDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import TreasuryPartySelect from '@/components/treasury/TreasuryPartySelect.vue';
|
||||
import { useSettleDefault } from '@/components/treasury/useSettleDefault';
|
||||
import { toman } from './treasury-format';
|
||||
import { parseMoneyNumber } from '@/lib/money';
|
||||
|
||||
@@ -62,6 +63,7 @@ const isMobile = computed(() => !screens.value.md);
|
||||
|
||||
const kind = 'income' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const balances = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const dashboard = ref<TreasuryDashboardDto | null>(null);
|
||||
@@ -70,9 +72,6 @@ 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() {
|
||||
@@ -80,17 +79,18 @@ function emptyForm() {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: undefined as string | undefined,
|
||||
partyName: '',
|
||||
partyKind: undefined as FinancialPartyKind | undefined,
|
||||
partyId: undefined as string | undefined,
|
||||
accountId: undefined as string | undefined,
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
bookedAt: formatLocalDateInput(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
const form = ref(emptyForm());
|
||||
const settleMirror = useSettleDefault(form);
|
||||
|
||||
const settleLabel = 'مبلغ دریافتشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -137,20 +137,37 @@ function apiMessage(e: unknown) {
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
function docLine(item: FinancialDocumentDto) {
|
||||
const parts = [
|
||||
item.reference ? `پیگیری ${item.reference}` : '',
|
||||
item.partyName || 'بدون طرف حساب',
|
||||
statusLabel[item.status] ?? item.status,
|
||||
`مانده ${toman(item.remaining)}`,
|
||||
].filter(Boolean);
|
||||
if (item.categoryName) parts.push(item.categoryName);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc, dash] = await Promise.all([
|
||||
const [docs, cats, acc, dash, bals] = 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),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind } }).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) return { data: [] as TreasuryPartyBalanceDto[] };
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
balances.value = bals.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
if (dash) dashboard.value = dash.data;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
settleMirror.reset();
|
||||
form.value = emptyForm();
|
||||
formTab.value = 'main';
|
||||
error.value = '';
|
||||
@@ -167,22 +184,34 @@ async function createDoc() {
|
||||
error.value = 'مبلغ کل معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!form.value.partyKind || (form.value.partyKind !== 'other' && !form.value.partyId)) {
|
||||
error.value = 'طرف حساب را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
const settleAmount = parseMoneyNumber(String(form.value.settleAmount ?? ''), 0) ?? 0;
|
||||
await api.post('/treasury/documents', {
|
||||
if (form.value.partyKind === 'other' && settleAmount !== amount) {
|
||||
error.value = 'برای سایر، مبلغ دریافتشده باید با مبلغ کل یکی باشد.';
|
||||
return;
|
||||
}
|
||||
const { data } = await api.post<FinancialDocumentDto>('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
partyKind: form.value.partyKind,
|
||||
personId: form.value.partyKind === 'person' ? form.value.partyId : null,
|
||||
companyId: form.value.partyKind === 'company' ? form.value.partyId : null,
|
||||
userId: form.value.partyKind === 'user' ? form.value.partyId : null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
bookedAt: form.value.bookedAt || undefined,
|
||||
});
|
||||
resetForm();
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
ok.value = data.reference
|
||||
? `سند ثبت شد. شماره پیگیری: ${data.reference}`
|
||||
: 'سند ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
@@ -211,30 +240,6 @@ async function voidDoc(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategoryQuick() {
|
||||
const name = newCategoryName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return;
|
||||
}
|
||||
categorySaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
|
||||
name,
|
||||
kind,
|
||||
});
|
||||
await load();
|
||||
form.value.categoryId = data.id;
|
||||
newCategoryName.value = '';
|
||||
categoryModalOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
categorySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
@@ -358,10 +363,8 @@ onMounted(() => {
|
||||
|
||||
<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 label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
@@ -393,21 +396,16 @@ onMounted(() => {
|
||||
<span>دستهبندی درآمد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-flex :gap="8">
|
||||
<a-select show-search option-filter-prop="label"
|
||||
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" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" ghost @click="categoryModalOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-select show-search option-filter-prop="label"
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
@@ -426,7 +424,12 @@ onMounted(() => {
|
||||
name="settleAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||
>
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" placeholder="۰" />
|
||||
<MoneyInput
|
||||
v-model="form.settleAmount"
|
||||
:decimals="0"
|
||||
:disabled="form.partyKind === 'other'"
|
||||
placeholder="۰"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
@@ -493,12 +496,6 @@ onMounted(() => {
|
||||
</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"
|
||||
@@ -511,13 +508,15 @@ onMounted(() => {
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -540,11 +539,7 @@ onMounted(() => {
|
||||
</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>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -614,7 +609,7 @@ onMounted(() => {
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" />
|
||||
<MoneyInput v-model="form.settleAmount" :decimals="0" :disabled="form.partyKind === 'other'" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -632,8 +627,8 @@ onMounted(() => {
|
||||
</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 label="طرف حساب" name="partyKind">
|
||||
<TreasuryPartySelect v-model:kind="form.partyKind" v-model:party-id="form.partyId" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
@@ -674,11 +669,6 @@ onMounted(() => {
|
||||
<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" />
|
||||
@@ -688,6 +678,8 @@ onMounted(() => {
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">مانده دریافتنی</a-divider>
|
||||
<TreasuryPartyBalanceTable :rows="balances" settled-label="دریافتشده" />
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
@@ -696,7 +688,7 @@ onMounted(() => {
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
placeholder="عنوان یا شماره پیگیری"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
@@ -722,11 +714,7 @@ onMounted(() => {
|
||||
</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>
|
||||
<template #description>{{ docLine(item) }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -735,23 +723,5 @@ onMounted(() => {
|
||||
</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,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
@@ -9,16 +10,21 @@ import {
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Tabs as ATabs,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@/icons/ant-bridge';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { TreasuryPartyBalanceDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import TreasuryPartyBalanceTable from '@/components/treasury/TreasuryPartyBalanceTable.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ATabPane = ATabs.TabPane;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
@@ -41,7 +47,17 @@ const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
const receivablesByParty = ref<Array<{ name: string; amount: string }>>([]);
|
||||
const debtors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const creditors = ref<TreasuryPartyBalanceDto[]>([]);
|
||||
const reportTab = ref('periods');
|
||||
const error = ref('');
|
||||
const balanceError = ref('');
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
person: 'مشتری',
|
||||
company: 'شرکت',
|
||||
user: 'کاربر',
|
||||
};
|
||||
|
||||
const lines = [
|
||||
['درآمد ثبتشده', 'bookedIncome'],
|
||||
@@ -81,6 +97,50 @@ async function load() {
|
||||
receivablesByParty.value = data.receivablesByParty;
|
||||
}
|
||||
|
||||
function sumRemaining(rows: TreasuryPartyBalanceDto[]) {
|
||||
return rows.reduce((sum, row) => sum + Number(row.remaining || 0), 0).toFixed(2);
|
||||
}
|
||||
|
||||
function exportParties(
|
||||
rows: TreasuryPartyBalanceDto[],
|
||||
title: string,
|
||||
settledLabel: string,
|
||||
filename: string,
|
||||
) {
|
||||
const body = rows.map((row) => [
|
||||
row.partyName,
|
||||
(row.partyKind && kindLabel[row.partyKind]) || 'ثبت قبلی',
|
||||
Number(row.amount),
|
||||
Number(row.settledAmount),
|
||||
Number(row.remaining),
|
||||
]);
|
||||
const totals = rows.reduce(
|
||||
(sum, row) => ({
|
||||
amount: sum.amount + Number(row.amount || 0),
|
||||
settled: sum.settled + Number(row.settledAmount || 0),
|
||||
remaining: sum.remaining + Number(row.remaining || 0),
|
||||
}),
|
||||
{ amount: 0, settled: 0, remaining: 0 },
|
||||
);
|
||||
body.push(['جمع', '', totals.amount, totals.settled, totals.remaining]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([
|
||||
['طرف حساب', 'نوع', 'مبلغ کل', settledLabel, 'مانده'],
|
||||
...body,
|
||||
]);
|
||||
const book = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(book, sheet, title);
|
||||
XLSX.writeFile(book, filename);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
const [income, expense] = await Promise.all([
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'income' } }),
|
||||
api.get<TreasuryPartyBalanceDto[]>('/treasury/party-balances', { params: { kind: 'expense' } }),
|
||||
]);
|
||||
debtors.value = income.data;
|
||||
creditors.value = expense.data;
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const { data } = await api.get<Blob>('/treasury/reports/export', { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
@@ -95,6 +155,14 @@ onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'گزارش بارگذاری نشد.';
|
||||
});
|
||||
void loadBalances().catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && (e.response?.status === 404 || e.response?.status === 403)) {
|
||||
debtors.value = [];
|
||||
creditors.value = [];
|
||||
return;
|
||||
}
|
||||
balanceError.value = 'مانده بدهکاران و بستانکاران بارگذاری نشد.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -108,9 +176,12 @@ onMounted(() => {
|
||||
</a-flex>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="balanceError" type="error" show-icon :message="balanceError" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مقایسه دورهها</a-divider>
|
||||
<a-tabs v-model:activeKey="reportTab">
|
||||
<a-tab-pane key="periods" tab="مقایسه دورهها">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-table
|
||||
v-if="metricRows.length"
|
||||
:columns="metricColumns"
|
||||
@@ -168,6 +239,39 @@ onMounted(() => {
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="debtors" tab="بدهکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ debtors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(debtors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(debtors, 'بدهکاران', 'دریافتشده', 'debtors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="debtors" settled-label="دریافتشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="creditors" tab="بستانکاران">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-typography-text type="secondary">
|
||||
{{ creditors.length }} طرف حساب · جمع مانده {{ toman(sumRemaining(creditors)) }}
|
||||
</a-typography-text>
|
||||
<a-button @click="exportParties(creditors, 'بستانکاران', 'پرداختشده', 'creditors.xlsx')">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<TreasuryPartyBalanceTable :rows="creditors" settled-label="پرداختشده" />
|
||||
</a-flex>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +30,9 @@ function doc(
|
||||
notes: null,
|
||||
createdAt: daysFromNow(-2),
|
||||
...partial,
|
||||
partyKind: partial.partyKind ?? null,
|
||||
companyId: partial.companyId ?? null,
|
||||
userId: partial.userId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user