merge: bring latest origin/main into dev (design local, logic main)

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 10:26:43 +03:30
co-authored by Cursor
41 changed files with 1388 additions and 221 deletions
@@ -1,16 +1,28 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { Tabs as ATabs, TabPane as ATabPane, Typography } from 'ant-design-vue';
import {
Tabs as ATabs,
TabPane as ATabPane,
Typography,
Radio as ARadio,
RadioGroup as ARadioGroup,
Checkbox as ACheckbox,
Space as ASpace,
Input as AInput,
} from 'ant-design-vue';
import type {
DataAccessPolicy,
DataAccessSection,
FinancialAccountDto,
PersonImportSourceStatDto,
RolePermissionKey,
SectionDataAccess,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
@@ -24,11 +36,16 @@ const props = defineProps<{
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
/** تب‌های عمق داده قفل شوند؛ تب مدیریت مالی جدا می‌ماند */
dataLocked?: boolean;
showTreasury?: boolean;
treasuryAccess?: UserTreasuryAccess | null;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
'update:treasuryAccess': [UserTreasuryAccess | null];
}>();
/** بخش‌هایی که به RolePermissionKey وصل‌اند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده می‌شوند */
@@ -50,12 +67,13 @@ const visibleSections = computed(() => {
});
});
const active = ref<DataAccessSection>('customers');
const treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
const active = ref<DataAccessSection | 'treasury'>('customers');
watch(
visibleSections,
(list) => {
if (!list.includes(active.value) && list[0]) active.value = list[0];
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
@@ -92,6 +110,52 @@ const activityTypes = ref<IdOption[]>([]);
const projectBoards = ref<IdOption[]>([]);
const projectStages = ref<IdOption[]>([]);
const routineCategories = ref<IdOption[]>([]);
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
const treasuryAccountQuery = ref('');
const filteredTreasuryAccounts = computed(() => {
const query = treasuryAccountQuery.value.trim();
if (!query) return treasuryAccounts.value;
return treasuryAccounts.value.filter((account) => account.name.includes(query));
});
function emptyTreasuryAccess(): UserTreasuryAccess {
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
}
function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next);
}
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
}
function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key)
? current.sections.filter((item) => item !== key)
: [...current.sections, key];
patchTreasury({ ...current, enabled: true, sections });
}
function toggleTreasuryAccount(id: string) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const accountIds = current.accountIds.includes(id)
? current.accountIds.filter((item) => item !== id)
: [...current.accountIds, id];
patchTreasury({ ...current, enabled: true, accountIds });
}
async function loadTreasuryAccounts() {
if (!props.showTreasury) return;
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
treasuryAccounts.value = data;
} catch {
treasuryAccounts.value = [];
}
}
async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
@@ -179,6 +243,7 @@ async function loadOptions() {
onMounted(() => {
void loadOptions();
void loadTreasuryAccounts();
});
</script>
@@ -204,11 +269,81 @@ onMounted(() => {
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(s, $event)"
/>
</a-tab-pane>
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
<a-space direction="vertical">
<a-typography-text type="secondary">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</a-typography-text>
<a-radio-group
:value="treasuryAccess == null ? 'role' : 'custom'"
:disabled="disabled"
@change="onTreasuryModeChange"
>
<a-space direction="vertical">
<a-radio value="role">مثل نقش</a-radio>
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
</a-space>
</a-radio-group>
<template v-if="treasuryAccess">
<a-checkbox
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
>
دسترسی به مدیریت مالی
</a-checkbox>
<template v-if="treasuryAccess.enabled">
<a-space direction="vertical">
<a-checkbox
v-for="perm in treasurySections"
:key="perm.key"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</a-space>
<a-checkbox
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
>
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="treasuryAccountQuery"
placeholder="جستجوی حساب"
:disabled="disabled"
allow-clear
/>
<a-space direction="vertical">
<a-checkbox
v-for="account in filteredTreasuryAccounts"
:key="account.id"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</a-checkbox>
</a-space>
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
حسابی برای انتخاب نیست.
</a-typography-text>
</template>
</template>
</a-space>
</a-tab-pane>
</a-tabs>
</div>
</template>
</template>
+13 -4
View File
@@ -11,6 +11,7 @@ import type {
TeamDto,
TeamMemberRole,
UpdatePriorArchiveCutoffRequest,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
@@ -334,8 +335,13 @@ async function removeTeam(id: string) {
}
}
const showTreasury = computed(() => {
const modules = auth.user?.enabledModules;
return !modules || modules.includes('treasury');
});
const expandedAccess = ref<string | null>(null);
const draftAccess = ref<DataAccessPolicy>({});
const draftTreasury = ref<UserTreasuryAccess | null>(null);
const accessMode = ref<'inherit' | 'custom'>('inherit');
const accessSaving = ref(false);
@@ -378,6 +384,7 @@ function openAccessEditor(u: OrgUserDto) {
draftAccess.value = hasOverride
? { ...clonePolicy(u.effectiveDataAccess), ...clonePolicy(override) }
: clonePolicy(u.effectiveDataAccess);
draftTreasury.value = u.treasuryAccessOverride ?? null;
expandedAccess.value = u.id;
}
@@ -388,8 +395,8 @@ async function saveUserAccess(userId: string) {
try {
const payload =
accessMode.value === 'inherit'
? { dataAccessOverride: null }
: { dataAccessOverride: draftAccess.value };
? { dataAccessOverride: null, treasuryAccessOverride: draftTreasury.value }
: { dataAccessOverride: draftAccess.value, treasuryAccessOverride: draftTreasury.value };
await api.patch(`/users/${userId}/data-access`, payload);
ok.value = 'محدوده داده به‌روز شد.';
expandedAccess.value = null;
@@ -585,7 +592,7 @@ onMounted(() => {
<a-collapse-panel key="access" header="محدوده داده کاربران">
<a-flex vertical :gap="8">
<a-typography-text type="secondary">
پیش‌فرض از نقش است؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید.
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید.
</a-typography-text>
<a-empty
v-if="!editableUsers.length"
@@ -612,8 +619,10 @@ onMounted(() => {
</a-space>
</a-radio-group>
<DataAccessPolicyPanel
v-if="accessMode === 'custom'"
v-model="draftAccess"
v-model:treasury-access="draftTreasury"
:show-treasury="showTreasury"
:data-locked="accessMode === 'inherit'"
compact
/>
<a-space>
@@ -121,6 +121,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
assigneeNames,
ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority,
};
}
@@ -254,40 +255,54 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId);
if (!fromStageId || fromStageId === toStageId) return;
if (!fromStageId) return;
const from = columns.value[fromStageId]!;
const to = columns.value[toStageId];
if (!to) return;
const item = from.items.find((i) => i.id === activityId)!;
const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex];
if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!;
const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return;
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;
const snapshot = {
fromItems: from.items,
fromTotal: from.total,
toItems: to.items,
toTotal: to.total,
};
const prevFrom = from.items;
const prevTo = to.items;
const prevFromTotal = from.total;
const prevToTotal = to.total;
const optimistic: ActivityDto = { ...item, status: toStage.kind, stageId: toStageId };
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
const nextTo = [...to.items];
const at = index == null || index < 0 || index > nextTo.length ? 0 : index;
nextTo.splice(at, 0, optimistic);
to.items = nextTo;
to.total += 1;
const nextTo = [...without];
nextTo.splice(insertAt, 0, optimistic);
if (same) {
from.items = nextTo;
} else {
from.items = from.items.filter((i) => i.id !== activityId);
from.total = Math.max(0, from.total - 1);
to.items = nextTo;
to.total += 1;
}
moving.value = true;
try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId,
position: insertAt,
});
to.items = to.items.map((i) => (i.id === activityId ? data : i));
const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed');
} catch (e) {
from.items = snapshot.fromItems;
from.total = snapshot.fromTotal;
to.items = snapshot.toItems;
to.total = snapshot.toTotal;
from.items = prevFrom;
from.total = prevFromTotal;
if (!same) {
to.items = prevTo;
to.total = prevToTotal;
}
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally {
moving.value = false;
@@ -4,6 +4,7 @@ import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
FlagFilled,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
@@ -13,10 +14,12 @@ import {
Flex as AFlex,
Space as ASpace,
Tag as ATag,
Tooltip as ATooltip,
Typography,
theme,
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { ActivityPriority } from '@mento/shared';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, formatCurrencyParts } from '@/lib/pipeline-board';
@@ -60,6 +63,16 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const PRIORITY_FLAG: Record<ActivityPriority, { label: string; color: string }> = {
very_high: { label: 'خیلی زیاد', color: '#dc2626' },
high: { label: 'زیاد', color: '#ea580c' },
medium: { label: 'متوسط', color: '#eab308' },
low: { label: 'کم', color: '#16a34a' },
very_low: { label: 'خیلی کم', color: '#94a3b8' },
};
const priorityFlag = computed(() =>
props.deal.priority ? PRIORITY_FLAG[props.deal.priority] : null,
);
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
@@ -194,6 +207,11 @@ const cardStyle = computed(() => ({
{{ money.unit }}
</a-typography-text>
</a-typography-text>
<a-tooltip v-if="priorityFlag" :title="`اولویت: ${priorityFlag.label}`">
<span :style="{ display: 'inline-flex', flexShrink: 0, lineHeight: 1 }">
<FlagFilled :style="{ color: priorityFlag.color, fontSize: '16px' }" />
</span>
</a-tooltip>
</a-flex>
</a-flex>
</a-card>
@@ -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 } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
const props = withDefaults(
defineProps<{
@@ -115,7 +115,7 @@ function onCardDragOver(stageId: string, event: DragEvent) {
function onCardDrop(stageId: string, index: number, event: DragEvent) {
event.preventDefault();
event.stopPropagation();
emit('drop', stageId, event, index);
emit('drop', stageId, event, pointerDropIndex(index, event));
}
function startRename() {
@@ -3,30 +3,30 @@ import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Menu as AMenu } from 'ant-design-vue';
import type { MenuProps } from 'ant-design-vue';
import { treasurySectionAllowed, type TreasurySectionKey } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
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: 'صندوق و حساب‌ها' },
...(canPayroll.value ? [{ to: '/finance/payroll', label: 'حقوق' }] : []),
{ to: '/finance/recurring', label: 'تکرارشونده' },
{ to: '/finance/reports', label: 'گزارش' },
{ to: '/finance/audit', label: 'سوابق' },
{ to: '/finance/categories', label: 'دسته‌بندی‌ها' },
]);
const links = computed(() => {
const role = auth.user?.role;
const permissions = auth.user?.permissions;
const all: Array<{ to: string; label: string; exact?: boolean; section: TreasurySectionKey }> = [
{ to: '/finance', label: 'داشبورد', exact: true, section: 'treasury_dashboard' },
{ to: '/finance/incomes', label: 'دریافت‌ها و درآمدها', section: 'treasury_incomes' },
{ to: '/finance/expenses', label: 'پرداخت‌ها و هزینه‌ها', section: 'treasury_expenses' },
{ to: '/finance/receivables', label: 'اقساط و مطالبات', section: 'treasury_receivables' },
{ to: '/finance/accounts', label: 'صندوق و حساب‌ها', section: 'treasury_accounts' },
{ to: '/finance/payroll', label: 'حقوق', section: 'treasury_payroll' },
{ 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' },
];
return all.filter((link) => treasurySectionAllowed(role, permissions, link.section));
});
function on(path: string, exact?: boolean) {
if (exact) return route.path === path;
+17 -4
View File
@@ -19,6 +19,7 @@ import type {
NotificationsListDto,
} from '@mento/shared';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { hasSectionPermission, sectionPermissionForPath } from '@/lib/section-permissions';
import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat';
const auth = useAuthStore();
@@ -167,12 +168,24 @@ function cardAllowedForNav(card: {
to?: string;
}) {
if (card.ownerOnly && auth.user?.role !== 'owner') return false;
const perms = auth.user?.permissions ?? [];
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;
|| perms.includes('treasury')
|| perms.some((key) => key.startsWith('treasury_'));
if (card.to?.startsWith('/finance')) {
return moduleAllowed(card.module ?? 'treasury') && canTreasury;
}
const section = card.to ? sectionPermissionForPath(card.to) : null;
const hasSection = section
? hasSectionPermission(auth.user?.role, auth.user?.permissions, section)
: true;
if (!hasSection) return false;
// لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز می‌شود
const grantedBySection = section === 'audit' && hasSection;
if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) {
return false;
}
if (!moduleAllowed(card.module)) return false;
if (
card.admin &&
+74 -2
View File
@@ -50,7 +50,78 @@ export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView
}
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
return (
file.mime.startsWith('image/') ||
(file.dataUrl ?? '').startsWith('data:image/') ||
/\.(png|jpe?g|gif|webp)$/i.test(file.name)
);
}
export type NoteChip =
| { kind: 'text'; text: string }
| { kind: 'image'; src: string; name: string }
| { kind: 'link'; href: string; label: string };
const FILE_BLOB = /\{[^{}]*"dataUrl"\s*:\s*"data:[^"]+"[^{}]*\}/gi;
const DATA_IMAGE = /data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+/gi;
const HTTP_URL = /https?:\/\/[^\s<>"']+/gi;
/** متن یادداشت را به نوشته، لینک کوتاه و تصویر جدا می‌کند. */
export function noteChips(raw: string | null | undefined): NoteChip[] {
const text = raw ?? '';
if (!text.trim()) return [];
const chips: NoteChip[] = [];
const token = new RegExp(
`${FILE_BLOB.source}|${DATA_IMAGE.source}|${HTTP_URL.source}`,
'gi',
);
let cursor = 0;
for (const match of text.matchAll(token)) {
const index = match.index ?? 0;
pushText(chips, text.slice(cursor, index));
chips.push(chipForToken(match[0]));
cursor = index + match[0].length;
}
pushText(chips, text.slice(cursor));
return chips;
}
function pushText(chips: NoteChip[], value: string) {
if (!value) return;
const prev = chips[chips.length - 1];
if (prev?.kind === 'text') prev.text += value;
else chips.push({ kind: 'text', text: value });
}
function chipForToken(token: string): NoteChip {
if (token.startsWith('{')) {
const file = readFileValue(token);
if (file?.dataUrl && leadNoteFileIsImage(file)) {
return { kind: 'image', src: file.dataUrl, name: file.name };
}
if (file?.dataUrl) return { kind: 'link', href: file.dataUrl, label: file.name };
return { kind: 'text', text: file?.name || 'فایل پیوست' };
}
if (token.startsWith('data:image/')) {
return { kind: 'image', src: token, name: 'تصویر پیوست' };
}
if (isImageUrl(token)) return { kind: 'image', src: token, name: shortUrl(token) };
return { kind: 'link', href: token, label: shortUrl(token) };
}
function isImageUrl(href: string): boolean {
return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(href);
}
function shortUrl(href: string): string {
try {
const url = new URL(href);
const file = decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? '');
const label = file || url.hostname;
return label.length > 42 ? `${label.slice(0, 24)}…${label.slice(-10)}` : label;
} catch {
return href.length > 42 ? `${href.slice(0, 28)}…` : href;
}
}
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
@@ -111,7 +182,8 @@ function readFileValue(value: string): LeadNoteFile | null {
return asFile(JSON.parse(value));
} catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
if (name) return { name, mime: '', dataUrl: null };
const mime = value.match(/"mime"\s*:\s*"([^"]+)"/)?.[1]?.trim() ?? '';
if (name) return { name, mime, dataUrl: null };
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null;
}
+14 -5
View File
@@ -1,5 +1,6 @@
import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
import type { ActivityPriority } from '@mento/shared';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -21,6 +22,8 @@ export type PipelineDeal = {
assigneeIds?: string[];
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده می‌شود */
priority?: ActivityPriority;
};
export type PipelineUiCopy = {
@@ -344,18 +347,16 @@ export function updateDealStage(
return without.map((stage) => {
if (stage.id !== stageId) return stage;
const next = [...stage.deals];
let insertAt =
toIndex == null || toIndex < 0 || toIndex > next.length
? next.length
: toIndex;
let insertAt = toIndex == null || toIndex < 0 ? next.length : toIndex;
if (
fromStageId === stageId &&
fromIndex >= 0 &&
toIndex != null &&
toIndex > fromIndex
) {
insertAt = Math.max(0, insertAt - 1);
insertAt -= 1;
}
insertAt = Math.max(0, Math.min(insertAt, next.length));
next.splice(insertAt, 0, { ...moving!, stageId });
return { ...stage, deals: next };
});
@@ -422,6 +423,14 @@ export function toFaDealCount(n: number): string {
}
/** جابه‌جایی یک ستون به ایندکس جدید */
/** نیمهٔ پایین کارت یعنی «بعد از این کارت». ایندکس نسبت به فهرست فعلی ستون است. */
export function pointerDropIndex(index: number, event: DragEvent): number {
const el = event.currentTarget as HTMLElement | null;
const rect = el?.getBoundingClientRect();
if (!rect || rect.height <= 0) return index;
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
}
export function moveStageToIndex(
stages: PipelineStage[],
stageId: string,
+31 -1
View File
@@ -1,4 +1,9 @@
import type { RolePermissionKey, UserRole } from '@mento/shared';
import {
treasurySectionAllowed,
type RolePermissionKey,
type TreasurySectionKey,
type UserRole,
} from '@mento/shared';
/** مسیر صفحه ← مجوز بخش؛ هم‌تراز SectionPermissionGuard در API */
const PATH_PERMISSIONS: Array<[prefix: string, key: RolePermissionKey]> = [
@@ -39,6 +44,31 @@ export function hasSectionPermission(
return Boolean(permissions?.includes(key));
}
function financeSection(path: string): TreasurySectionKey | null {
const clean = path.split(/[?#]/)[0] ?? path;
if (!clean.startsWith('/finance')) return null;
if (clean.startsWith('/finance/payroll')) return 'treasury_payroll';
if (clean.startsWith('/finance/incomes')) return 'treasury_incomes';
if (clean.startsWith('/finance/expenses')) return 'treasury_expenses';
if (clean.startsWith('/finance/receivables')) return 'treasury_receivables';
if (clean.startsWith('/finance/accounts')) return 'treasury_accounts';
if (clean.startsWith('/finance/recurring')) return 'treasury_recurring';
if (clean.startsWith('/finance/reports')) return 'treasury_reports';
if (clean.startsWith('/finance/audit')) return 'treasury_audit';
if (clean.startsWith('/finance/categories')) return 'treasury_categories';
return 'treasury_dashboard';
}
export function canOpenFinancePath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
path: string,
): boolean {
const section = financeSection(path);
if (!section) return true;
return treasurySectionAllowed(role, permissions, section);
}
export function canOpenPath(
role: UserRole | string | null | undefined,
permissions: readonly string[] | null | undefined,
+12 -6
View File
@@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import type { UserRole } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import { canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
import { canOpenFinancePath, canOpenPath, sectionPermissionForPath } from '@/lib/section-permissions';
export const router = createRouter({
history: createWebHistory(),
@@ -330,7 +330,7 @@ export const router = createRouter({
meta: {
roles: ['owner', 'admin'] as UserRole[],
treasuryTitle: 'حقوق و دستمزد',
treasuryDescription: 'فقط مالک و مدیر این صفحه را می‌بینند.',
treasuryDescription: 'پرداخت حقوق فقط با دسترسی جداگانه حقوق و دستمزد باز است.',
},
},
{
@@ -678,10 +678,16 @@ router.beforeEach((to) => {
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;
if (canOpenFinancePath(auth.user?.role, auth.user?.permissions, String(to.path))) {
return true;
}
const exactHome = to.path === '/finance' || to.path === '/finance/';
if (
exactHome &&
canOpenFinancePath(auth.user?.role, auth.user?.permissions, '/finance/payroll')
) {
return { path: '/finance/payroll' };
}
}
return { name: 'home' };
}
+173 -17
View File
@@ -25,6 +25,13 @@ import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
import {
leadNoteFileIsImage,
noteChips,
parseLeadFormNotes,
type LeadNoteFile,
type LeadNoteRow,
} from '@/lib/lead-form-notes';
const route = useRoute();
const router = useRouter();
@@ -62,6 +69,51 @@ const moreOpen = ref(false);
const commentOpen = ref(false);
const postponeOpen = ref(false);
const noteOpen = ref(true);
const noteModel = computed(() => parseLeadFormNotes(activity.value?.description));
const noteRows = computed(() =>
noteModel.value.mode === 'answers' ? noteModel.value.rows : ([] as LeadNoteRow[]),
);
const noteExtra = computed(() =>
noteChips(
noteModel.value.mode === 'answers' ? noteModel.value.prose : activity.value?.description,
).filter(
(chip) => !(chip.kind === 'text' && chip.text.replace(/\s+/g, '').trim() === 'پاسخ‌هایفرم:'),
),
);
const recoveredImages = ref<Record<string, string>>({});
function noteImageSrc(file: LeadNoteFile): string | null {
if (!leadNoteFileIsImage(file)) return null;
return file.dataUrl || recoveredImages.value[file.name] || null;
}
watch(
[() => activity.value?.id, () => activity.value?.lead?.id, noteRows],
async ([activityId, leadId]) => {
const missing = noteRows.value
.map((row) => row.file)
.filter((file): file is LeadNoteFile => Boolean(file && !file.dataUrl && leadNoteFileIsImage(file)))
.map((file) => file.name);
recoveredImages.value = {};
if (!activityId || !leadId || !missing.length) return;
try {
const { data } = await api.get<{ notes?: string | null }>(`/leads/${leadId}`);
if (activity.value?.id !== activityId) return;
const wanted = new Set(missing);
const next: Record<string, string> = {};
for (const row of parseLeadFormNotes(data.notes).rows) {
const file = row.file;
if (file?.dataUrl && wanted.has(file.name) && leadNoteFileIsImage(file)) {
next[file.name] = file.dataUrl;
}
}
recoveredImages.value = next;
} catch {
recoveredImages.value = {};
}
},
{ immediate: true },
);
const users = ref<OrgUserDto[]>([]);
const assignUserIds = ref<string[]>([]);
@@ -565,8 +617,61 @@ onMounted(() => {
<UiIcon name="chevron" :size="16" :class="{ open: noteOpen }" />
</button>
<div v-show="noteOpen">
<p v-if="activity.description?.trim()">{{ activity.description.trim() }}</p>
<p v-else-if="!imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<dl v-if="noteRows.length" class="note-rows">
<div v-for="(row, index) in noteRows" :key="`${row.label}-${index}`">
<dt>{{ row.label }}</dt>
<dd>
<template v-if="row.file && noteImageSrc(row.file)">
<a class="note-shot" :href="noteImageSrc(row.file)!" target="_blank" rel="noopener">
<img :src="noteImageSrc(row.file)!" :alt="row.file.name" />
</a>
<span>{{ row.file.name }}</span>
</template>
<span v-else-if="row.file">{{ row.file.name }}</span>
<template v-else>
<template v-for="(chip, chipIndex) in noteChips(row.text)" :key="chipIndex">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</template>
</dd>
</div>
</dl>
<p v-else-if="!noteExtra.length && !imageUrls.length" class="empty">یادداشتی ثبت نشده.</p>
<p v-if="noteExtra.length" class="note-extra">
<template v-for="(chip, index) in noteExtra" :key="index">
<a
v-if="chip.kind === 'link'"
:href="chip.href"
target="_blank"
rel="noopener"
>{{ chip.label }}</a>
<a
v-else-if="chip.kind === 'image'"
class="note-shot"
:href="chip.src"
target="_blank"
rel="noopener"
>
<img :src="chip.src" :alt="chip.name" />
</a>
<span v-else>{{ chip.text }}</span>
</template>
</p>
<div v-if="imageUrls.some((image) => image.kind === 'image')" class="note-photos">
<a
v-for="image in imageUrls.filter((item) => item.kind === 'image')"
@@ -654,7 +759,13 @@ onMounted(() => {
margin: 0;
display: grid;
gap: 0.9rem;
padding-bottom: 5.5rem;
padding-bottom: 1rem;
overflow-x: clip;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.nav {
@@ -876,6 +987,8 @@ dt {
dd {
font-weight: 700;
text-align: start;
min-width: 0;
overflow-wrap: anywhere;
}
.lead-link {
@@ -908,17 +1021,27 @@ dd {
.assignees-row dd {
flex: 1 1 16rem;
min-width: 0;
max-width: 100%;
font-weight: 500;
}
.assignees-row dd :deep(.ant-select) {
width: 100%;
max-width: 100%;
}
.assignees-row dd :deep(.ant-select-selection-overflow) {
flex-wrap: wrap;
}
.flag.low,
.priority.low {
color: #64748b;
color: #16a34a;
}
.flag.medium,
.priority.medium {
color: #ea580c;
color: #eab308;
}
.flag.high,
@@ -968,12 +1091,53 @@ dd {
line-height: 1.9;
font-size: 0.9rem;
white-space: pre-line;
overflow-wrap: anywhere;
}
.note p.empty {
color: #94a3b8;
}
.note-rows {
margin: 0.75rem 0 0;
display: grid;
gap: 0.55rem;
}
.note-rows > div {
display: grid;
grid-template-columns: minmax(7rem, 12rem) minmax(0, 1fr);
gap: 0.45rem 0.75rem;
align-items: start;
}
.note-rows dt {
color: #64748b;
font-weight: 500;
}
.note-rows dd {
display: grid;
gap: 0.35rem;
justify-items: start;
}
.note-shot {
display: block;
max-width: 100%;
}
.note-shot img {
display: block;
width: auto;
max-width: min(280px, 100%);
max-height: 220px;
object-fit: contain;
border-radius: 0.6rem;
border: 1px solid #e2e8f0;
background: #fff;
}
.note-photos {
display: flex;
flex-wrap: wrap;
@@ -1017,13 +1181,11 @@ dd {
}
.dock {
position: fixed;
inset-inline: 0.85rem;
bottom: calc(0.55rem + var(--mobile-tabbar-h));
position: static;
display: grid;
grid-template-columns: 1.15fr 1fr 1fr;
gap: 0.5rem;
z-index: 8;
width: 100%;
}
.act {
@@ -1053,15 +1215,9 @@ dd {
cursor: not-allowed;
}
@media (min-width: 900px) {
@media (max-width: 899px) {
.page {
padding-bottom: 0;
}
.dock {
position: sticky;
inset-inline: auto;
bottom: 0;
padding-bottom: calc(1rem + var(--mobile-tabbar-h));
}
}
</style>
+28 -13
View File
@@ -32,6 +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 { useAuthStore } from '@/stores/auth';
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
@@ -481,28 +482,41 @@ function onDragOver(stageId: string, event: DragEvent) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
event.stopPropagation();
const leadId =
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
draggingId.value = null;
dropStageId.value = null;
if (!leadId) return;
if (!leadId || moving.value) return;
const fromCol = columns.value.find((c) =>
c.leads.some((l) => l.id === leadId),
);
if (!fromCol || fromCol.stage.id === stageId) return;
const lead = fromCol.leads.find((l) => l.id === leadId);
const target = columns.value.find((c) => c.stage.id === stageId);
if (!fromCol || !target) return;
const fromIndex = fromCol.leads.findIndex((l) => l.id === leadId);
const lead = fromCol.leads[fromIndex];
if (!lead) return;
const same = fromCol.stage.id === stageId;
if (same && index == 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;
const nextLeads = [...without];
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
const prev = columns.value.map((c) => ({
...c,
leads: [...c.leads],
}));
columns.value = columns.value.map((c) => {
if (same && c.stage.id === stageId) return { ...c, leads: nextLeads };
if (c.stage.id === fromCol.stage.id) {
return {
...c,
@@ -511,11 +525,7 @@ async function onDrop(stageId: string, event: DragEvent) {
};
}
if (c.stage.id === stageId) {
return {
...c,
total: c.total + 1,
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
};
return { ...c, total: c.total + 1, leads: nextLeads };
}
return c;
});
@@ -523,7 +533,7 @@ async function onDrop(stageId: string, event: DragEvent) {
moving.value = true;
error.value = '';
try {
await api.patch(`/leads/${leadId}/move`, { stageId });
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
} catch {
columns.value = prev;
error.value = 'جابه‌جایی لید ناموفق بود.';
@@ -644,9 +654,13 @@ onMounted(load);
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
<div
v-for="(lead, index) in col.leads"
:key="lead.id"
@dragover.prevent="onDragOver(col.stage.id, $event)"
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
>
<a-card
hoverable
:bordered="true"
:body-style="cardBodyStyle"
@@ -786,6 +800,7 @@ onMounted(load);
</a-flex>
</a-flex>
</a-card>
</div>
<a-typography-text
v-if="!col.leads.length"
+12 -5
View File
@@ -312,11 +312,18 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
}
+107 -5
View File
@@ -13,6 +13,7 @@ import {
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
type DataAccessPolicy,
type FinancialAccountDto,
type InviteUserRequest,
type OrganizationDto,
type OrgModuleKey,
@@ -159,7 +160,16 @@ const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
conversations: ['conversations'],
conversations_groups: ['conversations'],
treasury: ['treasury'],
treasury_dashboard: ['treasury'],
treasury_incomes: ['treasury'],
treasury_expenses: ['treasury'],
treasury_receivables: ['treasury'],
treasury_accounts: ['treasury'],
treasury_payroll: ['treasury'],
treasury_recurring: ['treasury'],
treasury_reports: ['treasury'],
treasury_audit: ['treasury'],
treasury_categories: ['treasury'],
behavior: ['behavior'],
};
@@ -223,6 +233,10 @@ const roleFormDesc = ref('');
const roleFormColor = ref<RoleColor>('teal');
const roleFormParentId = ref('');
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
const roleFormAccountIds = ref<string[]>([]);
const roleFormOwnAccounts = ref(false);
const roleFormAccounts = ref<Array<{ id: string; name: string; responsibleName: string }>>([]);
const roleAccountQuery = ref('');
const deleteOpen = ref(false);
const deleteSaving = ref(false);
@@ -693,7 +707,11 @@ function openCreateRole(parentId?: string | null) {
? [...parent.permissions]
: visibleRolePermissions.value.map((p) => p.key);
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
roleFormAccountIds.value = [...(parent?.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(parent?.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
function openCreateChildRole() {
@@ -713,7 +731,40 @@ function openEditRole() {
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
roleFormPerms.value = [...role.permissions];
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
roleFormAccountIds.value = [...(role.treasuryAccountIds ?? [])];
roleFormOwnAccounts.value = Boolean(role.treasuryOwnAccounts);
roleAccountQuery.value = '';
roleFormOpen.value = true;
void loadRoleAccounts();
}
function isTreasuryDetail(key: RolePermissionKey) {
return key.startsWith('treasury_');
}
const filteredRoleAccounts = computed(() => {
const query = roleAccountQuery.value.trim();
if (!query) return roleFormAccounts.value;
return roleFormAccounts.value.filter((account) => account.name.includes(query));
});
async function loadRoleAccounts() {
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
roleFormAccounts.value = data.map((account) => ({
id: account.id,
name: account.name,
responsibleName: account.responsibleName?.trim() ?? '',
}));
} catch {
roleFormAccounts.value = [];
}
}
function toggleRoleAccount(id: string) {
roleFormAccountIds.value = roleFormAccountIds.value.includes(id)
? roleFormAccountIds.value.filter((item) => item !== id)
: [...roleFormAccountIds.value, id];
}
function permOn(key: RolePermissionKey) {
@@ -721,6 +772,14 @@ function permOn(key: RolePermissionKey) {
}
function toggleFormPerm(key: RolePermissionKey) {
if (key === 'treasury' && permOn('treasury')) {
roleFormPerms.value = roleFormPerms.value.filter(
(item) => item !== 'treasury' && !item.startsWith('treasury_'),
);
roleFormAccountIds.value = [];
roleFormOwnAccounts.value = false;
return;
}
roleFormPerms.value = permOn(key)
? roleFormPerms.value.filter((p) => p !== key)
: [...roleFormPerms.value, key];
@@ -744,12 +803,16 @@ async function submitRoleForm() {
permissions: RolePermissionKey[];
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
parentId?: string | null;
treasuryAccountIds?: string[];
treasuryOwnAccounts?: boolean;
} = {
name: roleFormName.value.trim(),
description: roleFormDesc.value.trim(),
color: roleFormColor.value,
permissions: roleFormPerms.value,
dataAccessPolicy: roleFormDataAccess.value,
treasuryAccountIds: roleFormAccountIds.value,
treasuryOwnAccounts: roleFormOwnAccounts.value,
};
if (!editing?.systemKey) {
body.parentId = roleFormParentId.value || null;
@@ -1661,14 +1724,53 @@ onUnmounted(() => {
</a-form-item>
<a-form-item label="مجوزهای دسترسی">
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
تیک مدیریت مالی در را باز می‌کند. بخش‌های ریز جدا انتخاب می‌شوند و با روشن کردن مدیریت مالی خودبه‌خود تیک نمی‌خورند.
تا وقتی هیچ بخش ریزی ذخیره نشده، همه بخش‌ها به‌جز حقوق باز می‌مانند. با ذخیره حتی یک بخش، فقط همان‌ها می‌مانند. حقوق جداست.
</a-typography-text>
<template v-for="perm in visibleRolePermissions" :key="perm.key">
<a-checkbox
v-if="!isTreasuryDetail(perm.key) || permOn('treasury')"
:checked="permOn(perm.key)"
:style="isTreasuryDetail(perm.key) ? { marginInlineStart: '24px' } : undefined"
@change="toggleFormPerm(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</template>
</a-flex>
</a-form-item>
<a-form-item v-if="permOn('treasury')" label="دسترسی حساب‌ها">
<a-flex vertical :gap="4">
<a-typography-text type="secondary">
این بخش جدا از تیک بخش‌های مالی است. می‌توانید حساب‌هایی را بدهید که خود عضو مسئول آن‌هاست،
و جدا از آن حساب‌های دیگر را هم انتخاب کنید. اگر هیچ‌کدام انتخاب نشود، همه حساب‌ها باز می‌ماند.
</a-typography-text>
<a-checkbox
v-for="perm in visibleRolePermissions"
:key="perm.key"
:checked="permOn(perm.key)"
@change="toggleFormPerm(perm.key)"
:checked="roleFormOwnAccounts"
@change="roleFormOwnAccounts = !roleFormOwnAccounts"
>
{{ perm.label }}
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="roleAccountQuery"
placeholder="جستجوی حساب"
allow-clear
style="width: 100%"
/>
<a-checkbox
v-for="account in filteredRoleAccounts"
:key="account.id"
:checked="roleFormAccountIds.includes(account.id)"
@change="toggleRoleAccount(account.id)"
>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</a-checkbox>
<a-typography-text v-if="!roleFormAccounts.length" type="secondary">
حسابی برای انتخاب نیست.
</a-typography-text>
</a-flex>
</a-form-item>
<a-form-item label="محدوده داده">
@@ -23,7 +23,7 @@ import {
message,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import type { FinancialAccountDto, FinancialAccountType, OrgUserDto } from '@mento/shared';
import MoneyInput from '@/components/MoneyInput.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { toman } from './treasury-format';
@@ -35,12 +35,15 @@ const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const error = ref('');
const ok = ref('');
const openForm = ref(false);
const editingId = ref<string | null>(null);
const responsibleKey = ref<string | undefined>(undefined);
const saving = ref(false);
const transferOpen = ref(false);
const form = ref({
const emptyForm = () => ({
name: '',
type: 'cash' as FinancialAccountType,
bankName: '',
@@ -50,6 +53,7 @@ const form = ref({
openingDate: '',
notes: '',
});
const form = ref(emptyForm());
const transfer = ref({
fromAccountId: '',
toAccountId: '',
@@ -80,9 +84,54 @@ function apiMessage(e: unknown) {
return Array.isArray(msg) ? msg.join('، ') : msg || 'عملیات ناموفق بود.';
}
const responsibleOptions = computed(() => {
const rows = users.value.map((user) => ({ id: user.id, fullName: user.fullName }));
const current = form.value.responsibleName.trim();
if (current && !rows.some((row) => row.fullName === current)) {
rows.unshift({ id: `name:${current}`, fullName: current });
}
return rows;
});
async function load() {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
accounts.value = data;
try {
const usersRes = await api.get<OrgUserDto[]>('/users');
users.value = usersRes.data;
} catch {
users.value = [];
}
}
function resetForm() {
editingId.value = null;
responsibleKey.value = undefined;
form.value = emptyForm();
}
function startEdit(item: FinancialAccountDto) {
editingId.value = item.id;
form.value = {
name: item.name,
type: item.type,
bankName: item.bankName ?? '',
accountRef: item.accountRef ?? '',
responsibleName: item.responsibleName ?? '',
openingBalance: item.openingBalance,
openingDate: item.openingDate.slice(0, 10),
notes: item.notes ?? '',
};
const match = users.value.find((user) => user.fullName === item.responsibleName);
responsibleKey.value = match?.id ?? (item.responsibleName ? `name:${item.responsibleName}` : undefined);
openForm.value = true;
}
function responsibleNameFromKey() {
const key = responsibleKey.value;
if (!key) return null;
if (key.startsWith('name:')) return key.slice(5);
return users.value.find((user) => user.id === key)?.fullName ?? null;
}
function onFinishFailed() {
@@ -95,23 +144,21 @@ async function createAccount() {
ok.value = '';
try {
const openingBalance = parseMoneyNumber(String(form.value.openingBalance ?? ''), 0) ?? 0;
await api.post('/treasury/accounts', {
const body = {
...form.value,
responsibleName: responsibleNameFromKey(),
openingBalance,
openingDate: form.value.openingDate || undefined,
});
openForm.value = false;
form.value = {
name: '',
type: 'cash',
bankName: '',
accountRef: '',
responsibleName: '',
openingBalance: '',
openingDate: '',
notes: '',
};
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
if (editingId.value) {
await api.patch(`/treasury/accounts/${editingId.value}`, body);
ok.value = 'حساب به‌روز شد. موجودی از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.';
} else {
await api.post('/treasury/accounts', body);
ok.value = 'حساب ثبت شد. موجودی اولیه درآمد دوره نیست.';
}
openForm.value = false;
resetForm();
await load();
} catch (e) {
error.value = apiMessage(e);
@@ -193,12 +240,18 @@ onMounted(() => {
<a-card :bordered="false">
<a-space>
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
<a-button
type="primary"
@click="
openForm = !openForm;
if (!openForm) resetForm();
"
>افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-divider orientation="right">{{ editingId ? 'ویرایش حساب' : 'حساب جدید' }}</a-divider>
<a-form
layout="vertical"
:model="form"
@@ -217,10 +270,15 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type" :rules="[{ required: true }]">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
<a-select-option value="petty">تنخواه</a-select-option>
<a-select
v-model:value="form.type"
show-search
option-filter-prop="label"
style="width: 100%"
>
<a-select-option value="cash" label="صندوق نقدی">صندوق نقدی</a-select-option>
<a-select-option value="bank" label="حساب بانکی">حساب بانکی</a-select-option>
<a-select-option value="petty" label="تنخواه">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -235,8 +293,24 @@ onMounted(() => {
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName">
<a-input v-model:value="form.responsibleName" />
<a-form-item label="مسئول">
<a-select
v-model:value="responsibleKey"
show-search
allow-clear
option-filter-prop="label"
style="width: 100%"
placeholder="انتخاب کاربر"
>
<a-select-option
v-for="user in responsibleOptions"
:key="user.id"
:value="user.id"
:label="user.fullName"
>
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
@@ -255,7 +329,9 @@ onMounted(() => {
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
<a-button type="primary" html-type="submit" :loading="saving">
{{ editingId ? 'ذخیره تغییرات' : 'ثبت حساب' }}
</a-button>
</a-form>
</template>
@@ -276,11 +352,18 @@ onMounted(() => {
>
<a-select
v-model:value="transfer.fromAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
<a-select-option
v-for="account in active"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
</a-select>
@@ -294,6 +377,8 @@ onMounted(() => {
>
<a-select
v-model:value="transfer.toAccountId"
show-search
option-filter-prop="label"
allow-clear
style="width: 100%"
placeholder="انتخاب"
@@ -302,6 +387,7 @@ onMounted(() => {
v-for="account in active"
:key="`to-${account.id}`"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -339,6 +425,7 @@ onMounted(() => {
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
@@ -347,7 +434,9 @@ onMounted(() => {
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
{{ typeLabel[item.type] }}
<template v-if="item.responsibleName"> · {{ item.responsibleName }}</template>
· موجودی اولیه {{ toman(item.openingBalance) }}
</template>
</a-list-item-meta>
</a-list-item>
@@ -73,9 +73,9 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
<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>
@@ -185,13 +185,13 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
@@ -204,13 +204,13 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
{{ account.name }}
</a-select-option>
</a-select>
@@ -218,8 +218,8 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value" :label="opt.label">
{{ opt.label }}
</a-select-option>
</a-select>
@@ -372,11 +372,12 @@ onMounted(() => {
<span>روش دریافت</span>
</a-space>
</template>
<a-select v-model:value="form.method" style="width: 100%">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
@@ -393,13 +394,13 @@ onMounted(() => {
</a-space>
</template>
<a-flex :gap="8">
<a-select
<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">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
@@ -471,7 +472,7 @@ onMounted(() => {
<span>حساب مقصد</span>
</a-space>
</template>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
@@ -481,6 +482,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -617,13 +619,13 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id" :label="cat.name">
{{ cat.name }}
</a-select-option>
</a-select>
@@ -636,7 +638,7 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
@@ -646,6 +648,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -654,11 +657,12 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select show-search option-filter-prop="label" v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
>
{{ opt.label }}
</a-select-option>
@@ -166,13 +166,13 @@ onMounted(() => {
name="userId"
:rules="[{ required: true, message: 'پرسنل الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.userId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
<a-select-option v-for="user in users" :key="user.id" :value="user.id" :label="user.fullName">
{{ user.fullName }}
</a-select-option>
</a-select>
@@ -239,7 +239,7 @@ onMounted(() => {
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="pay.accountId"
allow-clear
style="width: 100%"
@@ -249,6 +249,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -244,13 +244,13 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب"
>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id" :label="account.name">
{{ account.name }}
</a-select-option>
</a-select>
@@ -263,9 +263,9 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -329,7 +329,7 @@ onMounted(() => {
name="accountId"
:rules="[{ required: true, message: 'حساب الزامی است' }]"
>
<a-select
<a-select show-search option-filter-prop="label"
v-model:value="collect.accountId"
allow-clear
style="width: 100%"
@@ -339,6 +339,7 @@ onMounted(() => {
v-for="account in accounts"
:key="account.id"
:value="account.id"
:label="account.name"
>
{{ account.name }}
</a-select-option>
@@ -111,9 +111,9 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind" :rules="[{ required: true }]">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense" label="هزینه">هزینه</a-select-option>
<a-select-option value="income" label="درآمد">درآمد</a-select-option>
</a-select>
</a-form-item>
</a-col>
@@ -128,10 +128,10 @@ onMounted(() => {
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval" :rules="[{ required: true }]">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select-option value="yearly">سالانه</a-select-option>
<a-select show-search option-filter-prop="label" v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly" label="ماهانه">ماهانه</a-select-option>
<a-select-option value="weekly" label="هفتگی">هفتگی</a-select-option>
<a-select-option value="yearly" label="سالانه">سالانه</a-select-option>
</a-select>
</a-form-item>
</a-col>