merge: bring Ant Design UI from dev into main

Keep the design refresh together with DISC, the sales copilot, and treasury behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 21:33:56 +03:30
co-authored by Cursor
50 changed files with 9568 additions and 12306 deletions
+85
View File
@@ -0,0 +1,85 @@
---
description: Hard governance for Ant Design Vue v4 migration — isolated page-by-page, no manual CSS, 24-col grid, default controlHeight 36
alwaysApply: true
---
# Ant Design Vue v4 — Strict Governance (locked)
Source of truth for migrated UI: **Ant Design Vue 4** + central theme [`apps/web/src/theme/antdTheme.ts`](apps/web/src/theme/antdTheme.ts).
Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) remains for **unmigrated** screens only. Never mix systems inside one page.
## Hard rules
1. **Single-page migration lock**
Migrate **exactly one** route/view (or one clearly named page folder) per change set. Do not “clean up” neighboring pages, shared legacy primitives, or global CSS while migrating a page.
2. **No edits to legacy kit during Ant work**
Do **not** modify `apps/web/src/components/ui/**` or broaden changes across `views/` beyond the single page under migration, unless the user explicitly names that page.
2b. **Child components of a migrated View (mandatory)**
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
When rewriting a View, the agent **must** also review every component imported by that View that contains inputs/selects/buttons, and convert their internals to official `ant-design-vue` controls.
**No raw** `<input>`, `<select>`, `<textarea>`, or `<button>` may remain in the View **or** in those consumed child components. Prefer native Ant equivalents (`a-select` / `mode="multiple|tags"` / `a-time-picker` / …) over custom pickers when logic allows; if domain logic is required (e.g. Jalali), convert that child file to Ant chrome (`a-input` + `a-popover`, etc.) instead of leaving raw HTML. Do **not** leave legacy `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea` in the View or those children.
3. **Ant-only UI on migrated pages**
On a migrated page, build controls with Ant Design Vue (`a-button`, `a-form`, `a-table`, `a-card`, …) and `@ant-design/icons-vue`. Do **not** introduce new usage of `UiButton`, `UiInput`, `.btn`, `.field`, or ad-hoc HTML form controls for the same job.
4. **Ban manual CSS for layout/look**
On migrated pages:
- No new `<style scoped>` rules for color, spacing, radius, shadow, typography, or layout that Ant already covers.
- No hardcoded hex / `rgb()` for chrome UI (charts/data palettes may be excepted when not chrome).
- Prefer ConfigProvider theme tokens, component props, and Ant space/grid utilities.
- Inline `style` only for rare one-off values that tokens cannot express; prefer tokens first.
5. **Default Ant control size (`controlHeight: 36`) — no manual `size`**
- Global default comes from theme token `controlHeight: 36` in `antdTheme.ts` (Ant derives SM/LG from this).
- Do **not** set `size="small"`, `size="middle"`, `size="large"`, or `:size` on `a-button`, `a-input`, `a-select`, `a-input-number`, `a-date-picker`, `a-radio-group`, `a-pagination`, or similar controls on migrated pages.
- Do **not** override `controlHeight` / `controlHeightSM` / `controlHeightLG` inside `components.*` of the theme — inherit the token default.
- Exception: only if the user explicitly requests a one-off non-default size for a named control.
6. **24-column grid only**
Page layout on migrated screens uses Ant Grid: `a-row` / `a-col` with the **24-column** system (`:span`, `:xs`, `:sm`, `:md`, `:lg`, `:xl`). Do not invent parallel flex/grid CSS layouts for page chrome.
7. **RTL + locale**
App root is wrapped with `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">`. Do not override direction to LTR on whole pages; use local `direction="ltr"` only for inherently LTR fragments (e.g. phone numbers, codes).
8. **Auto-import**
`<a-*>` components are resolved via `unplugin-vue-components` + `AntDesignVueResolver({ importStyle: false })`. Do not add blanket `app.use(antd)` unless there is a documented exception. Do not per-component CSS imports (`importStyle` stays `false`; CSS-in-JS + theme).
9. **Theme is central**
Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
10. **Icons**
Use `@ant-design/icons-vue` on migrated pages. Do not expand `UiIcon` for Ant pages.
11. **Isolation guarantee**
Infrastructure PRs (deps, vite plugin, theme, App `ConfigProvider`) must not change behavior of unmigrated pages. No `ant-design-vue/dist/reset.css` (or similar global reset) unless explicitly approved — it can break legacy CSS.
## Allowed on migrated pages
- Ant Design Vue 4 components and their props/slots (except banned manual `size` on controls)
- `a-row` / `a-col` (24 columns), `a-space`, `a-flex` where appropriate
- Theme tokens via ConfigProvider
- Minimal page-local script logic (data fetching, forms) unchanged in spirit
## Forbidden on migrated pages
- New legacy `components/ui` usage
- Parallel button/input CSS classes (`.btn`, `.field`, `.crm-*` for chrome that Ant replaces)
- Custom CSS that re-skins Ant components
- Manual `size` props on form/action controls (use theme default 36px)
- Multi-page refactors “while we’re here”
- Adding Tailwind/Bootstrap/other UI kits
## Agent checklist before finishing a migration PR
- [ ] Only one page/route touched for UI migration
- [ ] Imported child components with controls also converted to Ant (no raw HTML left)
- [ ] Grep verification: `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` count is **0** in the View + those children
- [ ] No changes under `components/ui/`
- [ ] No new scoped CSS for look-and-feel
- [ ] No manual `size` on Ant controls
- [ ] Layout uses 24-col Ant grid
- [ ] Uses `antdTheme` / ConfigProvider / StyleProvider `hash-priority="high"` — no local theme fork
- [ ] `npm run build -w @mento/web` (or `vue-tsc`) passes
@@ -0,0 +1,86 @@
---
name: antd-system-architect
description: >-
Architect and migrate Montact web UI to Ant Design Vue v4 under strict
governance: single-page lock, no manual CSS, no manual size props, default
controlHeight 36, 24-column grid, central antdTheme, isolated from legacy
components/ui. Use when migrating a page to Ant Design, adding Ant components,
editing antdTheme, or reviewing Ant PRs.
---
# Ant Design Vue System Architect (Montact)
## When to use
- Migrating any `apps/web` view/page to Ant Design Vue 4
- Extending [`apps/web/src/theme/antdTheme.ts`](../../../apps/web/src/theme/antdTheme.ts)
- Touching Ant ConfigProvider / vite Ant auto-import
- Reviewing whether a change violates isolation from legacy UI
Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd-strict-governance.mdc).
## Non-negotiables
1. **One page per migration** — never batch unrelated views.
2. **Do not edit** `apps/web/src/components/ui/**` during Ant migration work.
3. **Child components of the View are in scope (mandatory)** — هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند. When rewriting a View, also open every imported child that renders inputs/selects/buttons and convert them to official `ant-design-vue` controls. No raw `<input>`, `<select>`, `<textarea>`, or `<button>` (and no `UiInput` / `UiSelect` / `UiButton` / `UiField` / `UiTextarea`) may remain in the View or those children. Prefer Ant natives (`a-select` multiple/tags, `a-time-picker`, …); for Jalali/domain pickers, convert the child file to Ant chrome (`a-input` + `a-popover`, etc.).
4. **No manual look CSS** on migrated pages (no new scoped colors/spacing/radius/shadows for chrome).
5. **24-column Ant grid** (`a-row` / `a-col`) for page layout.
6. **Theme only in** `apps/web/src/theme/antdTheme.ts`.
7. **Default control height is 36** via token `controlHeight: 36`. Do **not** pass `size` / `:size` on buttons, inputs, selects, or similar controls; do **not** set per-component `controlHeight*` in the theme `components` map.
8. **Auto-import** `<a-*>` via `AntDesignVueResolver({ importStyle: false })` — no per-file style imports. Root uses `StyleProvider` with `hash-priority="high"` so legacy `main.css` cannot override Ant.
9. **No global Ant reset CSS** that would restyle legacy pages.
## Stack facts (this repo)
| Piece | Location |
|---|---|
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme | `apps/web/src/App.vue` — `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` |
| Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
## Migration workflow
1. Confirm the **single** route/view the user named.
2. Inventory current UI: legacy `Ui*`, `.btn`/`.field`, `.crm-*`, custom tables.
3. List all imported children that contain controls; convert them in the same pass (except `components/ui/**`).
4. Replace with Ant equivalents:
- Button → `a-button` (**no** `size` prop)
- Input/Select/Form field → `a-form` + `a-form-item` + `a-input` / `a-select` / … (**no** `size`)
- Modal/Drawer → `a-modal` / `a-drawer`
- Table → `a-table` (columns via Ant API)
- Page chrome → `a-card`, title + `a-space`, layout via `a-row`/`a-col`
- Icons → `@ant-design/icons-vue`
5. Remove page-local CSS that only existed to skin the old controls.
6. Keep business logic, API calls, and router behavior intact.
7. Grep-verify zero hits for `<input|<select|<textarea|<button|UiInput|UiSelect|UiField|UiTextarea|UiButton` in the View + converted children.
8. Run `npm run build -w @mento/web` (or at least `vue-tsc -b` in `apps/web`).
9. Smoke-check that **other** routes still render with legacy UI unchanged.
## Theme extension rules
- Change tokens in `antdTheme.ts` only when the user asks or when a migrated page proves a missing token.
- Keep `controlHeight: 36` as the single source for control sizing; let Ant derive SM/LG.
- Prefer `token` and limited `components.Card` / `components.Form` overrides — never per-control `controlHeight` forks.
- Do not duplicate `ThemeConfig` objects inside views.
## Anti-patterns (reject)
- Importing both `UiButton` and `a-button` on the same migrated page
- Adding `ant-design-vue/dist/reset.css` “to fix spacing”
- Editing `components/ui` to “look more like Ant”
- `size="small|middle|large"` on Ant controls
- Multi-page visual refactors in one PR
- Custom CSS overriding `.ant-btn` / `.ant-input` selectors
## Done criteria
- Migrated page uses Ant components + 24-col grid
- Imported control-bearing children also Ant-pure (grep = 0)
- Controls use theme default height (no manual `size`)
- No new manual chrome CSS
- Legacy kit untouched
- Build/typecheck clean
- Unmigrated pages untouched in the diff
+2
View File
@@ -10,7 +10,9 @@
"lint": "echo \"web lint placeholder\""
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@mento/shared": "*",
"ant-design-vue": "^4.2.6",
"axios": "^1.8.4",
"echarts": "^6.1.0",
"pinia": "^3.0.1",
+11 -1
View File
@@ -1,7 +1,17 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import {
ConfigProvider as AConfigProvider,
StyleProvider as AStyleProvider,
} from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR';
import { antdTheme } from '@/theme/antdTheme';
</script>
<template>
<RouterView />
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
</template>
+66
View File
@@ -0,0 +1,66 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAffix: typeof import('ant-design-vue/es')['Affix']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckableTag: typeof import('ant-design-vue/es')['CheckableTag']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACol: typeof import('ant-design-vue/es')['Col']
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AFlex: typeof import('ant-design-vue/es')['Flex']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopover: typeof import('ant-design-vue/es')['Popover']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpaceCompact: typeof import('ant-design-vue/es')['Compact']
ASpin: typeof import('ant-design-vue/es')['Spin']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
AUpload: typeof import('ant-design-vue/es')['Upload']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
+69 -102
View File
@@ -1,13 +1,25 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
Checkbox as ACheckbox,
Col as ACol,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiField, UiInput, UiSelect } from '@/components/ui';
import type {
CustomFieldDefinitionDto,
CustomFieldEntity,
} from '@mento/shared';
const { Title: ATypographyTitle } = Typography;
const props = defineProps<{
entityType: CustomFieldEntity;
modelValue: Record<string, unknown>;
@@ -46,6 +58,10 @@ function displayValue(key: string) {
return String(v);
}
function selectOptions(field: CustomFieldDefinitionDto) {
return (field.options ?? []).map((opt) => ({ value: opt, label: opt }));
}
watch(
() => props.entityType,
() => {
@@ -57,110 +73,61 @@ onMounted(load);
</script>
<template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
<div v-else-if="fields.length" class="custom-block">
<h3 class="custom-title">فیلدهای سفارشی</h3>
<div class="crm-form-grid-2">
<UiField
v-for="field in fields"
:key="field.id"
:label="field.label"
:required="field.required"
:for-id="`cf-${field.key}`"
>
<UiSelect
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
>
<option value="">انتخاب کنید</option>
<option v-for="opt in field.options" :key="opt" :value="opt">
{{ opt }}
</option>
</UiSelect>
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
<input
<a-spin v-if="loading" />
<template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی
</a-typography-title>
<a-row :gutter="[16, 0]">
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
<a-form-item :label="field.label" :required="field.required">
<a-select
v-if="field.fieldType === 'select'"
:id="`cf-${field.key}`"
type="checkbox"
:checked="Boolean(props.modelValue[field.key])"
@change="
setValue(field.key, ($event.target as HTMLInputElement).checked)
"
allow-clear
:value="String(displayValue(field.key)) || undefined"
:options="selectOptions(field)"
placeholder="انتخاب کنید"
style="width: 100%"
@update:value="setValue(field.key, $event ?? '')"
/>
<span>فعال</span>
</label>
<UiInput
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
type="number"
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
@update:model-value="
setValue(
field.key,
$event === '' || $event == null ? null : Number($event),
)
"
/>
<a-checkbox
v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`"
:checked="Boolean(props.modelValue[field.key])"
@update:checked="setValue(field.key, $event)"
>
فعال
</a-checkbox>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<a-input-number
v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`"
:value="
props.modelValue[field.key] != null
? Number(props.modelValue[field.key])
: undefined
"
style="width: 100%"
@update:value="setValue(field.key, $event == null ? null : Number($event))"
/>
<UiInput
v-else
:id="`cf-${field.key}`"
type="text"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event)"
/>
</UiField>
</div>
</div>
<JalaliDateInput
v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))"
@update:model-value="setValue(field.key, $event || null)"
/>
<a-input
v-else
:id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</template>
<style scoped>
.custom-block {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding-top: var(--sp-4);
border-top: 1px dashed var(--line);
width: 100%;
}
.custom-title {
margin: 0;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink-secondary);
}
.muted {
color: var(--muted);
font-size: var(--fs-xs);
}
.cf-check {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
min-height: var(--control-h-md);
font-size: var(--fs-base);
font-weight: var(--fw-medium);
color: var(--ink);
cursor: pointer;
}
.cf-check input[type='checkbox'] {
width: 18px;
height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
</style>
+110 -197
View File
@@ -1,6 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Input as AInput,
List as AList,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
@@ -11,6 +29,9 @@ import type {
PaginatedResponse,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const props = defineProps<{
entityType: 'person' | 'company' | 'deal';
entityId: string;
@@ -187,210 +208,102 @@ onMounted(load);
</script>
<template>
<section class="timeline">
<header class="head">
<h3>تایم‌لاین</h3>
<p class="muted">یادداشت‌ها و فعالیت‌های مرتبط با این پرونده</p>
</header>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">تایم‌لاین</a-typography-title>
<a-typography-text type="secondary">
یادداشت‌ها و فعالیت‌های مرتبط با این پرونده
</a-typography-text>
<div class="composers">
<div class="composer">
<textarea
v-model="noteDraft"
rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
<a-textarea
v-model:value="noteDraft"
:rows="2"
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<VoiceToTextButton
@transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))"
/>
<div class="compose-actions">
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
</div>
<div class="composer">
<input
v-model="activityDraft"
type="text"
<a-button
type="primary"
:disabled="saving || !noteDraft.trim()"
:loading="saving"
@click="addNote"
>
ثبت یادداشت
</a-button>
</a-flex>
<a-space-compact block style="width: 100%">
<a-input
v-model:value="activityDraft"
placeholder="عنوان فعالیت / پیگیری…"
@keydown.enter.prevent="addActivity"
style="width: 100%"
@pressEnter="addActivity"
/>
<button
class="btn btn-ghost"
type="button"
<a-button
:disabled="saving || !activityDraft.trim()"
@click="addActivity"
>
افزودن فعالیت
</button>
</div>
</div>
</a-button>
</a-space-compact>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<ul v-else-if="items.length" class="list">
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]">
<div class="item-main">
<span class="badge">{{ item.meta }}</span>
<strong>{{ item.title }}</strong>
<p v-if="item.body" class="body">{{ item.body }}</p>
<time>{{ formatTime(item.at) }}</time>
</div>
<div class="item-actions">
<RouterLink :to="item.href">باز کردن</RouterLink>
<button
v-if="item.kind === 'activity' && !item.done"
class="linkish"
type="button"
@click="completeActivity(item)"
>
انجام شد
</button>
</div>
</li>
</ul>
<p v-else class="muted">هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p>
</section>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<a-spin :spinning="loading">
<a-list
v-if="items.length"
item-layout="horizontal"
:data-source="items"
:style="{ marginTop: '16px' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space :size="8">
<a-tag :bordered="false">{{ item.meta }}</a-tag>
<span>{{ item.title }}</span>
</a-space>
</template>
<template #description>
<a-typography-paragraph
v-if="item.body"
type="secondary"
:style="{ marginBottom: '4px' }"
>
{{ item.body }}
</a-typography-paragraph>
<a-typography-text type="secondary">{{ formatTime(item.at) }}</a-typography-text>
</template>
</a-list-item-meta>
<template #actions>
<RouterLink :to="item.href">باز کردن</RouterLink>
<a-button
v-if="item.kind === 'activity' && !item.done"
type="link"
@click="completeActivity(item)"
>
انجام شد
</a-button>
</template>
</a-list-item>
</template>
</a-list>
<a-empty
v-else-if="!loading"
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }"
/>
</a-spin>
</a-card>
</template>
<style scoped>
.timeline {
margin-top: 1.25rem;
padding-top: 1.1rem;
border-top: 1px solid var(--line);
display: grid;
gap: 0.9rem;
}
.head h3 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.head .muted {
margin: 0;
}
.composers {
display: grid;
gap: 0.75rem;
}
.composer {
display: grid;
gap: 0.5rem;
}
.composer textarea,
.composer input {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
background: #fff;
}
.compose-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.55rem;
}
.item {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fbfcfb;
}
@media (max-width: 640px) {
.item {
flex-direction: column;
}
.item-actions {
justify-items: start;
flex-direction: row;
display: flex;
gap: 0.75rem;
}
}
.item.note {
border-right: 3px solid var(--accent);
}
.item.activity {
border-right: 3px solid #b7791f;
}
.item.done {
opacity: 0.72;
}
.item-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.badge {
font-size: 0.75rem;
color: var(--muted);
}
.body {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
time {
font-size: 0.78rem;
color: var(--muted);
}
.item-actions {
display: grid;
gap: 0.35rem;
justify-items: end;
flex-shrink: 0;
}
.item-actions a,
.linkish {
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
font-size: 0.85rem;
padding: 0;
}
.error {
color: var(--danger);
margin: 0;
}
.muted {
color: var(--muted);
margin: 0;
}
</style>
+96 -129
View File
@@ -13,7 +13,20 @@ import {
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
import {
Col as ACol,
Flex as AFlex,
Modal as AModal,
Progress as AProgress,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
previewPath: string;
@@ -48,7 +61,6 @@ const emit = defineEmits<{
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
@@ -77,11 +89,6 @@ const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
@@ -367,11 +374,13 @@ function apiMessage(e: unknown, fallback: string) {
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
void handleExcelFile(file as File);
return false;
};
async function handleExcelFile(file: File) {
if (props.disabled || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
@@ -413,7 +422,6 @@ async function onFile(ev: Event) {
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
@@ -443,126 +451,85 @@ async function confirmMapping() {
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<a-upload
:show-upload-list="false"
:disabled="disabled || submitting"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:before-upload="beforeUpload"
@click.stop
>
<slot :loading="submitting" :disabled="disabled || submitting" />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
</a-upload>
<UiBusyOverlay
<a-modal
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
:footer="null"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
>
<a-flex vertical align="center" :gap="16">
<a-spin />
<a-typography-text>{{ busyMessage }}</a-typography-text>
<a-progress
v-if="busyProgress != null"
:percent="busyProgress"
style="width: 100%"
/>
</a-flex>
</a-modal>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
<a-modal
:open="!!preview"
title="تطبیق ستون‌های اکسل"
:confirm-loading="submitting"
ok-text="ورود فایل"
cancel-text="انصراف"
:ok-button-props="{ disabled: !canImport }"
@ok="confirmMapping"
@cancel="closeMapper"
>
<a-typography-paragraph type="secondary">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
</a-typography-paragraph>
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
:disabled="submitting"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
</UiButton>
</template>
</UiModal>
</a-typography-paragraph>
<a-flex vertical :gap="12">
<a-row
v-for="col in preview?.columns ?? []"
:key="col.header"
:gutter="[16, 8]"
align="middle"
>
<a-col :xs="24" :md="12">
<a-typography-text strong>{{ col.header }}</a-typography-text>
<div v-if="col.samples.length">
<a-typography-text type="secondary">
نمونه: {{ col.samples.join('، ') }}
</a-typography-text>
</div>
</a-col>
<a-col :xs="24" :md="12">
<a-select
:value="mapping[col.header] ?? ''"
:disabled="submitting"
style="width: 100%"
:options="[
{ value: '', label: 'نادیده بگیر' },
...(preview?.fields ?? []).map((field) => ({
value: field.key,
label: field.required ? `${field.label} *` : field.label,
})),
]"
@update:value="(v) => setColumnField(col.header, String(v ?? ''))"
/>
</a-col>
</a-row>
</a-flex>
<a-typography-text v-if="!canImport" type="danger">
فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.
</a-typography-text>
</a-modal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,427 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
Button as AButton,
Flex as AFlex,
Modal as AModal,
Segmented as ASegmented,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import { PrinterOutlined } from '@ant-design/icons-vue';
import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money';
const { Text: ATypographyText } = Typography;
export type PreviewLine = {
name: string;
description?: string;
sku?: string;
quantity: number;
unitPrice: number;
total: number;
};
type PaperSize = 'A4' | 'A5';
const props = defineProps<{
open: boolean;
docMode: 'invoice' | 'quote' | 'credit';
orgName: string;
number: string;
title: string;
statusLabel: string;
issuedAt: string;
dueOrValid: string;
dueOrValidLabel: string;
customerName: string;
customerSub?: string;
notes: string;
currency: string;
lines: PreviewLine[];
subtotal: number;
discount: number;
taxAmount: number;
taxPercent: number;
payable: number;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const paperSize = ref<PaperSize>('A4');
const thStyle = {
padding: '7px 8px',
textAlign: 'right' as const,
color: '#6b7280',
fontWeight: '700',
fontSize: '11px',
borderBottom: '1px solid #e5e7eb',
};
const tdStyle = {
padding: '7px 8px',
textAlign: 'right' as const,
borderBottom: '1px solid #e5e7eb',
verticalAlign: 'top' as const,
};
watch(
() => props.open,
(open) => {
if (open) paperSize.value = 'A4';
},
);
const paperOptions = [
{ value: 'A4', label: 'A4' },
{ value: 'A5', label: 'A5' },
];
const docTypeLabel = computed(() => {
if (props.docMode === 'quote') return 'پیش‌فاکتور';
if (props.docMode === 'credit') return 'فاکتور برگشتی';
return 'فاکتور فروش';
});
const accent = '#10b981';
const displayNumber = computed(() => props.number?.trim() || 'پیش‌نویس');
const paperWidth = computed(() => (paperSize.value === 'A5' ? 520 : 720));
function faDate(value: string) {
if (!value) return '—';
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function close() {
emit('update:open', false);
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function money(n: number) {
return formatMoney(n, props.currency);
}
function printSheet() {
const size = paperSize.value;
const rowsHtml = props.lines.length
? props.lines
.map(
(line, index) => `<tr>
<td>${toFaDigits(index + 1)}</td>
<td>
<strong>${escapeHtml(line.name)}</strong>
${line.description ? `<div class="sub">${escapeHtml(line.description)}</div>` : ''}
</td>
<td class="ltr">${escapeHtml(line.sku || '—')}</td>
<td>${toFaDigits(line.quantity)}</td>
<td class="ltr">${escapeHtml(money(line.unitPrice))}</td>
<td class="ltr">${escapeHtml(money(line.total))}</td>
</tr>`,
)
.join('')
: `<tr><td colspan="6" class="empty">ردیفی ثبت نشده است</td></tr>`;
const win = window.open('', '_blank', 'noopener,noreferrer,width=900,height=1200');
if (!win) return;
const html = `<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(docTypeLabel.value)} ${escapeHtml(displayNumber.value)}</title>
<style>
@page { size: ${size}; margin: 12mm; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IRANSans", Tahoma, sans-serif;
color: #111827;
font-size: ${size === 'A5' ? '11px' : '12.5px'};
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.top { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.org { font-size: ${size === 'A5' ? '15px' : '18px'}; font-weight: 800; margin: 0; }
.doc { text-align: left; }
.doc-type { color: ${accent}; font-weight: 800; font-size: ${size === 'A5' ? '13px' : '15px'}; }
.num { font-weight: 700; direction: ltr; unicode-bidi: isolate; margin-top: 4px; }
.grid {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap: 8px 16px;
padding: 10px 0;
border-top: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 14px;
}
.lbl { color: #6b7280; font-size: 11px; display: block; margin-bottom: 2px; }
.val { font-weight: 600; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 7px 8px; border-bottom: 1px solid #e5e7eb; text-align: right; vertical-align: top; }
th { color: #6b7280; font-weight: 700; font-size: 11px; background: #f9fafb; }
.sub { color: #6b7280; font-size: 11px; margin-top: 2px; }
.empty { text-align: center; color: #9ca3af; padding: 16px !important; }
.ltr { direction: ltr; unicode-bidi: isolate; text-align: left; }
.foot {
display: flex;
justify-content: space-between;
gap: 20px;
margin-top: 14px;
align-items: flex-start;
}
.notes { flex: 1; color: #374151; }
.totals { width: 240px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; }
.t-row { display: flex; justify-content: space-between; margin-bottom: 6px; }
.t-row.pay { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; font-weight: 800; color: ${accent}; }
.muted { color: #6b7280; }
</style>
</head>
<body>
<div class="top">
<div>
<p class="org">${escapeHtml(props.orgName || brand.nameFa)}</p>
<div class="muted">${escapeHtml(docTypeLabel.value)}</div>
</div>
<div class="doc">
<div class="doc-type">${escapeHtml(docTypeLabel.value)}</div>
<div class="num">${escapeHtml(displayNumber.value)}</div>
</div>
</div>
<div class="grid">
<div><span class="lbl">صادر برای</span><div class="val">${escapeHtml(props.customerName || '—')}</div>
${props.customerSub ? `<div class="sub">${escapeHtml(props.customerSub)}</div>` : ''}
</div>
<div><span class="lbl">عنوان</span><div class="val">${escapeHtml(props.title || docTypeLabel.value)}</div></div>
<div><span class="lbl">وضعیت</span><div class="val">${escapeHtml(props.statusLabel)}</div></div>
<div><span class="lbl">تاریخ صدور</span><div class="val">${escapeHtml(faDate(props.issuedAt))}</div></div>
<div><span class="lbl">${escapeHtml(props.dueOrValidLabel)}</span><div class="val">${escapeHtml(faDate(props.dueOrValid))}</div></div>
<div><span class="lbl">ارز</span><div class="val">${escapeHtml(currencySuffix(props.currency))} (${escapeHtml(props.currency)})</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>شرح</th>
<th>کد</th>
<th>تعداد</th>
<th>فی</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
<div class="foot">
<div class="notes">
${
props.notes?.trim()
? `<span class="lbl">یادداشت</span><div>${escapeHtml(props.notes)}</div>`
: ''
}
</div>
<div class="totals">
<div class="t-row"><span class="muted">جمع</span><span class="ltr">${escapeHtml(money(props.subtotal))}</span></div>
<div class="t-row"><span class="muted">تخفیف</span><span class="ltr">${escapeHtml(money(props.discount))}</span></div>
<div class="t-row"><span class="muted">مالیات</span><span class="ltr">${escapeHtml(money(props.taxAmount))}</span></div>
<div class="t-row pay"><span>قابل پرداخت</span><span class="ltr">${escapeHtml(money(props.payable))}</span></div>
</div>
</div>
</body>
</html>`;
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
setTimeout(() => {
win.print();
win.close();
}, 250);
}
</script>
<template>
<a-modal
:open="open"
title="پیش‌نمایش سند"
:width="paperSize === 'A5' ? 640 : 840"
destroy-on-close
centered
:body-style="{ paddingTop: '12px' }"
@cancel="close"
>
<template #footer>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" style="width: 100%">
<a-segmented v-model:value="paperSize" :options="paperOptions" />
<a-space :size="8">
<a-button @click="close">بستن</a-button>
<a-button type="primary" @click="printSheet">
<template #icon><PrinterOutlined /></template>
چاپ {{ paperSize }}
</a-button>
</a-space>
</a-flex>
</template>
<a-flex justify="center" :style="{ background: '#f3f4f6', padding: '16px', borderRadius: '8px' }">
<div
:style="{
width: '100%',
maxWidth: `${paperWidth}px`,
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '4px',
padding: paperSize === 'A5' ? '18px 20px' : '24px 28px',
fontSize: paperSize === 'A5' ? '12px' : '13px',
color: '#111827',
}"
>
<a-flex justify="space-between" align="flex-start" :gap="12" :style="{ marginBottom: '14px' }">
<div>
<div :style="{ fontWeight: 800, fontSize: paperSize === 'A5' ? '15px' : '18px' }">
{{ orgName || brand.nameFa }}
</div>
<a-typography-text type="secondary">{{ docTypeLabel }}</a-typography-text>
</div>
<div :style="{ textAlign: 'left' }">
<div :style="{ color: accent, fontWeight: 800 }">{{ docTypeLabel }}</div>
<div dir="ltr" :style="{ fontWeight: 700, marginTop: '4px' }">{{ displayNumber }}</div>
</div>
</a-flex>
<div
:style="{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '8px 12px',
padding: '10px 0',
borderTop: '1px solid #e5e7eb',
borderBottom: '1px solid #e5e7eb',
marginBottom: '14px',
}"
>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">صادر برای</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ customerName || '—' }}</div>
<a-typography-text v-if="customerSub" type="secondary" :style="{ fontSize: '11px' }">
{{ customerSub }}
</a-typography-text>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">عنوان</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ title || docTypeLabel }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">وضعیت</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ statusLabel }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">تاریخ صدور</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ faDate(issuedAt) }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">{{ dueOrValidLabel }}</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ faDate(dueOrValid) }}</div>
</div>
<div>
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">ارز</a-typography-text>
<div :style="{ fontWeight: 600 }">{{ currencySuffix(currency) }} ({{ currency }})</div>
</div>
</div>
<table :style="{ width: '100%', borderCollapse: 'collapse' }">
<thead>
<tr :style="{ background: '#f9fafb' }">
<th :style="thStyle">#</th>
<th :style="thStyle">شرح</th>
<th :style="thStyle">کد</th>
<th :style="thStyle">تعداد</th>
<th :style="thStyle">فی</th>
<th :style="thStyle">مبلغ</th>
</tr>
</thead>
<tbody>
<tr v-if="!lines.length">
<td colspan="6" :style="{ textAlign: 'center', color: '#9ca3af', padding: '16px' }">
ردیفی در فرم ثبت نشده است
</td>
</tr>
<tr v-for="(line, index) in lines" :key="`${line.name}-${index}`">
<td :style="tdStyle">{{ toFaDigits(index + 1) }}</td>
<td :style="tdStyle">
<div :style="{ fontWeight: 600 }">{{ line.name }}</div>
<div v-if="line.description" :style="{ color: '#6b7280', fontSize: '11px' }">
{{ line.description }}
</div>
</td>
<td :style="tdStyle" dir="ltr">{{ line.sku || '—' }}</td>
<td :style="tdStyle">{{ toFaDigits(line.quantity) }}</td>
<td :style="tdStyle" dir="ltr">{{ money(line.unitPrice) }}</td>
<td :style="tdStyle" dir="ltr">{{ money(line.total) }}</td>
</tr>
</tbody>
</table>
<a-flex justify="space-between" align="flex-start" :gap="16" :style="{ marginTop: '14px' }">
<div :style="{ flex: 1, minWidth: 0 }">
<template v-if="notes?.trim()">
<a-typography-text type="secondary" :style="{ fontSize: '11px', display: 'block' }">یادداشت</a-typography-text>
<div>{{ notes }}</div>
</template>
</div>
<div
:style="{
width: paperSize === 'A5' ? '200px' : '240px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '10px 12px',
flexShrink: 0,
}"
>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">جمع</a-typography-text>
<span dir="ltr">{{ money(subtotal) }}</span>
</a-flex>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">تخفیف</a-typography-text>
<span dir="ltr">{{ money(discount) }}</span>
</a-flex>
<a-flex justify="space-between" :style="{ marginBottom: '6px' }">
<a-typography-text type="secondary">مالیات</a-typography-text>
<span dir="ltr">{{ money(taxAmount) }}</span>
</a-flex>
<a-flex
justify="space-between"
:style="{
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid #e5e7eb',
fontWeight: 800,
color: accent,
}"
>
<span>قابل پرداخت</span>
<span dir="ltr">{{ money(payable) }}</span>
</a-flex>
</div>
</a-flex>
</div>
</a-flex>
</a-modal>
</template>
+56 -64
View File
@@ -1,5 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Button as AButton, Input as AInput } from 'ant-design-vue';
import {
CalendarOutlined,
ClockCircleOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import {
addJalaliMonths,
dateToJalali,
@@ -12,7 +19,6 @@ import {
parseLocalInput,
toFaDigits,
} from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
@@ -39,7 +45,7 @@ const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const MOBILE_MQ = '(max-width: 720px)';
const open = ref(false);
const triggerRef = ref<HTMLButtonElement | null>(null);
const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const clockRef = ref<HTMLElement | null>(null);
const hourColRef = ref<HTMLElement | null>(null);
@@ -439,41 +445,28 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="wrap" :class="{ disabled, 'time-only': mode === 'time' }">
<button
<div
ref="triggerRef"
class="wrap"
:class="{ disabled, 'time-only': mode === 'time' }"
style="width: 100%"
>
<a-input
:id="id"
ref="triggerRef"
class="trigger"
:class="{ 'clock-trigger': mode === 'time' }"
type="button"
readonly
:value="display"
:placeholder="emptyLabel"
:disabled="disabled"
:aria-expanded="open"
:aria-required="required || undefined"
style="width: 100%; cursor: pointer"
@click="toggle"
>
<template v-if="mode === 'time'">
<span class="clock-face" aria-hidden="true">
<i
class="hand hour-hand"
:style="{
transform: `rotate(${(draftHour % 12) * 30 + draftMinute * 0.5}deg)`,
}"
/>
<i
class="hand minute-hand"
:style="{ transform: `rotate(${draftMinute * 6}deg)` }"
/>
<i class="hub" />
</span>
<span class="clock-text" :class="{ empty: !display }">
{{ display || emptyLabel }}
</span>
<template #suffix>
<ClockCircleOutlined v-if="triggerIcon === 'clock'" />
<CalendarOutlined v-else />
</template>
<template v-else>
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
<UiIcon :name="triggerIcon" :size="18" />
</template>
</button>
</a-input>
<Teleport to="body">
<div
@@ -490,13 +483,13 @@ onBeforeUnmount(() => {
:style="{ top: `${top}px`, left: `${left}px` }"
>
<header v-if="mode !== 'time'" class="nav">
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<UiIcon name="chevron" :size="16" />
</button>
<a-button type="text" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<template #icon><RightOutlined /></template>
</a-button>
<strong>{{ monthTitle }}</strong>
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<UiIcon name="chevron" :size="16" />
</button>
<a-button type="text" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<template #icon><LeftOutlined /></template>
</a-button>
</header>
<template v-if="mode !== 'time'">
@@ -504,16 +497,16 @@ onBeforeUnmount(() => {
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div>
<div class="grid">
<button
<a-button
v-for="cell in cells"
:key="cell.key"
type="button"
type="text"
class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)"
>
{{ toFaDigits(cell.jd) }}
</button>
</a-button>
</div>
</template>
@@ -521,25 +514,25 @@ onBeforeUnmount(() => {
<template v-if="isMobile">
<p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p>
<div class="md-digits" dir="ltr">
<button
type="button"
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'hour' }"
aria-label="ساعت"
@click="clockFace = 'hour'"
>
{{ faPad(draftHour) }}
</button>
</a-button>
<span class="md-colon">:</span>
<button
type="button"
<a-button
type="text"
class="md-chip"
:class="{ on: clockFace === 'minute' }"
aria-label="دقیقه"
@click="clockFace = 'minute'"
>
{{ faPad(draftMinute) }}
</button>
</a-button>
</div>
<div
ref="clockRef"
@@ -561,10 +554,10 @@ onBeforeUnmount(() => {
</span>
<i class="md-hub" />
<template v-if="clockFace === 'hour'">
<button
<a-button
v-for="h in hours"
:key="`h-${h}`"
type="button"
type="text"
class="md-tick"
:class="{ on: draftHour === h, inner: h >= 12 }"
:style="hourDialStyle(h)"
@@ -572,13 +565,13 @@ onBeforeUnmount(() => {
@click.stop="setHour(h)"
>
{{ toFaDigits(h) }}
</button>
</a-button>
</template>
<template v-else>
<button
<a-button
v-for="m in clockMinutes"
:key="`m-${m}`"
type="button"
type="text"
class="md-tick"
:class="{ on: draftMinute === m }"
:style="minuteDialStyle(m)"
@@ -586,7 +579,7 @@ onBeforeUnmount(() => {
@click.stop="setMinute(m)"
>
{{ faPad(m) }}
</button>
</a-button>
</template>
</div>
</template>
@@ -596,31 +589,31 @@ onBeforeUnmount(() => {
<div class="col">
<span class="col-label">ساعت</span>
<div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت">
<button
<a-button
v-for="h in hours"
:key="`col-h-${h}`"
type="button"
type="text"
class="col-item"
:class="{ on: draftHour === h }"
@click="setHour(h)"
>
{{ faPad(h) }}
</button>
</a-button>
</div>
</div>
<div class="col">
<span class="col-label">دقیقه</span>
<div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه">
<button
<a-button
v-for="m in columnMinutes"
:key="`col-m-${m}`"
type="button"
type="text"
class="col-item"
:class="{ on: draftMinute === m }"
@click="setMinute(m)"
>
{{ faPad(m) }}
</button>
</a-button>
</div>
</div>
</div>
@@ -628,18 +621,17 @@ onBeforeUnmount(() => {
</div>
<footer class="foot">
<button type="button" class="link" @click="pickToday">
<a-button type="link" @click="pickToday">
{{ mode === 'time' ? 'الان' : 'امروز' }}
</button>
<button
</a-button>
<a-button
v-if="clearable && !required && model"
type="button"
class="link"
type="link"
@click="clear"
>
پاک کردن
</button>
<button type="button" class="done" @click="hide">تأیید</button>
</a-button>
<a-button type="primary" @click="hide">تأیید</a-button>
</footer>
</div>
</Teleport>
+95 -150
View File
@@ -1,10 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
List as AList,
Modal,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = defineProps<{
personId: string;
}>();
@@ -86,15 +103,24 @@ async function saveEdit(id: string) {
}
}
async function removeItem(id: string) {
if (!confirm('این مورد از حافظه حذف شود؟')) return;
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
}
function removeItem(id: string) {
Modal.confirm({
title: 'حذف از حافظه',
content: 'این مورد از حافظه حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/memory/${id}`);
if (editingId.value === id) cancelEdit();
await load();
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function formatWhen(value: string) {
@@ -110,152 +136,71 @@ onMounted(load);
</script>
<template>
<section class="memory-panel">
<header>
<div>
<p class="eyebrow">{{ brand.memoryEn }}</p>
<h2>حافظه مشتری</h2>
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.</p>
</div>
</header>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-typography-text type="secondary" strong>{{ brand.memoryEn }}</a-typography-text>
<a-typography-title :level="5" :style="{ margin: '4px 0' }">حافظه مشتری</a-typography-title>
<a-typography-paragraph type="secondary" :style="{ marginBottom: '12px' }">
نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.
</a-typography-paragraph>
<form class="add-form" @submit.prevent="addItem">
<textarea
v-model="draft"
rows="2"
<a-flex :gap="8" align="flex-start" :style="{ marginBottom: '12px' }">
<a-textarea
v-model:value="draft"
:rows="2"
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبح‌ها"
style="flex: 1"
@pressEnter.ctrl="addItem"
/>
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
<a-button
type="primary"
:disabled="saving || !draft.trim()"
:loading="saving"
@click="addItem"
>
افزودن
</button>
</form>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<ul v-else class="items">
<li v-for="item in items" :key="item.id">
<template v-if="editingId === item.id">
<textarea v-model="editContent" rows="2" />
<div class="row-actions">
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
ذخیره
</button>
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
</div>
<a-spin :spinning="loading">
<a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده.
</a-typography-text>
<a-list v-else-if="hasItems" item-layout="vertical" :data-source="items">
<template #renderItem="{ item }">
<a-list-item>
<template v-if="editingId === item.id">
<a-flex vertical :gap="8" style="width: 100%">
<a-textarea v-model:value="editContent" :rows="2" />
<a-space :size="8">
<a-button type="primary" :loading="saving" @click="saveEdit(item.id)">ذخیره</a-button>
<a-button @click="cancelEdit">انصراف</a-button>
</a-space>
</a-flex>
</template>
<a-list-item-meta v-else>
<template #title>
<a-typography-paragraph :style="{ marginBottom: 0, whiteSpace: 'pre-wrap' }">
{{ item.content }}
</a-typography-paragraph>
</template>
<template #description>
<a-typography-text type="secondary">
{{ sourceLabel[item.sourceType] ?? item.sourceType }}
<template v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</template>
· {{ formatWhen(item.updatedAt) }}
</a-typography-text>
</template>
</a-list-item-meta>
<template v-if="editingId !== item.id" #actions>
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
<a-button type="link" danger @click="removeItem(item.id)">حذف</a-button>
</template>
</a-list-item>
</template>
<template v-else>
<p class="content">{{ item.content }}</p>
<p class="meta">
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
<span> · {{ formatWhen(item.updatedAt) }}</span>
</p>
<div class="row-actions">
<button type="button" @click="startEdit(item)">ویرایش</button>
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
</div>
</template>
</li>
</ul>
</section>
</a-list>
</a-spin>
</a-card>
</template>
<style scoped>
.memory-panel {
display: grid;
gap: 0.85rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
font-size: 0.85rem;
}
h2 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.lead {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.92rem;
}
.add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.6rem;
align-items: start;
}
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.9rem;
background: #fff;
font: inherit;
width: 100%;
resize: vertical;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.items li {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 0.85rem;
background: #fff;
}
.content {
margin: 0;
white-space: pre-wrap;
}
.meta {
margin: 0.35rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.row-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.row-actions button {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.row-actions button.danger {
color: #b42318;
}
.muted {
color: var(--muted);
}
.error {
color: #b42318;
}
</style>
+12 -12
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { Input as AInput } from 'ant-design-vue';
import {
caretIndexForDigitCount,
countDigitChars,
@@ -34,7 +35,14 @@ const props = withDefaults(
const text = ref('');
const focused = ref(false);
const inputRef = ref<HTMLInputElement | null>(null);
const inputRef = ref<{ input?: HTMLInputElement } | HTMLInputElement | null>(null);
function nativeInput(): HTMLInputElement | null {
const el = inputRef.value;
if (!el) return null;
if (el instanceof HTMLInputElement) return el;
return el.input ?? null;
}
function readModelNumber(): number | null {
if (model.value === '' || model.value == null) return null;
@@ -83,7 +91,6 @@ async function onInput(event: Event) {
const n = parseMoneyNumber(raw, props.decimals);
if (n == null) {
// اجازهٔ تایپ ناقص مثل "-" یا خالی
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
text.value = cleaned;
writeModel(null);
@@ -99,7 +106,7 @@ async function onInput(event: Event) {
writeModel(n);
await nextTick();
const input = inputRef.value;
const input = nativeInput();
if (!input) return;
const nextCaret = caretIndexForDigitCount(display, digitsBefore);
input.setSelectionRange(nextCaret, nextCaret);
@@ -111,27 +118,20 @@ function normalizeSep(raw: string): string {
</script>
<template>
<input
<a-input
:id="id"
ref="inputRef"
class="money-input"
type="text"
inputmode="decimal"
autocomplete="off"
:dir="dir"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:readonly="readonly"
:value="text"
style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
</template>
<style scoped>
.money-input {
font-variant-numeric: tabular-nums;
}
</style>
+119 -227
View File
@@ -4,8 +4,21 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import {
Avatar as AAvatar,
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
List as AList,
ListItem as AListItem,
Modal as AModal,
Space as ASpace,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import {
filterPhoneContacts,
isContactPickerSupported,
@@ -15,6 +28,8 @@ import {
type PhoneContactDraft,
} from '@/lib/phone-contacts';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = withDefaults(
defineProps<{
disabled?: boolean;
@@ -38,7 +53,6 @@ const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set());
const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => {
@@ -65,7 +79,6 @@ async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolea
drafts.value = sortPhoneContacts(rows);
listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
phase.value = 'closed';
await nextTick();
phase.value = 'review';
@@ -83,7 +96,6 @@ function selectAll(on: boolean) {
selected.value = new Set();
return;
}
// اگر جستجو فعال است فقط نتایج را انتخاب کن، وگرنه همه
if (listQuery.value.trim()) {
const next = new Set(selected.value);
for (const row of visibleDrafts.value) next.add(row.key);
@@ -121,7 +133,6 @@ async function runDevicePicker() {
emit('fail', 'مخاطبی انتخاب نشد.');
return;
}
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false });
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
@@ -143,11 +154,12 @@ function startPicker() {
openHelp();
}
async function onVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
void handleVcfFile(file as File);
return false;
};
async function handleVcfFile(file: File) {
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
@@ -223,68 +235,70 @@ async function importSelected() {
</script>
<template>
<span class="wrap" @click.stop>
<span class="trigger" @click="startPicker">
<span @click.stop>
<span @click="startPicker">
<slot />
</span>
<input
ref="fileInput"
class="hidden"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onVcfFile"
/>
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<a-modal
:open="modalOpen"
:title="modalTitle"
:confirm-loading="importing"
@cancel="cancel"
>
<template v-if="phase === 'choose'">
<p class="lead">
<a-typography-paragraph type="secondary">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p>
<div class="choose-actions">
<UiButton variant="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" />
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block @click="runDevicePicker">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block @click="fileInput?.click()">
از فایل .vcf
</UiButton>
</div>
</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button block>از فایل .vcf</a-button>
</a-upload>
</a-flex>
</template>
<template v-else-if="phase === 'help'">
<p class="lead">
<a-typography-paragraph type="secondary">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</p>
<ul class="tips">
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
</ul>
</a-typography-paragraph>
<a-typography-paragraph type="secondary">
اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
Export → فایل vCard.
</a-typography-paragraph>
</template>
<template v-else-if="phase === 'review'">
<p class="lead">
<a-typography-paragraph type="secondary">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p>
<div class="search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<div class="toolbar">
<button type="button" class="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button>
<span class="count">
</a-typography-paragraph>
<a-input
v-model:value="listQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
style="width: 100%; margin-bottom: 12px"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<a-button type="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</a-button>
<a-button type="link" @click="selectAll(false)">هیچ‌کدام</a-button>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
@@ -292,194 +306,72 @@ async function importSelected() {
<template v-else>
از {{ toFaDigits(drafts.length) }}
</template>
</span>
</div>
<ul v-if="visibleDrafts.length" class="list">
<li v-for="row in visibleDrafts" :key="row.key">
<label>
<input
type="checkbox"
:checked="selected.has(row.key)"
</a-typography-text>
</a-flex>
<a-list
v-if="visibleDrafts.length"
:data-source="visibleDrafts"
:style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-checkbox
:checked="selected.has(item.key)"
:disabled="importing"
@change="toggle(row.key)"
/>
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</label>
</li>
</ul>
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
@click.prevent="toggle(item.key)"
>
<a-space :size="8">
<a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
<span>
<div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
<a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
</span>
</a-space>
</a-checkbox>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</template>
<template #footer>
<template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
<a-button @click="cancel">انصراف</a-button>
</template>
<template v-else-if="phase === 'help'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
انتخاب فایل .vcf
</UiButton>
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
</a-upload>
</template>
<template v-else-if="phase === 'review'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
فایل دیگر
</UiButton>
<UiButton
variant="primary"
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
<a-upload
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
:disabled="importing"
>
<a-button :disabled="importing">فایل دیگر</a-button>
</a-upload>
<a-button
type="primary"
:disabled="importing || !selectedCount"
:loading="importing"
@click="importSelected"
>
<UiIcon name="user-plus" :size="16" />
<template #icon><UserAddOutlined /></template>
ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }}
</UiButton>
</a-button>
</template>
</template>
</UiModal>
</a-modal>
</span>
</template>
<style scoped>
.wrap {
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.hidden {
display: none;
}
.lead {
margin: 0 0 0.85rem;
color: #64748b;
font-size: 0.88rem;
line-height: 1.7;
}
.choose-actions {
display: grid;
gap: 0.55rem;
}
.tips {
margin: 0;
padding: 0 1.1rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.8;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: 0.84rem;
color: #0f172a;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.7rem;
flex-wrap: wrap;
}
.link {
border: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.78rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.list label {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
cursor: pointer;
}
.avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: #ecfdf8;
color: #0f766e;
display: grid;
place-items: center;
font-weight: 700;
flex-shrink: 0;
}
.meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.meta strong {
font-size: 0.86rem;
}
.meta small {
color: #94a3b8;
font-size: 0.74rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
margin: 0.75rem 0 0;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
</style>
+4 -1
View File
@@ -65,7 +65,10 @@ const items: Array<{
const route = useRoute();
const router = useRouter();
const hidden = computed(() => route.name === 'person-detail');
/** صفحات با نوار اکشن ثابت موبایل — FAB نباید وسط صفحه رها شود */
const hidden = computed(
() => route.name === 'person-detail' || route.name === 'finance-incomes',
);
const isQuickCreateOpen = ref(false);
const ready = ref(false);
const fabPos = ref<FabPos>({
+105 -183
View File
@@ -1,37 +1,58 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Modal,
Modal as AModal,
Select as ASelect,
Space,
message,
} from 'ant-design-vue';
const ASpaceCompact = Space.Compact;
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
const props = defineProps<{
scope: SavedViewScope;
/** فیلتر فعلی صفحه برای ذخیره */
currentFilters: Record<string, unknown>;
}>();
const props = withDefaults(
defineProps<{
scope: SavedViewScope;
currentFilters: Record<string, unknown>;
/** فشرده برای قرار گرفتن داخل یک ردیف فیلتر */
compact?: boolean;
}>(),
{ compact: false },
);
const emit = defineEmits<{
apply: [filters: Record<string, unknown>];
}>();
const views = ref<SavedViewDto[]>([]);
const activeId = ref('');
const activeId = ref<string | undefined>(undefined);
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(false);
useEscClose(() => saveOpen.value, () => { saveOpen.value = false; });
const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null,
);
const selectOptions = computed(() =>
views.value.map((v) => ({
value: v.id,
label: `${v.name}${v.isShared ? ' (سازمانی)' : ''}`,
})),
);
async function loadViews() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<SavedViewDto[]>('/saved-views', {
params: { scope: props.scope },
@@ -39,16 +60,17 @@ async function loadViews() {
views.value = data;
} catch {
views.value = [];
error.value = 'بارگذاری فیلترهای ذخیره‌شده ناموفق بود.';
message.error('بارگذاری فیلترهای ذخیره‌شده ناموفق بود.');
} finally {
loading.value = false;
}
}
function onPick(id: string) {
activeId.value = id;
if (!id) return;
const view = views.value.find((v) => v.id === id);
function onPick(id: unknown) {
const next = typeof id === 'string' ? id : '';
activeId.value = next || undefined;
if (!next) return;
const view = views.value.find((v) => v.id === next);
if (view) emit('apply', view.filters ?? {});
}
@@ -56,14 +78,12 @@ function openSave() {
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? false;
saveOpen.value = true;
error.value = '';
}
async function saveView() {
const name = saveName.value.trim();
if (name.length < 2 || busy.value) return;
busy.value = true;
error.value = '';
try {
const body = {
name,
@@ -86,32 +106,42 @@ async function saveView() {
activeId.value = data.id;
}
saveOpen.value = false;
message.success('فیلتر ذخیره شد.');
} catch {
error.value = 'ذخیره فیلتر ناموفق بود.';
message.error('ذخیره فیلتر ناموفق بود.');
} finally {
busy.value = false;
}
}
async function removeView() {
function removeView() {
if (!activeId.value || busy.value) return;
if (!confirm('این فیلتر ذخیره‌شده حذف شود؟')) return;
busy.value = true;
try {
await api.delete(`/saved-views/${activeId.value}`);
views.value = views.value.filter((v) => v.id !== activeId.value);
activeId.value = '';
} catch {
error.value = 'حذف فیلتر ناموفق بود.';
} finally {
busy.value = false;
}
Modal.confirm({
title: 'حذف فیلتر',
content: 'این فیلتر ذخیره‌شده حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
busy.value = true;
try {
await api.delete(`/saved-views/${activeId.value}`);
views.value = views.value.filter((v) => v.id !== activeId.value);
activeId.value = undefined;
message.success('فیلتر حذف شد.');
} catch {
message.error('حذف فیلتر ناموفق بود.');
} finally {
busy.value = false;
}
},
});
}
watch(
() => props.scope,
() => {
activeId.value = '';
activeId.value = undefined;
void loadViews();
},
);
@@ -122,161 +152,53 @@ onMounted(() => {
</script>
<template>
<div class="saved-filters" dir="rtl">
<label class="pick">
<span class="lbl">فیلتر ذخیره‌شده</span>
<select
:value="activeId"
:disabled="loading"
@change="onPick(($event.target as HTMLSelectElement).value)"
>
<option value="">— انتخاب —</option>
<option v-for="v in views" :key="v.id" :value="v.id">
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
</option>
</select>
</label>
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
<UiIcon name="flag" :size="14" />
<!-- یک خط فشرده: سلکت + ذخیره (+ حذف) -->
<a-space-compact block dir="rtl" :style="{ width: '100%' }">
<a-select
:value="activeId"
allow-clear
:disabled="loading"
:loading="loading"
:options="selectOptions"
:placeholder="compact ? 'فیلتر ذخیره‌شده' : '— انتخاب —'"
:style="{ flex: 1, minWidth: 0, width: '100%' }"
@update:value="onPick"
/>
<a-button :disabled="loading" title="ذخیره فیلتر فعلی" @click="openSave">
<template #icon><FlagOutlined /></template>
ذخیره
</button>
<button
</a-button>
<a-button
v-if="activeId"
type="button"
class="btn danger"
title="حذف فیلتر"
danger
:disabled="busy"
title="حذف فیلتر"
@click="removeView"
>
حذف
</button>
<p v-if="error" class="err">{{ error }}</p>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-space-compact>
<div v-if="saveOpen" class="sheet">
<div class="sheet-backdrop" aria-hidden="true" />
<div class="sheet-panel" role="dialog" aria-modal="true">
<header class="sheet-head">
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
<button type="button" class="x-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="saveOpen = false">
<UiIcon name="x" :size="16" />
</button>
</header>
<label>
نام
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخ‌های این هفته" />
</label>
<label class="check">
<input v-model="saveShared" type="checkbox" />
اشتراک با سازمان
</label>
<div class="actions">
<button type="button" class="btn primary" :disabled="busy" @click="saveView">
{{ busy ? '…' : 'ذخیره' }}
</button>
<button type="button" class="btn" @click="saveOpen = false">لغو</button>
</div>
</div>
</div>
</div>
<a-modal
v-model:open="saveOpen"
:title="activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر'"
:confirm-loading="busy"
ok-text="ذخیره"
cancel-text="لغو"
destroy-on-close
@ok="saveView"
>
<a-form layout="vertical">
<a-form-item label="نام" required>
<a-input
v-model:value="saveName"
:maxlength="120"
placeholder="مثلاً سرنخ‌های این هفته"
/>
</a-form-item>
<a-form-item :style="{ marginBottom: 0 }">
<a-checkbox v-model:checked="saveShared">اشتراک با سازمان</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped>
.saved-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.45rem;
margin-top: 0.55rem;
}
.pick {
display: grid;
gap: 0.2rem;
min-width: 11rem;
}
.lbl {
font-size: 0.72rem;
color: #64748b;
}
select,
input[type='text'] {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
font-size: 0.85rem;
color: #0f172a;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 8px;
padding: 0.4rem 0.65rem;
font-size: 0.82rem;
cursor: pointer;
}
.btn.primary {
background: #0d9488;
border-color: #0d9488;
color: #fff;
}
.btn.danger {
color: #b91c1c;
}
.err {
width: 100%;
margin: 0;
font-size: 0.8rem;
color: #b91c1c;
}
.sheet {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
}
.sheet-panel {
position: relative;
z-index: 1;
width: min(100%, 360px);
background: #fff;
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
}
.sheet-panel h3 {
margin: 0;
font-size: 1rem;
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.x-btn { width: 32px; height: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #64748b; display: grid; place-items: center; cursor: pointer; }
.x-btn:hover { background: #f8fafc; color: #0f172a; }
.sheet-panel label {
display: grid;
gap: 0.3rem;
font-size: 0.85rem;
color: #334155;
}
.check {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.actions {
display: flex;
gap: 0.45rem;
}
</style>
+48 -154
View File
@@ -1,9 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, ref } from 'vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Flex as AFlex,
Popover as APopover,
Typography,
} from 'ant-design-vue';
import { SettingOutlined } from '@ant-design/icons-vue';
import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
tableKey: string;
@@ -14,164 +23,49 @@ const props = withDefaults(
);
const open = ref(false);
const root = ref<HTMLElement | null>(null);
const cols = useTableColumns(() => props.tableKey);
const columns = computed(() => cols.columns());
const hiddenCount = computed(() => cols.hiddenCount());
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
}
onMounted(() => document.addEventListener('click', onDocClick));
onBeforeUnmount(() => document.removeEventListener('click', onDocClick));
const placement = computed(() =>
props.align === 'end' ? 'bottomLeft' : 'bottomRight',
);
</script>
<template>
<div ref="root" class="tc-pop">
<button
class="tc-tool"
type="button"
:class="{ active: hiddenCount > 0 }"
:title="label"
@click.stop="open = !open"
>
<UiIcon name="sliders" :size="16" />
<span class="tc-label">{{ label }}</span>
<em v-if="hiddenCount" class="tc-badge">{{ toFaDigits(hiddenCount) }} مخفی</em>
</button>
<div v-if="open" class="tc-menu" :class="align" @click.stop>
<p class="tc-head">نمایش ستون‌ها</p>
<p v-if="!columns.length" class="tc-empty">ستونی برای نمایش وجود ندارد.</p>
<label v-for="col in columns" :key="col" class="tc-item">
<input type="checkbox" :checked="cols.isVisible(col)" @change="cols.toggle(col)" />
<span>{{ col }}</span>
</label>
<button v-if="hiddenCount" type="button" class="tc-reset" @click="cols.showAll()">
نمایش همه ستون‌ها
</button>
</div>
</div>
<a-popover v-model:open="open" trigger="click" :placement="placement">
<template #content>
<a-flex
vertical
:gap="8"
style="min-width: 13rem; max-width: calc(100vw - 1.5rem)"
>
<a-typography-text type="secondary" strong>
نمایش ستون‌ها
</a-typography-text>
<a-typography-text v-if="!columns.length" type="secondary">
ستونی برای نمایش وجود ندارد.
</a-typography-text>
<a-checkbox
v-for="col in columns"
:key="col"
:checked="cols.isVisible(col)"
@click.prevent="cols.toggle(col)"
>
{{ col }}
</a-checkbox>
<a-button v-if="hiddenCount" type="link" @click="cols.showAll()">
نمایش همه ستون‌ها
</a-button>
</a-flex>
</template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
<template #icon><SettingOutlined /></template>
{{ label }}
<template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی
</template>
</a-button>
</a-popover>
</template>
<style scoped>
.tc-pop {
position: relative;
}
.tc-tool {
min-height: var(--control-h, 2.4rem);
height: var(--control-h, 2.4rem);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
white-space: nowrap;
}
.tc-tool:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.tc-tool.active {
border-color: #5eead4;
color: #0f766e;
}
.tc-badge {
font-style: normal;
font-size: 0.72rem;
background: #f0fdfa;
color: #0f766e;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.tc-menu {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 20;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
padding: 0.45rem;
min-width: 13rem;
max-width: calc(100vw - 1.5rem);
max-height: min(24rem, 70vh);
overflow-y: auto;
display: grid;
gap: 0.1rem;
}
.tc-menu.end {
inset-inline-start: auto;
inset-inline-end: 0;
}
.tc-head {
margin: 0 0 0.25rem;
padding: 0.25rem 0.5rem;
font-size: var(--fs-xs, 0.75rem);
color: var(--ink-muted, #64748b);
font-weight: 700;
}
.tc-empty {
margin: 0;
padding: 0.4rem 0.5rem;
font-size: var(--fs-sm);
color: var(--ink-muted, #64748b);
}
.tc-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.42rem 0.5rem;
border-radius: var(--radius);
cursor: pointer;
font-size: var(--fs-sm);
user-select: none;
}
.tc-item:hover {
background: #f0fdfa;
}
.tc-item input {
width: 1rem;
height: 1rem;
accent-color: #0d9488;
margin: 0;
}
.tc-reset {
margin-top: 0.3rem;
border: 0;
border-top: 1px solid var(--line);
background: none;
padding: 0.5rem;
font: inherit;
font-size: var(--fs-sm);
color: #0f766e;
cursor: pointer;
text-align: start;
}
@media (max-width: 640px) {
.tc-label {
display: none;
}
}
</style>
+24 -326
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { matchesSearch } from '@/lib/search';
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
type PickUser = { id: string; fullName: string };
@@ -21,335 +19,35 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const root = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
let snapshot: string[] = [];
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
const selected = computed(() =>
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
const filtered = computed(() => {
if (!query.value.trim()) return props.users;
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
});
const canRemove = computed(() => props.modelValue.length > props.min);
function sameSet(a: string[], b: string[]) {
return a.length === b.length && a.every((id) => b.includes(id));
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
emit('update:modelValue', ids);
emit('change', ids);
}
function openList() {
if (props.disabled) return;
snapshot = [...props.modelValue];
query.value = '';
open.value = true;
void nextTick(() => searchInput.value?.focus());
}
function closeList() {
if (!open.value) return;
open.value = false;
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
}
function toggle(id: string) {
const has = props.modelValue.includes(id);
if (has && !canRemove.value) return;
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
emit('update:modelValue', next);
}
function remove(id: string) {
if (props.disabled || !canRemove.value) return;
const next = props.modelValue.filter((x) => x !== id);
emit('update:modelValue', next);
if (!open.value) emit('change', next);
}
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeList();
}
onMounted(() => document.addEventListener('mousedown', onDocClick));
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
</script>
<template>
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
<div class="chips">
<span v-for="u in selected" :key="u.id" class="chip">
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
<span class="chip-name">{{ u.fullName }}</span>
<button
v-if="canRemove && !disabled"
type="button"
class="chip-x"
:aria-label="`حذف ${u.fullName}`"
@click.stop="remove(u.id)"
>
<UiIcon name="x" :size="12" />
</button>
</span>
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
<button
type="button"
class="add"
:disabled="disabled"
:aria-expanded="open"
@click.stop="open ? closeList() : openList()"
>
<UiIcon name="user-plus" :size="14" />
{{ addLabel }}
</button>
</div>
<div v-if="open" class="pop" @click.stop>
<label class="search">
<UiIcon name="search" :size="14" />
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
</label>
<ul class="list">
<li v-for="u in filtered" :key="u.id">
<button
type="button"
class="row"
:class="{ on: modelValue.includes(u.id) }"
@click="toggle(u.id)"
>
<span class="check" aria-hidden="true">
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
<span class="row-name">{{ u.fullName }}</span>
</button>
</li>
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
</ul>
<div class="foot">
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
<button type="button" class="done" @click="closeList">تأیید</button>
</div>
</div>
</div>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
</template>
<style scoped>
.ums {
position: relative;
width: 100%;
min-width: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.25rem;
padding-inline-end: 0.5rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #ccfbf1;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
max-width: 100%;
}
.chip-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-x {
width: 20px;
height: 20px;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.chip-x:hover {
background: #fee2e2;
color: #b91c1c;
}
.empty {
color: #94a3b8;
font-size: 0.82rem;
font-weight: 500;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 30px;
padding: 0 0.7rem;
border: 1px dashed #5eead4;
border-radius: 999px;
background: #fff;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.add:hover:not(:disabled) {
background: #f0fdfa;
}
.add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pop {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 30;
width: min(18rem, calc(100vw - 2rem));
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
border: 1px solid #f1f5f9;
display: grid;
overflow: hidden;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.5rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
background: transparent;
color: #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0 0.35rem 0.35rem;
max-height: 15rem;
overflow-y: auto;
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.42rem 0.5rem;
border: 0;
border-radius: 10px;
background: none;
font: inherit;
font-size: 0.86rem;
font-weight: 500;
color: #0f172a;
text-align: start;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.on {
background: #f0fdfa;
font-weight: 700;
}
.check {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 5px;
border: 1.5px solid #cbd5e1;
display: grid;
place-items: center;
color: #fff;
}
.row.on .check {
background: #14b8a6;
border-color: #14b8a6;
}
.row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.none {
padding: 0.75rem;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-top: 1px solid #f1f5f9;
font-size: 0.78rem;
color: #64748b;
}
.done {
border: 0;
border-radius: 8px;
background: #14b8a6;
color: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
padding: 0.35rem 0.9rem;
cursor: pointer;
}
</style>
+17 -93
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const { Text: ATypographyText } = Typography;
type SpeechResult = {
isFinal: boolean;
@@ -186,102 +189,23 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="vtt-wrap" :class="[variant, { live: recording }]">
<button
class="vtt"
type="button"
:class="{ on: recording }"
<div>
<a-button
:type="recording ? 'primary' : 'default'"
:danger="recording"
:disabled="disabled"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<span class="glyph" aria-hidden="true">
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
</span>
<span v-if="recording" class="timer">{{ timerLabel }}</span>
</button>
<p v-if="error && !hideError" class="err">{{ error }}</p>
<template #icon>
<PauseCircleOutlined v-if="recording" />
<AudioOutlined v-else />
</template>
<span v-if="recording">{{ timerLabel }}</span>
</a-button>
<div v-if="error && !hideError">
<a-typography-text type="danger">{{ error }}</a-typography-text>
</div>
</div>
</template>
<style scoped>
.vtt-wrap {
display: grid;
gap: 0.3rem;
justify-items: start;
}
.vtt {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #d7e3dc;
background: #f4faf7;
color: #0f766e;
border-radius: var(--radius);
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.75rem;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.vtt:hover:not(:disabled) {
background: #ecfdf5;
}
.vtt.on {
border-color: #5eead4;
background: #042f2e;
color: #ecfdf5;
padding-inline: 0.7rem 0.85rem;
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
}
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.glyph {
width: 22px;
height: 22px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.timer {
font-variant-numeric: tabular-nums;
min-width: 2.4rem;
}
.toolbar .vtt {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
border-radius: var(--radius);
border-color: transparent;
background: #f8fafc;
justify-content: center;
}
.toolbar.live .vtt {
width: auto;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.7rem;
border-radius: var(--radius);
background: #042f2e;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import UiButton from '@/components/ui/UiButton.vue';
import UiIcon from '@/components/UiIcon.vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -18,15 +18,14 @@ const emit = defineEmits<{
</script>
<template>
<UiButton
variant="dashed"
<a-button
type="dashed"
block
class="add-deal"
:style="{ color: PIPELINE_ACCENT[accent].text }"
:aria-label="label"
@click="emit('add')"
>
<UiIcon name="plus" :size="16" />
<template #icon><PlusOutlined /></template>
{{ label }}
</UiButton>
</a-button>
</template>
+147 -291
View File
@@ -1,6 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Flex as AFlex,
Space as ASpace,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import {
@@ -30,6 +45,9 @@ const emit = defineEmits<{
bulkDelete: [];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null,
);
@@ -47,306 +65,144 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
const showTrash = computed(() => props.selectMode && props.selected);
const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.45 : 1,
margin: 0,
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
boxShadow: token.value.boxShadowTertiary,
}));
</script>
<template>
<article
class="deal-card"
:class="{
dragging,
'no-amount': !showAmount,
'select-mode': selectMode,
selected,
}"
<a-card
hoverable
:bordered="true"
:body-style="{ padding: '12px 14px', margin: 0 }"
:style="cardStyle"
:data-card-id="deal.id"
:draggable="!selectMode"
role="listitem"
:aria-grabbed="dragging ? 'true' : 'false'"
:style="{
'--badge-bg': colors.soft,
'--badge-fg': colors.text,
}"
@dragstart="!selectMode && emit('dragStart', deal, $event)"
@dragend="emit('dragEnd')"
@dblclick="!selectMode && emit('open', deal.id)"
@click="selectMode && emit('toggleSelect', deal.id)"
>
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selected }"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selected"
@click.stop="emit('toggleSelect', deal.id)"
>
<UiIcon v-if="selected" name="check" :size="14" />
</button>
<span v-else class="badge" aria-hidden="true">{{ deal.initials }}</span>
<div class="titles">
<h3 @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)">
{{ deal.title }}
</h3>
<p v-if="deal.description" class="desc">{{ deal.description }}</p>
</div>
<button
v-if="showTrash"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="16" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menu', deal.id)"
>
<UiIcon name="dots-v" :size="16" />
</button>
<img
class="avatar"
:src="avatarSrc"
alt=""
width="32"
height="32"
:title="assigneeTitle"
/>
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle">
+{{ extraAssignees }}
</span>
<span class="date">
{{ deal.date }}
<UiIcon name="calendar" :size="13" />
</span>
<div v-if="showAmount" class="amount">
<strong>{{ money.number }}</strong>
<span>{{ money.unit }}</span>
</div>
</article>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="flex-start" :style="{ minWidth: 0 }">
<a-button
v-if="selectMode"
type="text"
shape="circle"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selected"
:style="{
width: '28px',
height: '28px',
minWidth: '28px',
color: selected ? token.colorTextLightSolid : token.colorTextTertiary,
border: `2px solid ${selected ? token.colorPrimary : token.colorBorder}`,
background: selected ? token.colorPrimary : token.colorBgContainer,
}"
@click.stop="emit('toggleSelect', deal.id)"
>
<template #icon>
<CheckOutlined v-if="selected" />
</template>
</a-button>
<a-avatar
v-else
:style="{
background: colors.soft,
color: colors.text,
flexShrink: 0,
fontWeight: 700,
fontSize: `${token.fontSizeSM}px`,
}"
>
{{ deal.initials }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35, cursor: 'pointer' }"
:title="deal.title"
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
>
{{ deal.title }}
</a-typography-text>
<a-typography-text
v-if="deal.description"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
}"
:title="deal.description"
>
{{ deal.description }}
</a-typography-text>
</div>
<a-button
v-if="showTrash"
type="text"
danger
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button
v-else
type="text"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menu', deal.id)"
>
<template #icon><MoreOutlined /></template>
</a-button>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{
minWidth: 0,
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<a-space :size="8" align="center" :style="{ minWidth: 0 }">
<a-avatar :src="avatarSrc" :size="24" :title="assigneeTitle" />
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
+{{ extraAssignees }}
</a-tag>
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
{{ deal.date }}
</a-typography-text>
</a-space>
<a-typography-text
v-if="showAmount"
strong
dir="ltr"
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
>
{{ money.number }}
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
{{ money.unit }}
</a-typography-text>
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
</template>
<style scoped>
.deal-card {
--face: 32px;
display: grid;
grid-template-columns: var(--face) minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: 0.5rem;
row-gap: 0.65rem;
align-items: center;
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: var(--radius);
padding: 0.85rem 0.7rem 0.85rem 0.55rem;
min-width: 0;
overflow: hidden;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
cursor: grab;
transition:
box-shadow 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease,
opacity 0.18s ease,
background 0.18s ease;
text-align: right;
direction: rtl;
}
.deal-card.select-mode {
cursor: pointer;
}
.deal-card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.deal-card:hover {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.07);
}
.deal-card:active {
cursor: grabbing;
}
.deal-card.select-mode:active {
cursor: pointer;
}
.deal-card.dragging {
opacity: 0.95;
transform: scale(1.02);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
}
.deal-card.no-amount .date {
grid-column: 2 / -1;
}
.deal-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.badge,
.avatar,
.tick {
width: var(--face);
height: var(--face);
max-width: var(--face);
border-radius: 999px;
justify-self: center;
}
.badge {
display: grid;
place-items: center;
background: var(--badge-bg);
color: var(--badge-fg);
font-size: 0.65rem;
font-weight: 600;
line-height: 1;
}
.tick {
display: grid;
place-items: center;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
padding: 0;
cursor: pointer;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.avatar {
object-fit: cover;
border: 1px solid var(--line);
}
.more-assignees {
width: auto;
min-width: 1.15rem;
height: 1.15rem;
padding: 0 0.22rem;
margin-inline-start: -1.55rem;
margin-top: 1.1rem;
border-radius: 999px;
background: #0f766e;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: grid;
place-items: center;
z-index: 1;
justify-self: end;
align-self: start;
}
.titles {
min-width: 0;
text-align: right;
}
.titles h3 {
margin: 0;
font-size: 0.82rem;
font-weight: 600;
line-height: 1.4;
color: var(--ink);
cursor: pointer;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow-wrap: anywhere;
}
.desc {
margin: 0.1rem 0 0;
color: var(--muted);
font-size: 0.7rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu {
align-self: start;
justify-self: end;
border: 0;
background: none;
color: var(--muted);
width: auto;
height: auto;
padding: 0;
margin: 0;
line-height: 0;
cursor: pointer;
}
.menu:hover,
.menu:focus-visible {
color: var(--ink);
background: none;
outline: none;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
}
.date {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.25rem;
min-width: 0;
width: 100%;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount {
text-align: left;
display: grid;
justify-items: end;
gap: 0.02rem;
min-width: 0;
}
.amount strong {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
letter-spacing: -0.01em;
max-width: 100%;
overflow-wrap: anywhere;
}
.amount span {
font-size: 0.65rem;
color: var(--muted);
font-weight: 600;
}
</style>
+109 -180
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import {
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
} from 'ant-design-vue';
import PipelineColumn from './PipelineColumn.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY } from '@/lib/pipeline-board';
@@ -39,13 +46,40 @@ const emit = defineEmits<{
const adding = ref(false);
const newName = ref('');
const addInput = ref<HTMLInputElement | null>(null);
const addInput = ref<{ focus: () => void } | null>(null);
const shellStyle = {
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: 0,
minWidth: 0,
position: 'relative' as const,
overflow: 'hidden' as const,
};
const trackStyle = {
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch' as const,
gap: '16px',
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
};
async function startAdd() {
adding.value = true;
newName.value = '';
await nextTick();
addInput.value?.focus();
addInput.value?.focus?.();
}
function cancelAdd() {
@@ -63,193 +97,88 @@ function submitAdd() {
</script>
<template>
<div class="board-shell" dir="rtl">
<div class="board">
<PipelineColumn
v-for="(stage, index) in stages"
:key="stage.id"
:stage="stage"
:dragging-id="draggingId"
:drop-active="dropStageId === stage.id"
:can-manage="canManage"
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@drag-over="(id, e) => emit('dragOver', id, e)"
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<div class="pipeline-board-shell" dir="rtl" :style="shellStyle">
<div class="pipeline-board-track" :style="trackStyle">
<PipelineColumn
v-for="(stage, index) in stages"
:key="stage.id"
:stage="stage"
:dragging-id="draggingId"
:drop-active="dropStageId === stage.id"
:can-manage="canManage"
:copy="copy"
:select-mode="selectMode"
:selected-ids="selectedIds"
:is-first="index === 0"
:is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')"
@drag-over="(id, e) => emit('dragOver', id, e)"
@drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)"
/>
<section v-if="canManage" class="add-col">
<form v-if="adding" class="add-form" @submit.prevent="submitAdd">
<input
ref="addInput"
v-model="newName"
maxlength="80"
placeholder="نام ستون جدید…"
aria-label="نام ستون جدید"
@keydown.esc.prevent="cancelAdd"
/>
<div class="add-actions">
<button class="ok" type="submit" :disabled="newName.trim().length < 1">
افزودن
</button>
<button class="cancel" type="button" @click="cancelAdd">انصراف</button>
</div>
</form>
<button v-else type="button" class="add-btn" @click="startAdd">
<UiIcon name="plus" :size="18" />
افزودن ستون
</button>
</section>
<div
v-if="canManage"
:style="{
flex: '0 0 280px',
width: '280px',
alignSelf: 'stretch',
display: 'flex',
alignItems: 'flex-start',
}"
>
<a-card v-if="adding" :style="{ width: '100%' }" :body-style="{ padding: '12px' }">
<a-flex vertical :gap="12">
<a-input
ref="addInput"
v-model:value="newName"
:maxlength="80"
placeholder="نام ستون جدید…"
aria-label="نام ستون جدید"
@pressEnter="submitAdd"
@keydown.esc.prevent="cancelAdd"
/>
<a-space>
<a-button
type="primary"
:disabled="newName.trim().length < 1"
@click="submitAdd"
>
افزودن
</a-button>
<a-button @click="cancelAdd">انصراف</a-button>
</a-space>
</a-flex>
</a-card>
<a-button v-else type="dashed" block :style="{ minHeight: '52px' }" @click="startAdd">
<template #icon><PlusOutlined /></template>
افزودن ستون
</a-button>
</div>
</div>
</div>
</template>
<style scoped>
.board-shell {
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--pad-modal);
min-width: 0;
}
.board {
display: flex;
flex-direction: row;
gap: 16px;
width: 100%;
min-width: 0;
max-width: 100%;
align-items: stretch;
overflow-x: auto;
overflow-y: hidden;
padding: 0.1rem 0.05rem 0.5rem;
touch-action: pan-x pan-y;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
.pipeline-board-track {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
}
.board::-webkit-scrollbar {
.pipeline-board-track::-webkit-scrollbar {
height: 8px;
}
.board::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 38%, transparent);
.pipeline-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
}
.add-col {
flex: 0 0 17.5rem;
min-width: 17.5rem;
min-height: 420px;
display: flex;
align-items: flex-start;
padding-top: 0.15rem;
}
.add-btn {
width: 100%;
min-height: 3.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1.5px dashed var(--line);
border-radius: var(--radius);
background: #f8fafc;
color: var(--muted);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.add-btn:hover {
border-color: var(--accent);
color: var(--accent-deep, #0f766e);
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.add-form {
width: 100%;
display: grid;
gap: 0.55rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.add-form input {
width: 100%;
min-height: var(--control-h);
padding: 0 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
font: inherit;
}
.add-actions {
display: flex;
gap: 0.4rem;
}
.add-actions .ok,
.add-actions .cancel {
min-height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.add-actions .ok {
border: 0;
background: var(--accent);
color: #fff;
}
.add-actions .ok:disabled {
opacity: 0.5;
cursor: default;
}
.add-actions .cancel {
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
@container pipeline (max-width: 720px) {
.board {
scroll-snap-type: x mandatory;
scroll-padding-inline: 0.25rem;
}
.add-col {
flex: 0 0 min(20rem, 86cqi);
width: min(20rem, 86cqi);
min-width: min(20rem, 86cqi);
min-height: 360px;
scroll-snap-align: start;
}
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>
@@ -1,6 +1,19 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { computed, nextTick, ref } from 'vue';
import {
HolderOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons-vue';
import {
Button as AButton,
Dropdown as ADropdown,
Input as AInput,
Menu as AMenu,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -39,11 +52,63 @@ const emit = defineEmits<{
loadMore: [stageId: string];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const renaming = ref(false);
const draft = ref('');
const titleInput = ref<HTMLInputElement | null>(null);
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
const menuOpen = ref(false);
const menuRoot = ref<HTMLElement | null>(null);
const accent = computed(() => PIPELINE_ACCENT[props.stage.color]);
const columnStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
flex: '0 0 280px',
width: '280px',
alignSelf: 'stretch' as const,
minHeight: 0,
padding: 0,
borderRadius: `${token.value.borderRadiusLG}px`,
border: `1px solid ${
props.dropActive ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
background: props.dropActive
? token.value.colorPrimaryBg
: token.value.colorFillAlter,
boxShadow: props.dropActive
? `0 0 0 2px ${token.value.colorPrimaryBg}`
: undefined,
overflow: 'hidden' as const,
}));
const headStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '12px',
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
minWidth: 0,
}));
const bodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
function onCardDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
@@ -62,7 +127,11 @@ function startRename() {
draft.value = props.stage.title;
renaming.value = true;
menuOpen.value = false;
void nextTick(() => titleInput.value?.select());
void nextTick(() => {
const el = titleInput.value as unknown as HTMLInputElement | null;
el?.focus?.();
el?.select?.();
});
}
function commitRename() {
@@ -78,144 +147,133 @@ function cancelRename() {
draft.value = props.stage.title;
}
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function onColumnDragStart(event: DragEvent) {
event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`);
event.dataTransfer!.effectAllowed = 'move';
emit('columnDragStart', props.stage.id, event);
}
function onDocPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
const root = menuRoot.value;
if (root && target && !root.contains(target)) menuOpen.value = false;
const menuItems = computed(() => [
{ key: 'rename', label: 'تغییر نام' },
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
{ key: 'delete', label: 'حذف ستون', danger: true },
]);
function onMenuClick({ key }: { key: string | number }) {
menuOpen.value = false;
if (key === 'rename') startRename();
else if (key === 'right') emit('move', props.stage.id, -1);
else if (key === 'left') emit('move', props.stage.id, 1);
else if (key === 'delete') emit('delete', props.stage.id);
}
function onMenuOpened() {
document.addEventListener('pointerdown', onDocPointerDown);
}
function onMenuClosed() {
document.removeEventListener('pointerdown', onDocPointerDown);
}
watch(menuOpen, (open) => {
if (open) onMenuOpened();
else onMenuClosed();
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown);
});
</script>
<template>
<section
class="column"
:class="{ drop: dropActive }"
:style="{
'--stage-solid': PIPELINE_ACCENT[stage.color].solid,
'--stage-soft': PIPELINE_ACCENT[stage.color].soft,
'--stage-border': PIPELINE_ACCENT[stage.color].border,
'--stage-text': PIPELINE_ACCENT[stage.color].text,
}"
:style="columnStyle"
@dragover.prevent="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)"
>
<header class="column-head">
<div :style="headStyle">
<span
v-if="canManage"
class="grip"
draggable="true"
title="کشیدن برای جابه‌جایی"
aria-label="جابه‌جایی ستون"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@dragstart="onColumnDragStart"
@dragend="emit('columnDragEnd')"
>
<UiIcon name="grip" :size="14" />
<HolderOutlined />
</span>
<div class="title-wrap">
<span class="dot" aria-hidden="true" />
<input
v-if="renaming"
ref="titleInput"
v-model="draft"
class="title-input"
maxlength="80"
aria-label="نام ستون"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<h2
v-else
:class="{ editable: canManage }"
:title="canManage ? 'برای تغییر نام کلیک کنید' : undefined"
@click="startRename"
>
{{ stage.title }}
</h2>
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
</div>
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span>
<div v-if="canManage" ref="menuRoot" class="more-wrap">
<button
type="button"
class="icon-btn"
aria-label="گزینه‌های ستون"
:aria-expanded="menuOpen"
@click.stop="toggleMenu"
>
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuOpen" class="col-menu" role="menu">
<button type="button" role="menuitem" @click="startRename">
تغییر نام
</button>
<button
type="button"
role="menuitem"
:disabled="isFirst"
@click="emit('move', stage.id, -1); menuOpen = false"
>
انتقال به راست
</button>
<button
type="button"
role="menuitem"
:disabled="isLast"
@click="emit('move', stage.id, 1); menuOpen = false"
>
انتقال به چپ
</button>
<button
type="button"
role="menuitem"
class="danger"
@click="emit('delete', stage.id); menuOpen = false"
>
حذف ستون
</button>
</div>
</div>
<button
type="button"
class="plus"
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: accent.solid,
display: 'inline-block',
flexShrink: 0,
}"
aria-hidden="true"
/>
<a-input
v-if="renaming"
ref="titleInput"
v-model:value="draft"
:maxlength="80"
aria-label="نام ستون"
:style="{ flex: 1, minWidth: 0 }"
@pressEnter="commitRename"
@keydown.esc.prevent="cancelRename"
@blur="commitRename"
/>
<a-typography-text
v-else
strong
ellipsis
:style="{
flex: 1,
minWidth: 0,
margin: 0,
cursor: canManage ? 'text' : 'default',
}"
:title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
@click="startRename"
>
{{ stage.title }}
</a-typography-text>
<a-tag v-if="stage.badge && !renaming" :bordered="false">
{{ stage.badge }}
</a-tag>
<a-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
</a-typography-text>
<a-dropdown
v-if="canManage"
v-model:open="menuOpen"
:trigger="['click']"
placement="bottomLeft"
>
<a-button type="text" aria-label="گزینه‌های ستون" @click.stop>
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu :items="menuItems" @click="onMenuClick" />
</template>
</a-dropdown>
<a-button
type="text"
:aria-label="copy.addItemAria"
:style="{ color: accent.solid }"
@click="emit('addDeal', stage.id)"
>
<UiIcon name="plus" :size="16" />
</button>
</header>
<template #icon><PlusOutlined /></template>
</a-button>
</div>
<div class="cards" role="list" :aria-label="stage.title">
<div role="list" :aria-label="stage.title" :style="bodyStyle">
<div
v-for="(deal, index) in stage.deals"
:key="deal.id"
class="card-slot"
@dragover="onCardDragOver(stage.id, $event)"
@drop="onCardDrop(stage.id, index, $event)"
>
@@ -234,256 +292,26 @@ onBeforeUnmount(() => {
@bulk-delete="emit('bulkDelete')"
/>
</div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
<button
<a-typography-text
v-if="!stage.deals.length"
type="secondary"
:style="{ textAlign: 'center', display: 'block', margin: 'auto', padding: '16px' }"
>
{{ copy.empty }}
</a-typography-text>
<a-button
v-if="stage.hasMore"
type="button"
class="load-more"
:disabled="stage.loadingMore"
type="dashed"
block
:loading="stage.loadingMore"
@click="emit('loadMore', stage.id)"
>
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</button>
</a-button>
</div>
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
<div :style="{ padding: '0 12px 12px', flexShrink: 0 }">
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
</div>
</section>
</template>
<style scoped>
.column {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1 1 17.5rem;
min-width: 17.5rem;
min-height: 420px;
padding: 0.75rem;
border-radius: var(--radius);
border: 1px solid var(--line);
background: #fff;
transition: background 0.15s ease, border-color 0.15s ease;
}
.column.drop {
background: color-mix(in srgb, var(--stage-soft) 70%, transparent);
border-color: color-mix(in srgb, var(--stage-border) 80%, transparent);
}
.column-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.1rem 0.1rem 0.15rem;
}
.grip {
display: grid;
place-items: center;
width: 1.25rem;
color: var(--muted);
cursor: grab;
flex: 0 0 auto;
touch-action: none;
}
.grip:active {
cursor: grabbing;
}
.title-wrap {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
min-width: 0;
flex: 1;
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
background: var(--stage-solid);
flex: 0 0 auto;
}
.column-head h2,
.title-input {
margin: 0;
min-width: 0;
width: 100%;
font-size: 0.98rem;
font-weight: 600;
color: var(--ink);
font: inherit;
font-weight: 600;
}
.column-head h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.column-head h2.editable {
cursor: text;
}
.title-input {
height: 1.7rem;
padding: 0 0.4rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.count {
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
.more-wrap {
position: relative;
flex: 0 0 auto;
}
.icon-btn,
.plus {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.icon-btn {
border: 1px solid transparent;
color: var(--muted);
}
.icon-btn:hover,
.icon-btn:focus-visible {
background: var(--stage-soft);
color: var(--stage-solid);
outline: none;
}
.plus {
border: 1.5px solid var(--stage-solid);
color: var(--stage-solid);
}
.plus:hover,
.plus:focus-visible {
background: var(--stage-soft);
outline: none;
}
.col-menu {
position: absolute;
top: calc(100% + 4px);
inset-inline-end: 0;
z-index: 8;
min-width: 10.5rem;
padding: 0.3rem;
display: grid;
gap: 0.1rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.col-menu button {
border: 0;
background: transparent;
padding: 0.45rem 0.7rem;
font: inherit;
font-size: 0.86rem;
color: var(--ink);
text-align: right;
cursor: pointer;
border-radius: var(--radius);
}
.col-menu button:hover:not(:disabled) {
background: #f8fafc;
}
.col-menu button:disabled {
opacity: 0.4;
cursor: default;
}
.col-menu .danger {
color: var(--danger, #dc2626);
}
.cards {
display: flex;
flex-direction: column;
gap: 0.7rem;
flex: 1;
min-width: 0;
min-height: 4rem;
}
.card-slot {
min-width: 0;
}
.empty {
margin: 1rem 0;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
.stage-badge {
flex: 0 0 auto;
padding: 0.05rem 0.4rem;
border-radius: 999px;
background: var(--stage-soft);
color: var(--stage-text);
font-size: 0.66rem;
font-weight: 700;
white-space: nowrap;
}
.load-more {
min-height: var(--control-h);
border: 1px dashed var(--stage-border);
border-radius: var(--radius);
background: #fff;
color: var(--stage-text);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.load-more:hover:not(:disabled) {
background: var(--stage-soft);
}
.load-more:disabled {
opacity: 0.6;
cursor: default;
}
@container pipeline (max-width: 720px) {
.column {
flex: 0 0 min(20rem, 86cqi);
width: min(20rem, 86cqi);
min-width: min(20rem, 86cqi);
max-width: none;
min-height: 360px;
scroll-snap-align: start;
}
}
</style>
+260 -430
View File
@@ -1,8 +1,31 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
CalendarOutlined,
CheckOutlined,
DeleteOutlined,
MoreOutlined,
} from '@ant-design/icons-vue';
import {
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Table as ATable,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import UiIcon from '@/components/UiIcon.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import type {
PipelineAccent,
PipelineDeal,
PipelineStage,
PipelineUiCopy,
} from '@/lib/pipeline-board';
import {
DEAL_BOARD_COPY,
formatCurrencyParts,
@@ -26,14 +49,28 @@ const emit = defineEmits<{
bulkDelete: [];
}>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const listRoot = ref<HTMLElement | null>(null);
/** فقط یکی از جدول/کارت در DOM باشد تا منوی انتخاب گم نشود */
const useCards = ref(false);
let resizeObs: ResizeObserver | null = null;
const rows = computed(() =>
type Row = {
key: string;
deal: PipelineDeal;
stageTitle: string;
accent: PipelineAccent;
};
function accentColors(accent: PipelineAccent) {
return PIPELINE_ACCENT[accent];
}
const rows = computed<Row[]>(() =>
props.stages.flatMap((stage) =>
stage.deals.map((deal) => ({
key: deal.id,
deal,
stageTitle: stage.title,
accent: stage.color,
@@ -48,6 +85,33 @@ function money(deal: PipelineDeal) {
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
const columns = computed<TableColumnsType<Row>>(() => {
const cols: TableColumnsType<Row> = [];
if (props.selectMode) {
cols.push({ key: 'select', title: '', width: 56 });
}
cols.push(
{ key: 'title', title: props.copy.listColTitle },
{ key: 'stage', title: 'مرحله' },
);
if (!hideAmount.value) {
cols.push({ key: 'amount', title: 'مبلغ' });
}
cols.push(
{ key: 'date', title: 'تاریخ' },
{ key: 'actions', title: 'عملیات', width: 72, align: 'center' },
);
return cols;
});
function customRow(record: Row) {
return {
onClick: () => {
if (props.selectMode) emit('toggleSelect', record.deal.id);
},
};
}
onMounted(() => {
const el = listRoot.value;
if (!el || typeof ResizeObserver === 'undefined') return;
@@ -66,445 +130,211 @@ onUnmounted(() => {
</script>
<template>
<div ref="listRoot" class="list" dir="rtl">
<p v-if="!rows.length" class="empty">{{ copy.empty }}</p>
<div ref="listRoot" dir="rtl" :style="{ minWidth: 0 }">
<a-empty v-if="!rows.length" :description="copy.empty" />
<div v-if="rows.length && !useCards" class="tc-row">
<TableColumnsMenu :table-key="tableKey" align="end" />
</div>
<div v-if="rows.length && !useCards" class="table-wrap">
<table v-table-cols="tableKey" class="data-table">
<thead>
<tr>
<th v-if="selectMode" class="tick-col"></th>
<th>{{ copy.listColTitle }}</th>
<th>مرحله</th>
<th v-if="!hideAmount">مبلغ</th>
<th>تاریخ</th>
<th class="menu-col">عملیات</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in rows"
:key="row.deal.id"
:data-card-id="row.deal.id"
:class="{ selected: selectedIds.has(row.deal.id) }"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<td v-if="selectMode" class="tick-col" @click.stop>
<button
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
:aria-pressed="selectedIds.has(row.deal.id)"
@click="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
</td>
<td>
<button
type="button"
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
</span>
</button>
</td>
<td>
<span
class="stage"
<template v-else-if="!useCards">
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
<TableColumnsMenu :table-key="tableKey" align="end" />
</a-flex>
<a-table
:columns="columns"
:data-source="rows"
:pagination="false"
row-key="key"
:custom-row="customRow"
:row-class-name="(record: Row) => (selectedIds.has(record.deal.id) ? 'pipeline-row-selected' : '')"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'select'">
<a-button
type="text"
shape="circle"
:aria-label="selectedIds.has(record.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', record.deal.id)"
>
<template #icon>
<CheckOutlined v-if="selectedIds.has(record.deal.id)" />
</template>
</a-button>
</template>
<template v-else-if="column.key === 'title'">
<a-flex
:gap="10"
align="center"
:style="{ minWidth: 0, cursor: 'pointer' }"
@click.stop="
selectMode
? emit('toggleSelect', record.deal.id)
: emit('openDeal', record.deal.id)
"
>
<a-avatar
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
background: accentColors(record.accent).soft,
color: accentColors(record.accent).text,
flexShrink: 0,
}"
>
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
{{ row.stageTitle }}
</span>
</td>
<td v-if="!hideAmount">
<span class="amount" dir="ltr">
<strong>{{ money(row.deal).number }}</strong>
<small>{{ money(row.deal).unit }}</small>
</span>
</td>
<td>
<span class="date">
<UiIcon name="calendar" :size="14" />
{{ row.deal.date }}
</span>
</td>
<td class="menu-cell" @click.stop>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click="emit('menuDeal', row.deal.id)"
>
<UiIcon name="dots-v" :size="18" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
{{ record.deal.initials }}
</a-avatar>
<div :style="{ minWidth: 0 }">
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
{{ record.deal.title }}
</a-typography-text>
<a-typography-text
v-if="record.deal.description"
type="secondary"
ellipsis
:style="{ display: 'block', margin: 0, fontSize: `${token.fontSizeSM}px` }"
>
{{ record.deal.description }}
</a-typography-text>
</div>
</a-flex>
</template>
<template v-else-if="column.key === 'stage'">
<a-tag
:bordered="false"
:style="{
background: accentColors(record.accent).soft,
color: accentColors(record.accent).text,
}"
>
{{ record.stageTitle }}
</a-tag>
</template>
<template v-else-if="column.key === 'amount'">
<span dir="ltr">
<a-typography-text strong>{{ money(record.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
{{ money(record.deal).unit }}
</a-typography-text>
</span>
</template>
<template v-else-if="column.key === 'date'">
<a-space :size="6">
<CalendarOutlined />
<span>{{ record.deal.date }}</span>
</a-space>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="selectMode && selectedIds.has(record.deal.id)"
type="text"
danger
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button
v-else
type="text"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menuDeal', record.deal.id)"
>
<template #icon><MoreOutlined /></template>
</a-button>
</template>
</template>
</a-table>
</template>
<div v-if="rows.length && useCards" class="cards">
<article
<a-flex v-else vertical :gap="12">
<a-card
v-for="row in rows"
:key="row.deal.id"
class="card"
:class="{ selected: selectedIds.has(row.deal.id) }"
hoverable
:data-card-id="row.deal.id"
:body-style="{ padding: '14px 16px' }"
:style="{
background: selectedIds.has(row.deal.id)
? token.colorPrimaryBg
: token.colorBgContainer,
borderColor: selectedIds.has(row.deal.id)
? token.colorPrimary
: token.colorBorderSecondary,
}"
@dblclick="!selectMode && emit('openDeal', row.deal.id)"
@click="selectMode && emit('toggleSelect', row.deal.id)"
>
<div class="card-top">
<button
v-if="selectMode"
type="button"
class="tick"
:class="{ on: selectedIds.has(row.deal.id) }"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', row.deal.id)"
>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
</button>
<button
type="button"
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
<a-flex vertical :gap="12">
<a-flex align="center" :gap="10" :style="{ minWidth: 0 }">
<a-button
v-if="selectMode"
type="text"
shape="circle"
@click.stop="emit('toggleSelect', row.deal.id)"
>
<template #icon>
<CheckOutlined v-if="selectedIds.has(row.deal.id)" />
</template>
</a-button>
<a-avatar
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
background: accentColors(row.accent).soft,
color: accentColors(row.accent).text,
}"
>{{ row.deal.initials }}</span>
<span class="titles">
<strong>{{ row.deal.title }}</strong>
<em v-if="row.deal.description">{{ row.deal.description }}</em>
>
{{ row.deal.initials }}
</a-avatar>
<div
:style="{ flex: 1, minWidth: 0, cursor: 'pointer' }"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
{{ row.deal.title }}
</a-typography-text>
<a-typography-text
v-if="row.deal.description"
type="secondary"
ellipsis
:style="{ display: 'block', margin: 0 }"
>
{{ row.deal.description }}
</a-typography-text>
</div>
<a-button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="text"
danger
@click.stop="emit('bulkDelete')"
>
<template #icon><DeleteOutlined /></template>
</a-button>
<a-button v-else type="text" @click.stop="emit('menuDeal', row.deal.id)">
<template #icon><MoreOutlined /></template>
</a-button>
</a-flex>
<a-tag
:bordered="false"
:style="{
alignSelf: 'flex-start',
background: accentColors(row.accent).soft,
color: accentColors(row.accent).text,
}"
>
{{ row.stageTitle }}
</a-tag>
<a-flex justify="space-between" align="center">
<a-space :size="6">
<CalendarOutlined />
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
</a-space>
<span v-if="!hideAmount" dir="ltr">
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
{{ money(row.deal).unit }}
</a-typography-text>
</span>
</button>
<button
v-if="selectMode && selectedIds.has(row.deal.id)"
type="button"
class="menu trash"
aria-label="حذف انتخاب‌شده‌ها"
@click.stop="emit('bulkDelete')"
>
<UiIcon name="trash" :size="18" />
</button>
<button
v-else
type="button"
class="menu"
:aria-label="copy.itemMenuAria"
@click.stop="emit('menuDeal', row.deal.id)"
>
<UiIcon name="dots-v" :size="18" />
</button>
</div>
<span
class="stage"
:style="{
background: PIPELINE_ACCENT[row.accent].soft,
color: PIPELINE_ACCENT[row.accent].text,
}"
>
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
{{ row.stageTitle }}
</span>
<div class="card-foot">
<span class="date">
<UiIcon name="calendar" :size="14" />
{{ row.deal.date }}
</span>
<span v-if="!hideAmount" class="amount" dir="ltr">
<strong>{{ money(row.deal).number }}</strong>
<small>{{ money(row.deal).unit }}</small>
</span>
</div>
</article>
</div>
</a-flex>
</a-flex>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.list {
min-width: 0;
}
.empty {
margin: 2rem 0;
text-align: center;
color: var(--muted);
font-weight: 700;
}
.table-wrap {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
.data-table th,
.data-table td {
padding: 0.85rem 1rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.data-table th {
font-size: 0.78rem;
color: #94a3b8;
font-weight: 700;
background: #f8fafc;
}
.data-table tbody tr:last-child td {
border-bottom: 0;
}
.data-table tbody tr:hover td {
background: #f8fafc;
}
.data-table tbody tr.selected td {
background: color-mix(in srgb, var(--accent) 7%, #fff);
}
.tick-col {
width: 2.6rem;
padding-inline: 0.55rem !important;
}
.tick {
width: 1.45rem;
height: 1.45rem;
border-radius: 6px;
border: 2px solid #cbd5e1;
background: #fff;
color: #fff;
display: grid;
place-items: center;
padding: 0;
cursor: pointer;
flex: 0 0 auto;
}
.tick.on {
border-color: var(--accent);
background: var(--accent);
}
.identity {
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
text-align: right;
font: inherit;
color: inherit;
}
.badge {
flex: 0 0 auto;
width: 2.2rem;
height: 2.2rem;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 0.7rem;
font-weight: 600;
}
.titles {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.titles strong {
font-size: 0.92rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.titles em {
font-style: normal;
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
white-space: nowrap;
}
.stage i {
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
display: block;
}
.amount {
display: grid;
justify-items: start;
gap: 0.05rem;
}
.amount strong {
font-weight: 600;
color: var(--ink);
}
.amount small {
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
}
.date {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted);
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.menu-col,
.menu-cell {
width: 3.2rem;
min-width: 3.2rem;
text-align: center;
}
.menu {
border: 0;
background: transparent;
color: var(--muted);
width: 2rem;
height: 2rem;
padding: 0;
border-radius: var(--radius);
display: inline-grid;
place-items: center;
cursor: pointer;
}
.menu:hover,
.menu:focus-visible {
color: var(--ink);
background: #f1f5f9;
}
.menu.trash {
color: var(--danger);
}
.menu.trash:hover,
.menu.trash:focus-visible {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, #fff);
}
.cards {
display: grid;
gap: 0.7rem;
}
.card {
background: #fff;
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
border-radius: var(--radius);
padding: 0.95rem 1rem;
display: grid;
gap: 0.7rem;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.card.selected {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
background: color-mix(in srgb, var(--accent) 6%, #fff);
}
.card-top {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.card-top .identity {
flex: 1;
}
.card-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
}
</style>
@@ -1,12 +1,23 @@
<script setup lang="ts">
import UiFilterBar from '@/components/ui/UiFilterBar.vue';
import UiIcon from '@/components/UiIcon.vue';
import UiSegmented from '@/components/ui/UiSegmented.vue';
import UiSelect from '@/components/ui/UiSelect.vue';
import { computed } from 'vue';
import {
AppstoreOutlined,
FundOutlined,
} from '@ant-design/icons-vue';
import {
Flex as AFlex,
InputNumber as AInputNumber,
InputSearch as AInputSearch,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Typography,
theme,
} from 'ant-design-vue';
export type PipelineViewMode = 'kanban' | 'list';
withDefaults(
const props = withDefaults(
defineProps<{
pipelineName: string;
pipelineOptions: Array<{ id: string; name: string }>;
@@ -27,6 +38,9 @@ const emit = defineEmits<{
create: [];
}>();
const { Title: ATypographyTitle } = Typography;
const { token } = theme.useToken();
const viewMode = defineModel<PipelineViewMode>('viewMode', { required: true });
const search = defineModel<string>('search', { default: '' });
const selectedPipelineId = defineModel<string>('selectedPipelineId', { required: true });
@@ -34,165 +48,112 @@ const amountMin = defineModel<string>('amountMin', { default: '' });
const amountMax = defineModel<string>('amountMax', { default: '' });
const viewOptions = [
{ value: 'kanban' as const, label: 'نمای ستونی', icon: 'grid' as const },
{ value: 'list' as const, label: 'نمای لیست', icon: 'list' as const },
{ value: 'kanban' as const, label: 'ستونی' },
{ value: 'list' as const, label: 'لیست' },
];
function onPick(value: string) {
if (value === '__create__') {
const pipelineSelectOptions = computed(() => {
const list = props.pipelineOptions.map((o) => ({ value: o.id, label: o.name }));
if (props.canCreate) {
list.push({ value: '__create__', label: props.createOptionLabel });
}
return list;
});
function onPick(value: unknown) {
const v = String(value ?? '');
if (v === '__create__') {
emit('create');
return;
}
selectedPipelineId.value = value;
selectedPipelineId.value = v;
}
const amountMinNum = computed({
get: () => (amountMin.value === '' ? undefined : Number(amountMin.value)),
set: (v: number | string | null | undefined) => {
if (v == null || v === '' || Number.isNaN(Number(v))) amountMin.value = '';
else amountMin.value = String(v);
},
});
const amountMaxNum = computed({
get: () => (amountMax.value === '' ? undefined : Number(amountMax.value)),
set: (v: number | string | null | undefined) => {
if (v == null || v === '' || Number.isNaN(Number(v))) amountMax.value = '';
else amountMax.value = String(v);
},
});
</script>
<template>
<header class="toolbar" dir="rtl">
<div class="title-block">
<span class="brand-ico" aria-hidden="true">
<UiIcon :name="icon" :size="18" />
</span>
<h1>{{ pipelineName }}</h1>
</div>
<a-flex vertical :gap="12" dir="rtl">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<a-space :size="10" align="center">
<span
:style="{
width: `${token.controlHeight}px`,
height: `${token.controlHeight}px`,
borderRadius: `${token.borderRadius}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
aria-hidden="true"
>
<AppstoreOutlined v-if="icon === 'grid'" />
<FundOutlined v-else />
</span>
<a-typography-title :level="4" :style="{ margin: 0 }">
{{ pipelineName }}
</a-typography-title>
</a-space>
<UiSegmented v-model="viewMode" :options="viewOptions">
<template #icon="{ option }">
<UiIcon :name="option.icon ?? 'grid'" :size="16" />
</template>
</UiSegmented>
<a-space wrap :size="8">
<a-segmented v-model:value="viewMode" :options="viewOptions" />
<a-select
:value="selectedPipelineId"
:options="pipelineSelectOptions"
:style="{ width: '180px' }"
@update:value="onPick"
/>
<slot name="excel" />
</a-space>
</a-flex>
<UiSelect
class="pipeline-pick"
:model-value="selectedPipelineId"
@update:model-value="onPick"
>
<option
v-for="opt in pipelineOptions"
:key="opt.id"
:value="opt.id"
>
{{ opt.name }}
</option>
<option v-if="canCreate" value="__create__">{{ createOptionLabel }}</option>
</UiSelect>
<div class="excel-actions">
<slot name="excel" />
</div>
<UiFilterBar
v-model:search="search"
class="filters"
:show-filters="showAmountFilters"
<!-- یک نوار فیلتر تک‌ردیفه؛ بدون a-row و بدون فاصلهٔ ول -->
<a-flex
wrap="wrap"
align="center"
:gap="8"
:style="{ width: '100%', minWidth: 0 }"
>
<a-input-search
v-model:value="search"
allow-clear
placeholder="جستجو در معاملات…"
:style="{ flex: '1 1 220px', minWidth: '180px', maxWidth: '100%' }"
/>
<template v-if="showAmountFilters">
<label class="field">
<span>حداقل مبلغ</span>
<input v-model="amountMin" type="number" min="0" placeholder="۰" />
</label>
<label class="field">
<span>حداکثر مبلغ</span>
<input v-model="amountMax" type="number" min="0" placeholder="—" />
</label>
<a-input-number
v-model:value="amountMinNum"
:min="0"
placeholder="حداقل مبلغ"
:style="{ width: '140px', flex: '0 0 auto' }"
/>
<a-input-number
v-model:value="amountMaxNum"
:min="0"
placeholder="حداکثر مبلغ"
:style="{ width: '140px', flex: '0 0 auto' }"
/>
</template>
</UiFilterBar>
</header>
<div :style="{ flex: '1 1 240px', minWidth: '200px', maxWidth: '100%' }">
<slot name="filters" />
</div>
</a-flex>
</a-flex>
</template>
<style scoped>
.toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.85rem;
min-width: 0;
width: 100%;
}
.title-block {
display: flex;
align-items: center;
gap: 0.55rem;
flex: 0 0 auto;
min-width: 0;
}
.brand-ico {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
display: grid;
place-items: center;
background: var(--accent-deep);
color: #fff;
flex: 0 0 auto;
}
h1 {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-pick {
flex: 0 1 13rem;
min-width: 8.5rem;
}
.excel-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
flex: 0 0 auto;
}
.excel-actions :deep(.btn-ghost-sm) {
border: 1px solid #e2e8f0;
background: #fff;
color: #0f766e;
border-radius: 10px;
padding: 0.45rem 0.75rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
font: inherit;
}
.filters {
flex: 1 1 12rem;
min-width: 0;
}
.field {
display: grid;
gap: 0.3rem;
}
.field span {
font-size: 0.8rem;
color: var(--muted);
font-weight: 700;
}
@container pipeline (max-width: 720px) {
.toolbar {
flex-wrap: wrap;
}
.title-block {
width: 100%;
}
.pipeline-pick,
.excel-actions,
.filters {
flex: 1 1 100%;
}
}
</style>
@@ -1,12 +1,22 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiInput, UiSelect } from '@/components/ui';
import {
Button as AButton,
Flex as AFlex,
Input as AInput,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CloseOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults(
@@ -33,6 +43,10 @@ const typeOptions = computed(() => {
return list;
});
const selectOptions = computed(() =>
typeOptions.value.map((t) => ({ value: t, label: ENTITY_TYPE_LABELS[t] })),
);
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref('');
const results = ref<PickedEntity[]>([]);
@@ -99,126 +113,55 @@ function pick(item: PickedEntity) {
</script>
<template>
<div class="ent-picker">
<div v-if="model" class="picked">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
<strong>{{ model.label }}</strong>
<button type="button" aria-label="حذف اتصال" @click="model = null">
<UiIcon name="x" :size="14" />
</button>
</div>
<a-flex vertical :gap="8" style="width: 100%">
<a-flex v-if="model" justify="space-between" align="center" :gap="8">
<a-space :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[model.type] }}</a-tag>
<a-typography-text strong>{{ model.label }}</a-typography-text>
</a-space>
<a-button type="text" aria-label="حذف اتصال" @click="model = null">
<template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<template v-else-if="typeOptions.length">
<div class="search-row">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
</UiSelect>
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
</div>
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
<li v-if="loading" class="muted">در حال جستجو…</li>
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
<li v-for="r in results" :key="r.id">
<button type="button" @click="pick(r)">{{ r.label }}</button>
</li>
</ul>
<a-flex :gap="8" wrap="wrap">
<a-select
v-if="typeOptions.length > 1"
v-model:value="type"
:options="selectOptions"
style="width: 120px"
/>
<a-input
v-model:value="q"
:placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`"
style="flex: 1; min-width: 160px"
/>
</a-flex>
<a-flex
v-if="results.length || loading || q.trim().length >= 2"
vertical
:gap="4"
>
<a-typography-text v-if="loading" type="secondary">در حال جستجو…</a-typography-text>
<a-typography-text v-else-if="!results.length" type="secondary">
موردی یافت نشد
</a-typography-text>
<a-button
v-for="r in results"
:key="r.id"
type="text"
block
style="text-align: start"
@click="pick(r)"
>
{{ r.label }}
</a-button>
</a-flex>
</template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div>
<a-typography-text v-else type="secondary">
ماژول لید/مشتری برای سازمان فعال نیست.
</a-typography-text>
</a-flex>
</template>
<style scoped>
.ent-picker {
display: grid;
gap: var(--sp-2);
}
.search-row {
display: flex;
gap: var(--sp-2);
}
.type-sel {
width: 110px;
flex-shrink: 0;
}
.search-row > :last-child {
flex: 1 1 auto;
}
.results {
list-style: none;
margin: 0;
padding: 4px;
border: 1px solid var(--line);
border-radius: var(--radius);
max-height: 220px;
overflow-y: auto;
background: var(--surface);
}
.results li button {
width: 100%;
text-align: right;
border: 0;
background: transparent;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
color: var(--ink);
}
.results li button:hover {
background: var(--accent-subtle);
color: var(--accent-deep);
}
.muted {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: var(--fs-sm);
}
.picked {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 10px;
border-radius: var(--radius);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent-deep);
min-width: 0;
}
.picked strong {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picked-type {
font-size: var(--fs-xs);
color: var(--muted);
}
.picked button {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 6px;
}
.picked button:hover {
background: var(--surface);
color: var(--danger);
}
</style>
@@ -8,9 +8,22 @@ import type {
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
import {
Alert as AAlert,
Checkbox as ACheckbox,
Col as ACol,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
@@ -22,6 +35,8 @@ import {
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
const props = withDefaults(
defineProps<{
open: boolean;
@@ -54,7 +69,23 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
const priorityOptions = computed(() =>
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
([value, label]) => ({ value, label }),
),
);
const categoryOptions = computed(() => [
{ value: '', label: 'بدون دسته' },
...(ctx.value?.categories ?? []).map((c) => ({ value: c.id, label: c.name })),
]);
const assigneeOptions = computed(() =>
assignees.value.map((u) => ({
value: u.id,
label: `${u.fullName}${u.id === auth.user?.id ? ' (خودم)' : ''}`,
})),
);
async function ensureContext() {
if (ctx.value) return;
@@ -105,7 +136,7 @@ async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
return;
return Promise.reject();
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
@@ -128,6 +159,7 @@ async function submit() {
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
return Promise.reject();
} finally {
saving.value = false;
}
@@ -135,119 +167,90 @@ async function submit() {
</script>
<template>
<UiModal :open="open" title="کار جدید" @close="emit('close')">
<form class="qt-form" @submit.prevent="submit">
<UiField label="عنوان" required>
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" />
</UiField>
<UiField label="توضیحات">
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
</UiField>
<div class="grid-2">
<UiField label="تاریخ" required>
<a-modal
:open="open"
title="کار جدید"
:confirm-loading="saving"
ok-text="ثبت کار"
cancel-text="انصراف"
destroy-on-close
@ok="submit"
@cancel="emit('close')"
>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان" required>
<a-input
v-model:value="form.title"
placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور"
:maxlength="200"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.description" :rows="2" :maxlength="2000" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" />
</UiField>
<UiField label="اولویت">
<UiSelect v-model="form.priority">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
</UiSelect>
</UiField>
<UiField label="دسته‌بندی">
<UiSelect v-model="form.categoryId">
<option value="">بدون دسته</option>
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
<UiSelect v-model="form.assigneeId">
<option v-for="u in assignees" :key="u.id" :value="u.id">
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
</option>
</UiSelect>
</UiField>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="اولویت">
<a-select
v-model:value="form.priority"
:options="priorityOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="دسته‌بندی">
<a-select
v-model:value="form.categoryId"
:options="categoryOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="assignees.length > 1" :span="24">
<a-form-item label="مسئول">
<a-select
v-model:value="form.assigneeId"
:options="assigneeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="اتصال به پرونده">
<a-space v-if="preset" :size="8">
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
<a-typography-text strong>{{ preset.label }}</a-typography-text>
</a-space>
<EntityLinkPicker v-else v-model="form.entity" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-checkbox v-model:checked="form.isMandatory">
کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)
</a-checkbox>
</a-col>
</a-row>
<UiField label="اتصال به پرونده">
<div v-if="preset" class="preset">
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
<strong>{{ preset.label }}</strong>
</div>
<EntityLinkPicker v-else v-model="form.entity" />
</UiField>
<label class="check">
<input v-model="form.isMandatory" type="checkbox" />
<span>کار اجباری (در امتیاز روزانه و Streak حساب می‌شود)</span>
</label>
<p v-if="error" class="error">{{ error }}</p>
</form>
<template #footer>
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.qt-form {
display: grid;
gap: var(--sp-4);
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.span-2 {
grid-column: 1 / -1;
}
.preset {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.preset span {
font-size: var(--fs-xs);
color: var(--muted);
}
.check {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
}
.check input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,18 +1,30 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText, Title: ATypographyTitle } = Typography;
const props = withDefaults(
defineProps<{
entity: RoutineEntityRefDto;
@@ -85,223 +97,90 @@ function onCreated(t: RoutineTaskDto) {
</script>
<template>
<section v-if="enabled" class="related">
<header>
<h3>
<span class="mark"><UiIcon name="check" :size="15" /></span>
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
<UiButton size="sm" variant="outline" @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template>
<a-card v-if="enabled" :bordered="false" :style="{ marginTop: '16px' }">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<a-space :size="8">
<CheckOutlined />
<a-typography-title :level="5" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-tag v-if="open.length" :bordered="false">{{ toFaDigits(open.length) }} باز</a-tag>
</a-space>
<a-button @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</UiButton>
</header>
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<div v-else-if="!items.length" class="empty">
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 72 72" width="72" height="72">
<rect x="18" y="16" width="36" height="44" rx="8" fill="#f4fbf8" stroke="#b7e4d4" stroke-width="1.6" />
<rect x="26" y="10" width="20" height="12" rx="4" fill="#e7f6f1" stroke="#7dcebb" stroke-width="1.6" />
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<p class="muted">{{ emptyTitle }}</p>
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p>
<button class="empty-cta" type="button" @click="quickOpen = true">
ایجاد Task
<UiIcon name="plus" :size="14" />
</button>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '12px' }"
/>
<a-alert
v-if="actions.error.value"
type="error"
show-icon
:message="actions.error.value"
:style="{ marginTop: '12px' }"
/>
<div v-else class="rows">
<RoutineTaskRow
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</button>
<template v-if="showClosed">
<a-spin :spinning="loading && !items.length">
<a-empty
v-if="!loading && !items.length"
:description="emptyTitle"
:style="{ margin: '16px 0' }"
>
<template v-if="emptyHint" #description>
<div>
<div>{{ emptyTitle }}</div>
<a-typography-text type="secondary">{{ emptyHint }}</a-typography-text>
</div>
</template>
<a-button type="primary" @click="quickOpen = true">
<template #icon><PlusOutlined /></template>
ایجاد Task
</a-button>
</a-empty>
<a-flex v-else vertical :gap="8" :style="{ marginTop: '12px' }">
<RoutineTaskRow
v-for="t in closed"
v-for="t in open"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
show-delay
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</div>
<a-button
v-if="closed.length"
type="link"
@click="showClosed = !showClosed"
>
{{ showClosed ? 'پنهان کردن' : 'نمایش' }}
{{ toFaDigits(closed.length) }} کار انجام‌شده / بسته
</a-button>
<template v-if="showClosed">
<RoutineTaskRow
v-for="t in closed"
:key="t.id"
:task="t"
:busy="actions.busyId.value === t.id"
show-date
show-assignee
@toggle="actions.toggle"
@open="openTask"
@action="onAction"
/>
</template>
</a-flex>
</a-spin>
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" />
</section>
</a-card>
</template>
<style scoped>
.related {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
font-weight: var(--fw-semibold);
color: var(--ink);
}
h3 em {
font-style: normal;
font-size: var(--fs-xs);
font-weight: var(--fw-medium);
padding: 1px 8px;
border-radius: 99px;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.rows {
display: grid;
gap: var(--sp-2);
}
.muted {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.toggle-closed {
justify-self: start;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
cursor: pointer;
padding: 4px 0;
}
.error {
margin: 0;
}
.empty {
display: grid;
gap: 0.35rem;
}
.empty-art,
.hint,
.empty-cta {
display: none;
}
@media (max-width: 900px) {
.fancy {
gap: 0.85rem;
padding: 0.95rem 0.85rem 1rem;
border: 1px solid #e7eef2;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.fancy .mark {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.fancy header :deep(.ui-btn),
.fancy header :deep(button) {
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
box-shadow: none;
height: auto;
min-height: 34px;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 700;
flex-direction: row-reverse;
gap: 0.2rem;
}
.fancy .empty {
justify-items: center;
text-align: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem 0.2rem;
}
.fancy .empty-art {
display: grid;
place-items: center;
width: 92px;
height: 92px;
margin-bottom: 0.2rem;
border-radius: 24px;
background: linear-gradient(180deg, #f7fcfa, #eef8f4);
}
.fancy .hint {
display: block;
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
}
.fancy .empty-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
}
</style>
@@ -1,9 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
Alert as AAlert,
Col as ACol,
FormItem as AFormItem,
Modal as AModal,
Row as ARow,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal);
@@ -57,81 +67,63 @@ const submitLabel = computed(() => {
return 'ذخیره';
}
});
const okDanger = computed(() => modal.value.kind === 'cancel');
</script>
<template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
<form class="rt-modal" @submit.prevent="actions.submit()">
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
<a-modal
:open="!!modal.kind"
:title="title"
:confirm-loading="modal.saving"
:ok-text="submitLabel"
:ok-button-props="{ danger: okDanger }"
cancel-text="انصراف"
destroy-on-close
@ok="actions.submit()"
@cancel="actions.close()"
>
<a-typography-text v-if="modal.task" strong>
{{ modal.task.title }}
</a-typography-text>
<template v-if="modal.kind === 'reschedule'">
<div class="when">
<UiField label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField>
<UiField label="ساعت" hint="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</UiField>
</div>
</template>
<a-row v-if="modal.kind === 'reschedule'" :gutter="[16, 0]" :style="{ marginTop: '12px' }">
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="modal.time" mode="time" />
</a-form-item>
</a-col>
</a-row>
<UiField v-else :label="noteLabel" :required="noteRequired">
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
</UiField>
<a-form-item
v-else
:label="noteLabel"
:required="noteRequired"
:style="{ marginTop: '12px' }"
>
<a-textarea
v-model:value="modal.note"
:rows="4"
:placeholder="placeholder"
:maxlength="2000"
/>
</a-form-item>
<p v-if="modal.kind === 'cancel'" class="hint">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<a-typography-text v-if="modal.kind === 'cancel'" type="secondary">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</a-typography-text>
<template #footer>
<UiButton
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
:loading="modal.saving"
@click="actions.submit()"
>
{{ submitLabel }}
</UiButton>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
<a-alert
v-if="modal.error"
type="error"
show-icon
:message="modal.error"
:style="{ marginTop: '12px' }"
/>
</a-modal>
</template>
<style scoped>
.rt-modal {
display: grid;
gap: var(--sp-4);
}
.task-name {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--surface-subtle);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.when {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-3);
}
.hint {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.error {
margin: 0;
}
@media (max-width: 600px) {
.when {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,17 +1,33 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import {
Button as AButton,
Checkbox as ACheckbox,
Dropdown as ADropdown,
Flex as AFlex,
Menu as AMenu,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import {
CalendarOutlined,
CheckOutlined,
ClockCircleOutlined,
FlagOutlined,
MoreOutlined,
WarningOutlined,
} from '@ant-design/icons-vue';
const AMenuItem = AMenu.Item;
import UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import {
ENTITY_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute,
formatDayNumeric,
formatDelay,
@@ -20,6 +36,9 @@ import {
isOpenStatus,
} from '@/lib/routines';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
export type RoutineTaskRowAction =
| 'start'
| 'complete-note'
@@ -45,396 +64,135 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
done.value
? `برگرداندن «${props.task.title}» به حالت انجام‌نشده`
: `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
);
function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task);
}
function onMenuClick({ key }: { key: string | number }) {
const k = String(key);
if (k === 'open') {
emit('open', props.task);
return;
}
act(k as RoutineTaskRowAction);
}
</script>
<template>
<article
class="rt-row"
:class="[`st-${task.status}`, { done, closed, busy }]"
<a-flex
align="flex-start"
:gap="12"
:style="{
width: '100%',
padding: '12px 16px',
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: '8px',
cursor: 'pointer',
opacity: closed ? 0.62 : 1,
}"
@click="emit('open', task)"
>
<button
type="button"
class="rt-check"
:class="{ on: done }"
<a-checkbox
:checked="done"
:disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel"
@click.stop="emit('toggle', task)"
>
<span class="box">
<UiIcon v-if="done" name="check-plain" :size="16" />
<span v-else-if="busy" class="spin" aria-hidden="true" />
</span>
</button>
@click.stop
@update:checked="emit('toggle', task)"
/>
<div class="rt-main">
<div class="rt-title-line">
<h3 class="rt-title">{{ task.title }}</h3>
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
</div>
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
<a-flex vertical :gap="4" style="flex: 1; min-width: 0">
<a-space :size="6">
<a-typography-title
:level="5"
:style="{
margin: 0,
textDecoration: done ? 'line-through' : undefined,
}"
>
{{ task.title }}
</a-typography-title>
<FlagOutlined v-if="task.isMandatory" />
</a-space>
<div class="rt-meta">
<span v-if="showDate" class="meta-item">
<UiIcon name="calendar" :size="13" />
{{ formatDayNumeric(task.scheduledFor) }}
</span>
<span v-if="showTime" class="meta-item">
<UiIcon name="clock" :size="13" />
{{ formatTime(task.dueAt) }}
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
<UiIcon name="warning" :size="13" />
{{ formatDelay(task.dueAt) }} تأخیر
</span>
<span v-if="done && task.completedAt" class="meta-item ok">
<UiIcon name="check" :size="13" />
{{ formatTime(task.completedAt) }}
</span>
<span v-if="task.category" class="cat-chip">
<i :style="{ background: task.category.color }" />
{{ task.category.name }}
</span>
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
{{ PRIORITY_LABELS[task.priority] }}
</UiBadge>
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
<a-typography-paragraph
v-if="task.description"
type="secondary"
:ellipsis="{ rows: 1 }"
:style="{ marginBottom: 0 }"
>
{{ task.description }}
</a-typography-paragraph>
<a-space wrap :size="[8, 4]">
<a-typography-text v-if="showDate" type="secondary">
<CalendarOutlined /> {{ formatDayNumeric(task.scheduledFor) }}
</a-typography-text>
<a-typography-text v-if="showTime" type="secondary">
<ClockCircleOutlined /> {{ formatTime(task.dueAt) }}
</a-typography-text>
<a-typography-text
v-if="showDelay && task.status === 'overdue'"
type="danger"
>
<WarningOutlined /> {{ formatDelay(task.dueAt) }} تأخیر
</a-typography-text>
<a-typography-text v-if="done && task.completedAt" type="success">
<CheckOutlined /> {{ formatTime(task.completedAt) }}
</a-typography-text>
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
<a-tag v-if="task.status !== 'todo'" :bordered="false">
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
</a-tag>
<a-tag :bordered="false">{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</a-tag>
<RouterLink
v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)"
@click.stop
>
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
{{ ent.label }}
<a-tag color="processing" :bordered="false">
{{ ENTITY_TYPE_LABELS[ent.type] }} · {{ ent.label }}
</a-tag>
</RouterLink>
</div>
</div>
</a-space>
</a-flex>
<div class="rt-side" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
<a-space :size="8" @click.stop>
<a-space v-if="showAssignee && task.assignee" :size="6">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span>
</span>
<UiDropdownMenu v-model:open="menuOpen" align="end">
<template #trigger="{ toggle }">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
<UiIcon name="dots" :size="18" />
</button>
<a-typography-text type="secondary">{{ task.assignee.fullName }}</a-typography-text>
</a-space>
<a-dropdown>
<a-button type="text" aria-label="عملیات بیشتر">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="onMenuClick">
<a-menu-item key="open">جزئیات</a-menu-item>
<a-menu-item v-if="task.status === 'todo'" key="start">شروع کار</a-menu-item>
<a-menu-item v-if="open || done" key="complete-note">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</a-menu-item>
<a-menu-item v-if="open" key="reschedule">تغییر زمان</a-menu-item>
<a-menu-item v-if="task.status === 'overdue'" key="move-today">
انتقال به امروز
</a-menu-item>
<a-menu-item v-if="open && task.rules.allowSkip" key="skip">رد کردن</a-menu-item>
<a-menu-item v-if="open" key="cancel" danger>لغو کار</a-menu-item>
</a-menu>
</template>
<button type="button" @click="emit('open', task)">جزئیات</button>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
<button v-if="open || done" type="button" @click="act('complete-note')">
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
</button>
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
</UiDropdownMenu>
</div>
</article>
</a-dropdown>
</a-space>
</a-flex>
</template>
<style scoped>
.rt-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
}
.rt-row:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-xs);
}
.rt-row.st-overdue {
border-inline-start: 3px solid var(--danger);
}
.rt-row.done {
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
}
.rt-row.closed {
opacity: 0.62;
}
.rt-check {
width: 40px;
height: 40px;
margin: -6px;
padding: 0;
border: 0;
background: transparent;
display: grid;
place-items: center;
cursor: pointer;
border-radius: var(--radius);
}
.rt-check:disabled {
cursor: not-allowed;
}
.rt-check:focus-visible {
outline: 2px solid var(--accent);
}
.rt-check .box {
width: 24px;
height: 24px;
border-radius: 7px;
border: 2px solid var(--line-strong);
background: var(--surface);
display: grid;
place-items: center;
color: #fff;
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
}
.rt-check:not(:disabled):hover .box {
border-color: var(--accent);
}
.rt-check:active .box {
transform: scale(0.92);
}
.rt-check.on .box {
background: var(--success);
border-color: var(--success);
}
.st-overdue .rt-check .box {
border-color: color-mix(in srgb, var(--danger) 55%, white);
}
.spin {
width: 12px;
height: 12px;
border: 2px solid var(--line);
border-top-color: var(--accent);
border-radius: 50%;
animation: rt-spin 0.7s linear infinite;
}
@keyframes rt-spin {
to {
transform: rotate(360deg);
}
}
.rt-main {
min-width: 0;
display: grid;
gap: 4px;
}
.rt-title-line {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rt-title {
margin: 0;
font-size: var(--fs-base);
font-weight: var(--fw-semibold);
color: var(--ink);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done .rt-title {
color: var(--muted);
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.mand {
color: var(--warning-deep);
flex-shrink: 0;
}
.rt-desc {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 2px;
font-size: var(--fs-xs);
color: var(--muted);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.meta-item.late {
color: var(--danger);
font-weight: var(--fw-semibold);
}
.meta-item.ok {
color: var(--success-deep);
}
.cat-chip,
.src-chip {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.cat-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.src-chip {
color: var(--muted-light);
}
.ent-link {
display: inline-flex;
align-items: center;
gap: 5px;
max-width: 220px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ent-link:hover {
background: var(--accent-soft);
}
.ent-type {
color: var(--muted);
font-weight: var(--fw-normal);
}
.rt-side {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.assignee {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-xs);
color: var(--ink-secondary);
max-width: 150px;
}
.assignee-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-more {
width: 32px;
height: 32px;
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
display: grid;
place-items: center;
cursor: pointer;
}
.rt-more:hover {
background: var(--surface-subtle);
color: var(--ink);
}
:global(.ui-dd-panel button.danger) {
color: var(--danger);
}
@media (max-width: 900px) {
.rt-row {
padding: var(--sp-3);
gap: var(--sp-2);
}
.rt-check {
width: 48px;
height: 48px;
margin: -8px -6px;
}
.rt-check .box {
width: 28px;
height: 28px;
border-radius: 8px;
}
.rt-title {
white-space: normal;
}
.assignee-name {
display: none;
}
}
</style>
+19 -7
View File
@@ -1089,6 +1089,7 @@ onUnmounted(() => {
.workspace {
min-width: 0;
min-height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
@@ -1909,10 +1910,10 @@ onUnmounted(() => {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 1rem;
gap: 16px;
min-width: 0;
/* همان فاصله بالای سایدبار شناور (14px) */
padding: 14px 1.5rem 0.85rem;
/* هم‌تراز با پدینگ ۱۶px محتوای workspace */
padding: 16px;
position: sticky;
top: 0;
z-index: 20;
@@ -2118,18 +2119,29 @@ onUnmounted(() => {
}
.content {
padding: 0 1.5rem 1.5rem;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
padding: 16px;
overflow-x: clip;
}
/* ریشهٔ RouterView ارتفاع workspace را پر کند */
.content > *:first-child {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
@media (max-width: 1280px) and (min-width: 901px) {
.content {
padding-inline: 1rem;
padding: 16px;
}
.desktop-topbar {
padding-inline: 1rem;
padding: 16px;
}
}
@@ -2258,7 +2270,7 @@ onUnmounted(() => {
}
.content {
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
}
.notif-layer {
+77
View File
@@ -0,0 +1,77 @@
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context';
/**
* Central Ant Design Vue v4 theme for Montact.
* Migrated pages must consume this via App ConfigProvider — do not fork in views.
*
* controlHeight: 36 is the system default (Ant derives SM/LG).
* Do not set controlHeight* under components — inherit the token.
*/
/** Tailwind gray-50 / gray-200 — layout fill + control/card borders */
const GRAY_50 = '#f9fafb';
const GRAY_200 = '#e5e7eb';
export const antdTheme = {
token: {
colorPrimary: '#10b981',
fontFamily: '"IRANSans", Tahoma, sans-serif',
/** Page / subtle surface fill */
colorBgLayout: GRAY_50,
colorFillQuaternary: GRAY_50,
colorFillAlter: GRAY_50,
/** Control, card, and divider borders */
colorBorder: GRAY_200,
colorBorderSecondary: GRAY_200,
borderRadius: 8,
borderRadiusSM: 6,
borderRadiusLG: 12,
/** Default Ant control size for the whole app (buttons, inputs, selects, …). */
controlHeight: 36,
/* Spacing scale: 8, 12, 16, 20, 24 */
paddingXXS: 4,
paddingXS: 8,
paddingSM: 12,
padding: 16,
paddingMD: 20,
paddingLG: 24,
paddingXL: 32,
marginXXS: 4,
marginXS: 8,
marginSM: 12,
margin: 16,
marginMD: 20,
marginLG: 24,
marginXL: 32,
boxShadow:
'0 1px 2px 0 rgba(15, 23, 42, 0.04), 0 1px 6px -1px rgba(15, 23, 42, 0.06), 0 2px 4px 0 rgba(15, 23, 42, 0.04)',
boxShadowSecondary:
'0 6px 16px 0 rgba(15, 23, 42, 0.08), 0 3px 6px -4px rgba(15, 23, 42, 0.1), 0 9px 28px 8px rgba(15, 23, 42, 0.05)',
},
components: {
Card: {
paddingLG: 24,
headerBg: 'transparent',
},
Collapse: {
headerBg: GRAY_50,
contentBg: GRAY_50,
colorBorder: GRAY_200,
},
Input: {
colorBorder: GRAY_200,
},
Select: {
colorBorder: GRAY_200,
},
Form: {
itemMarginBottom: 18,
},
},
} as ThemeConfig;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+403 -164
View File
@@ -1,18 +1,153 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Empty as AEmpty,
Flex as AFlex,
Space as ASpace,
Spin as ASpin,
Tag as ATag,
Typography,
theme,
} from 'ant-design-vue';
import {
CalendarOutlined,
HolderOutlined,
PhoneOutlined,
ReloadOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const router = useRouter();
const GAP = 16;
const COL_WIDTH = 280;
const loading = ref(false);
const moving = ref(false);
const error = ref('');
const columns = ref<LeadBoardColumnDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
let suppressCardClick = false;
/**
* ارتفاع کامل زیر topbar شل.
* height:0 + flex:1 روی فرزند، زنجیرهٔ کشش را اجباری می‌کند.
*/
const pageStyle = computed(() => ({
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
height: 'calc(100dvh - 120px)',
maxHeight: 'calc(100dvh - 120px)',
minHeight: '420px',
minWidth: 0,
}));
const boardShellStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column' as const,
padding: `${GAP}px`,
background: token.value.colorBgContainer,
border: `1px solid ${token.value.colorBorderSecondary}`,
borderRadius: `${token.value.borderRadiusLG}px`,
position: 'relative' as const,
overflow: 'hidden',
}));
const boardTrackStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'stretch',
gap: `${GAP}px`,
width: '100%',
overflowX: 'auto' as const,
overflowY: 'hidden' as const,
paddingBottom: '4px',
}));
function columnStyle(stageId: string) {
const active = dropStageId.value === stageId;
return {
boxSizing: 'border-box' as const,
flex: `0 0 ${COL_WIDTH}px`,
width: `${COL_WIDTH}px`,
alignSelf: 'stretch',
display: 'flex',
flexDirection: 'column' as const,
minHeight: 0,
background: token.value.colorFillAlter,
border: `1px solid ${
active ? token.value.colorPrimary : token.value.colorBorderSecondary
}`,
borderRadius: `${token.value.borderRadiusLG}px`,
boxShadow: active ? `0 0 0 2px ${token.value.colorPrimaryBg}` : undefined,
overflow: 'hidden',
};
}
const columnHeadStyle = computed(() => ({
boxSizing: 'border-box' as const,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${GAP}px`,
padding: `${GAP}px`,
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
background: token.value.colorBgContainer,
}));
const columnBodyStyle = computed(() => ({
boxSizing: 'border-box' as const,
flex: '1 1 auto',
height: 0,
minHeight: 0,
overflowY: 'auto' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: '12px',
padding: '12px',
margin: 0,
}));
const cardBodyStyle = computed(() => ({
padding: '12px 14px',
margin: 0,
}));
function leadInitials(lead: LeadDto) {
const raw = (lead.contactName || lead.title || '?').trim();
const parts = raw.split(/\s+/).filter(Boolean);
if (!parts.length) return '؟';
if (parts.length === 1) return parts[0]!.slice(0, 1);
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
}
function leadSubtitle(lead: LeadDto) {
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
}
function formatDate(value: string | null) {
if (!value) return '';
@@ -39,7 +174,13 @@ async function load() {
}
}
function openLead(lead: LeadDto) {
if (suppressCardClick || draggingId.value || moving.value) return;
void router.push({ name: 'lead-detail', params: { id: lead.id } });
}
function onDragStart(lead: LeadDto, event: DragEvent) {
suppressCardClick = true;
draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
@@ -48,6 +189,9 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
}
function onDragOver(stageId: string, event: DragEvent) {
@@ -106,183 +250,278 @@ onMounted(load);
</script>
<template>
<section class="page">
<UiPageHeader title="مسیر لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
<template #actions>
<RouterLink :to="{ name: 'leads' }" class="link-btn">
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
<div class="lead-board-page" :style="pageStyle">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">
مسیر لیدها
</a-typography-title>
<a-typography-text type="secondary">
کشیدن و رها کردن برای تغییر وضعیت
</a-typography-text>
</div>
<a-space :size="GAP">
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
<a-button @click="navigate">
<template #icon><UnorderedListOutlined /></template>
فهرست لیدها
</a-button>
</RouterLink>
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
<a-button
:disabled="loading || moving"
:loading="loading"
@click="load"
>
<template #icon><ReloadOutlined /></template>
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
</a-button>
</a-space>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<a-alert v-if="error" type="error" show-icon :message="error" />
<div v-else-if="columns.length" class="board">
<section
v-for="col in columns"
:key="col.stage.id"
class="column"
:class="{ drop: dropStageId === col.stage.id }"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
<div :style="boardShellStyle">
<div
v-if="loading || moving"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<header class="col-head">
<span
class="dot"
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
</header>
<a-spin />
</div>
<div class="cards">
<article
v-for="lead in col.leads"
:key="lead.id"
class="card"
:class="{ dragging: draggingId === lead.id }"
draggable="true"
@dragstart="onDragStart(lead, $event)"
@dragend="onDragEnd"
>
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
{{ lead.title }}
</RouterLink>
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
<p v-if="lead.city" class="line">{{ lead.city }}</p>
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
<p v-if="lead.nextFollowUpAt" class="line muted">
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
</p>
</article>
<p v-if="!col.leads.length" class="empty-col">خالی</p>
<a-empty
v-if="!loading && !columns.length"
description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
:style="{ margin: 'auto' }"
/>
<div
v-else-if="columns.length"
class="lead-board-track"
:style="boardTrackStyle"
>
<div
v-for="col in columns"
:key="col.stage.id"
:style="columnStyle(col.stage.id)"
@dragover="onDragOver(col.stage.id, $event)"
@drop="onDrop(col.stage.id, $event)"
>
<div :style="columnHeadStyle">
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: col.stage.color || token.colorPrimary,
display: 'inline-block',
flexShrink: 0,
}"
/>
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
</a-space>
<a-typography-text type="secondary">
{{ toFaDigits(col.leads.length) }}
</a-typography-text>
</div>
<div :style="columnBodyStyle">
<a-card
v-for="lead in col.leads"
:key="lead.id"
hoverable
:bordered="true"
:body-style="cardBodyStyle"
:style="{
cursor: 'pointer',
opacity: draggingId === lead.id ? 0.45 : 1,
flexShrink: 0,
margin: 0,
background: token.colorBgContainer,
boxShadow: token.boxShadow,
}"
@click="openLead(lead)"
>
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
<a-flex :gap="10" align="center" :style="{ minWidth: 0 }">
<span
role="button"
tabindex="0"
aria-label="جابه‌جایی لید"
title="برای جابه‌جایی بکشید"
draggable="true"
:style="{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '28px',
height: '28px',
flexShrink: 0,
borderRadius: `${token.borderRadiusSM}px`,
color: token.colorTextTertiary,
cursor: 'grab',
background: token.colorFillQuaternary,
}"
@click.stop
@dragstart.stop="onDragStart(lead, $event)"
@dragend.stop="onDragEnd"
>
<HolderOutlined />
</span>
<a-avatar
:style="{
background: token.colorPrimaryBg,
color: token.colorPrimary,
flexShrink: 0,
fontWeight: 700,
}"
>
{{ leadInitials(lead) }}
</a-avatar>
<div :style="{ flex: 1, minWidth: 0 }">
<a-typography-text
strong
ellipsis
:style="{ display: 'block', margin: 0, lineHeight: 1.35 }"
:title="lead.title"
>
{{ lead.title }}
</a-typography-text>
<a-typography-text
v-if="leadSubtitle(lead)"
type="secondary"
ellipsis
:style="{
display: 'block',
margin: 0,
marginTop: '2px',
fontSize: `${token.fontSizeSM}px`,
lineHeight: 1.35,
}"
:title="leadSubtitle(lead)"
>
{{ leadSubtitle(lead) }}
</a-typography-text>
</div>
</a-flex>
<a-flex
justify="space-between"
align="center"
:gap="8"
:style="{ minWidth: 0 }"
>
<a-flex
v-if="lead.mobile"
align="center"
:gap="6"
dir="ltr"
:style="{ minWidth: 0 }"
>
<PhoneOutlined
:style="{
color: token.colorPrimary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
fontVariantNumeric: 'tabular-nums',
color: token.colorText,
}"
>
{{ lead.mobile }}
</a-typography-text>
</a-flex>
<a-tag
v-if="lead.assigneeName"
:bordered="false"
:style="{
margin: 0,
maxWidth: '46%',
overflow: 'hidden',
textOverflow: 'ellipsis',
background: token.colorFillSecondary,
}"
:title="lead.assigneeName"
>
{{ lead.assigneeName }}
</a-tag>
</a-flex>
<a-flex
v-if="lead.nextFollowUpAt"
align="center"
:gap="6"
:style="{
paddingTop: '8px',
borderTop: `1px solid ${token.colorSplit}`,
}"
>
<CalendarOutlined
:style="{
color: token.colorTextSecondary,
fontSize: `${token.fontSizeSM}px`,
}"
/>
<a-typography-text
type="secondary"
:style="{
margin: 0,
fontSize: `${token.fontSizeSM}px`,
}"
>
پیگیری {{ formatDate(lead.nextFollowUpAt) }}
</a-typography-text>
</a-flex>
</a-flex>
</a-card>
<a-typography-text
v-if="!col.leads.length"
type="secondary"
:style="{
textAlign: 'center',
display: 'block',
margin: 'auto',
padding: `${GAP}px`,
}"
>
خالی
</a-typography-text>
</div>
</div>
</section>
</div>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای برای نمایش در مسیر لیدها نیست."
description="در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
/>
</section>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ری‌اسکین Ant */
.lead-board-page {
width: 100%;
}
.link-btn {
text-decoration: none;
.lead-board-track {
scrollbar-width: thin;
}
.board {
display: flex;
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
min-height: 24rem;
.lead-board-track::-webkit-scrollbar {
height: 8px;
}
.column {
flex: 0 0 16.5rem;
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
border: 1px solid var(--line);
border-radius: var(--radius);
display: grid;
grid-template-rows: auto 1fr;
max-height: calc(100vh - 12rem);
}
.column.drop {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.col-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid var(--line);
}
.col-head h2 {
margin: 0;
font-size: 0.95rem;
flex: 1;
}
.col-head em {
font-style: normal;
font-size: 0.8rem;
color: var(--muted);
}
.dot {
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
}
.cards {
padding: 0.55rem;
display: grid;
gap: 0.5rem;
overflow-y: auto;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: calc(var(--radius) - 2px);
padding: 0.65rem 0.7rem;
display: grid;
gap: 0.2rem;
cursor: grab;
}
.card.dragging {
opacity: 0.45;
}
.title {
font-weight: 600;
color: var(--ink);
text-decoration: none;
}
.title:hover {
color: var(--accent);
}
.line {
margin: 0;
font-size: 0.82rem;
}
.muted,
.empty-col {
color: var(--muted);
}
.empty-col {
margin: 0.5rem;
text-align: center;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
margin: 0;
.lead-board-track::-webkit-scrollbar-thumb {
border-radius: 999px;
background: color-mix(in srgb, currentColor 28%, transparent);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+370 -520
View File
@@ -2,6 +2,37 @@
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const ASpaceCompact = Space.Compact;
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = withDefaults(
defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const companyOptions = computed(() => [
{ value: '', label: 'بدون شرکت' },
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
vcfInputKey.value += 1;
void nextTick(() => triggerVcfPick());
}
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
function triggerVcfPick() {
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
input?.click();
}
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
void (async () => {
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file as File);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
})();
return false;
};
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
triggerVcfPick();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
triggerVcfPick();
}
function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
function remove() {
if (!id.value) return;
Modal.confirm({
title: 'حذف شخص',
content: 'این شخص حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
</script>
<template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
<!-- Desktop Header -->
<UiPageHeader
<div>
<a-flex
v-if="!embedded"
:title="pageTitle"
:back-to="backTo"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<template #lead>
<span class="crumb">
<div>
<a-typography-text type="secondary">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
· {{ pageTitle }}
</a-typography-text>
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
</div>
<a-space :size="8" wrap>
<a-button @click="cancel">
<template #icon><ArrowLeftOutlined /></template>
انصراف
</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
<template #icon><DeleteOutlined /></template>
حذف
</UiButton>
</template>
</UiPageHeader>
</a-button>
<a-button type="primary" :loading="saving" @click="submit">
<template #icon><CheckOutlined /></template>
ذخیره
</a-button>
</a-space>
</a-flex>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<a-spin :spinning="loading">
<a-form v-if="!loading" layout="vertical" @finish="submit">
<a-card :bordered="false">
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="firstName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-space-compact block style="width: 100%">
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
<a-button
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<template #icon><PhoneOutlined /></template>
{{ pickingContact ? '…' : 'از مخاطبین' }}
</a-button>
</a-space-compact>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="phone" type="tel" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="email" type="email" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="position" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="آدرس وب‌سایت">
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیج اینستاگرام">
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ تولد">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
</a-row>
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<a-divider orientation="right">آدرس‌ها</a-divider>
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
<a-flex justify="flex-end">
<a-button type="link" @click="addAddress">
<template #icon><PlusOutlined /></template>
افزودن آدرس
</a-button>
</a-flex>
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
<a-col :flex="'auto'">
<a-input
v-model:value="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
</a-col>
<a-col>
<a-button danger @click="removeAddress(idx)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
</a-flex>
<UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" />
</UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</p>
<div class="pick-search">
<UiIcon name="search" :size="15" />
<input
v-model="pickQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<ul v-if="visiblePickDrafts.length" class="pick-list">
<li v-for="row in visiblePickDrafts" :key="row.key">
<button type="button" class="pick-row" @click="choosePick(row)">
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="pick-meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
<template #footer>
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
</template>
</UiModal>
<UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" />
</UiField>
<UiField label="ایمیل" for-id="email">
<UiInput id="email" v-model="email" type="email" />
</UiField>
<UiField label="سمت" for-id="position">
<UiInput id="position" v-model="position" />
</UiField>
<UiField label="آدرس وب‌سایت" for-id="website">
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
</UiField>
<UiField label="پیج اینستاگرام" for-id="instagram">
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
<div class="crm-col-span-full addresses">
<div class="addresses-head">
<span>آدرس‌ها</span>
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
</div>
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
<UiInput
:id="`address-${idx}`"
v-model="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
×
</button>
</div>
</div>
<div class="crm-col-span-full">
<UiField label="وضعیت مشتری">
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood-btn"
type="button"
:class="{ on: mood === item }"
<a-divider orientation="right">وضعیت و شرکت</a-divider>
<a-form-item label="وضعیت مشتری">
<a-row :gutter="[8, 8]">
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
<a-button
block
:type="mood === item ? 'primary' : 'default'"
:ghost="mood === item"
:style="{ height: 'auto', paddingBlock: '12px' }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
<a-flex vertical align="center" :gap="6">
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</a-flex>
</a-button>
</a-col>
</a-row>
</a-form-item>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="شرکت">
<a-select
v-model:value="companyId"
show-search
option-filter-prop="label"
:options="companyOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
v-model:value="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="notes" :rows="3" />
</a-form-item>
</a-col>
</a-row>
<a-card
v-if="!isNew"
:bordered="true"
:style="{ marginTop: '8px' }"
type="inner"
>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<div>
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
<div>
<a-typography-text strong>
{{ toFaDigits(loyaltyPoints) }} امتیاز
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
</a-typography-text>
</div>
</div>
</UiField>
</div>
<RouterLink :to="{ name: 'loyalty' }">
<a-button type="link">مدیریت باشگاه</a-button>
</RouterLink>
</a-flex>
</a-card>
<div class="crm-col-span-full">
<UiField label="شرکت" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
</div>
<CustomFieldsSection entity-type="person" v-model="customFields" />
</a-card>
<div class="crm-col-span-full">
<UiField label="برچسب‌ها" for-id="tags">
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<div class="crm-col-span-full">
<UiField label="یادداشت" for-id="notes">
<UiTextarea id="notes" v-model="notes" :rows="3" />
</UiField>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
<div class="loyalty-info">
<span class="muted">باشگاه مشتریان</span>
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
</div>
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
</UiCard>
<a-space
v-if="embedded"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<a-space
v-else
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
</a-space>
</a-form>
</a-spin>
<p v-if="error" class="crm-field-error">{{ error }}</p>
<a-upload
ref="vcfUploadRef"
:key="vcfInputKey"
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="onVcfBeforeUpload"
:style="{ display: 'none' }"
>
<span />
</a-upload>
<div class="mobile-actions mob-only">
<UiButton variant="primary" block type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
حذف
</UiButton>
</div>
</form>
<a-modal
:open="sourceOpen"
title="از مخاطبین"
:footer="null"
destroy-on-close
@cancel="closeSource"
>
<a-typography-paragraph type="secondary">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
<a-button type="link" @click="closeSource">انصراف</a-button>
</a-flex>
</a-modal>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
</section>
<a-modal
:open="pickOpen"
title="انتخاب مخاطب"
destroy-on-close
@cancel="closePick"
>
<a-typography-paragraph type="secondary">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</a-typography-paragraph>
<a-input
v-model:value="pickQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
:style="{ marginBottom: '12px' }"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-list
v-if="visiblePickDrafts.length"
:data-source="visiblePickDrafts"
:style="{ maxHeight: '360px', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
</a-button>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
<template #footer>
<a-button @click="closePick">انصراف</a-button>
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.crumb {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.embed-actions {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addresses {
display: grid;
gap: var(--sp-2);
}
.addresses-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.add-addr {
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
}
.addr-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
}
.rm-addr {
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.rm-addr:hover {
color: #b91c1c;
border-color: #fecaca;
}
.moods {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.mood-btn {
display: grid;
justify-items: center;
gap: 4px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font: inherit;
color: var(--muted);
font-size: var(--fs-xs);
transition: all var(--transition-fast);
}
.mood-btn.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.loyalty-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
background: var(--surface-subtle);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.loyalty-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.loyalty-link {
color: var(--accent-deep);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.mobile-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
.pick-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: var(--fs-sm);
line-height: 1.7;
}
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.pick-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.pick-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: var(--fs-sm);
}
.pick-meta small {
color: var(--muted);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>
+294 -359
View File
@@ -17,12 +17,28 @@ import {
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import {
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
import {
Alert as AAlert,
Button as AButton,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Modal as AModal,
Space as ASpace,
Spin as ASpin,
theme,
} from 'ant-design-vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
@@ -30,6 +46,9 @@ import {
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const { token } = theme.useToken();
const GAP = 16;
/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
const pageStyle = {
boxSizing: 'border-box' as const,
display: 'flex',
flexDirection: 'column' as const,
gap: `${GAP}px`,
flex: '1 1 auto',
minHeight: 0,
minWidth: 0,
width: '100%',
height: '100%',
padding: 0,
margin: 0,
};
const route = useRoute();
const router = useRouter();
@@ -62,7 +96,6 @@ const amountMax = ref('');
const showCreate = ref(false);
const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
}
function onDeleteStage(stageId: string) {
deleteStageId.value = stageId;
const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
Modal.confirm({
title: 'حذف ستون',
content: `ستون «${title}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید اول آن‌ها را جابه‌جا کنید.`,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
if (!boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
throw e;
} finally {
stageBusy.value = false;
}
},
});
}
const deleteStageTitle = computed(() => {
const id = deleteStageId.value;
return stages.value.find((s) => s.id === id)?.title ?? '';
});
async function confirmDeleteStage() {
const stageId = deleteStageId.value;
if (!stageId || !boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
deleteStageId.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
const deleteDealIds = ref<string[]>([]);
const dealDeleting = ref(false);
const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
const deleteDealTitle = computed(() => {
const ids = deleteDealIds.value;
if (ids.length !== 1) return '';
function dealTitleById(dealId: string) {
for (const s of stages.value) {
const d = s.deals.find((x) => x.id === ids[0]);
const d = s.deals.find((x) => x.id === dealId);
if (d) return d.title;
}
return '';
});
const deleteConfirmCopy = computed(() => {
const n = deleteDealIds.value.length;
if (n <= 0) return '';
if (n === 1) {
return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
}
return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
});
}
function askDeleteDeal(dealId: string) {
menuOpenId.value = null;
deleteDealIds.value = [dealId];
confirmDeleteDeals([dealId]);
}
function startSelect(dealId: string) {
@@ -460,31 +481,43 @@ function clearSelect() {
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuOpenId.value = null;
deleteDealIds.value = [...selectedIds.value];
confirmDeleteDeals([...selectedIds.value]);
}
async function confirmDeleteDeal() {
const ids = deleteDealIds.value;
function confirmDeleteDeals(ids: string[]) {
if (!ids.length || dealDeleting.value) return;
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
deleteDealIds.value = [];
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
} finally {
dealDeleting.value = false;
}
const copy =
ids.length === 1
? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
: `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
Modal.confirm({
title: ids.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله',
content: copy,
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
dealDeleting.value = true;
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
clearSelect();
await load();
if (failed) {
error.value =
failed === ids.length
? 'حذف معامله‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
}
} catch (e) {
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
throw e;
} finally {
dealDeleting.value = false;
}
},
});
}
async function onDealSaved() {
@@ -593,6 +626,19 @@ function onMenuDeal(dealId: string) {
});
}
function onDealMenuClick({ key }: { key: string | number }) {
const id = menuOpenId.value;
if (!id) return;
if (key === 'select') startSelect(id);
else if (key === 'view') onOpenDeal(id);
else if (key === 'edit') {
dealModal.value = { dealId: id };
menuOpenId.value = null;
} else if (key === 'promote') void promotePriorDeal(id);
else if (key === 'delete') askDeleteDeal(id);
else menuOpenId.value = null;
}
const unbindMenuDismiss = bindMenuDismiss(
() => menuOpenId.value != null,
() => (menuOpenId.value = null),
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</script>
<template>
<section class="page" dir="rtl">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
<template #excel>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
<SavedFiltersBar
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری مسیر فروش…</p>
<div v-if="selectMode" class="select-bar">
<span>{{ toFaDigits(selectedCount) }} معامله انتخاب شده</span>
<button
v-if="selectedCount"
type="button"
class="select-del"
@click="askBulkDelete"
<div class="pipeline-page" dir="rtl" :style="pageStyle">
<a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
<PipelineToolbar
pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@create="showCreate = true"
>
حذف انتخاب‌شده‌ها
</button>
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button>
<template #excel>
<a-button :disabled="loading || busy" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<a-button :disabled="loading || busy">
<template #icon><UploadOutlined /></template>
ورود از اکسل
</a-button>
</ExcelImportFlow>
</template>
<template #filters>
<SavedFiltersBar
compact
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
</template>
</PipelineToolbar>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
<a-alert
v-if="selectMode"
type="info"
show-icon
:message="`${toFaDigits(selectedCount)} معامله انتخاب شده`"
>
<template #action>
<a-space>
<a-button
v-if="selectedCount"
type="primary"
danger
@click="askBulkDelete"
>
<template #icon><DeleteOutlined /></template>
حذف انتخاب‌شده‌ها
</a-button>
<a-button @click="clearSelect">انصراف از انتخاب</a-button>
</a-space>
</template>
</a-alert>
</a-flex>
<div
:style="{
position: 'relative',
flex: '1 1 auto',
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<div
v-if="pageBusy"
:style="{
position: 'absolute',
inset: 0,
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
>
<a-spin :tip="busyMessage" />
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
</div>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
:select-mode="selectMode"
:selected-ids="selectedIds"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
:select-mode="selectMode"
:selected-ids="selectedIds"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@toggle-select="toggleSelect"
@bulk-delete="askBulkDelete"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
<Teleport to="body">
<DealCardOverlay
v-if="overlayDeal && draggingId"
@@ -734,109 +819,62 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</Teleport>
<Teleport to="body">
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined">
<button type="button" role="menuitem" @click="startSelect(menuOpenId)">
انتخاب
</button>
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
مشاهده معامله
</button>
<button
type="button"
role="menuitem"
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null"
<div
v-if="menuOpenId"
:style="{
position: 'fixed',
zIndex: 1080,
...(menuStyle ?? {}),
}"
>
<a-menu
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
@click="onDealMenuClick"
>
ویرایش
</button>
<button
v-if="canPromotePrior(findDeal(menuOpenId))"
type="button"
role="menuitem"
@click="promotePriorDeal(menuOpenId)"
>
انتقال به کار جاری
</button>
<button type="button" role="menuitem" class="danger" @click="askDeleteDeal(menuOpenId)">
حذف
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
<a-menu-item key="select">انتخاب</a-menu-item>
<a-menu-item key="view">مشاهده معامله</a-menu-item>
<a-menu-item key="edit">ویرایش</a-menu-item>
<a-menu-item
v-if="canPromotePrior(findDeal(menuOpenId))"
key="promote"
>
انتقال به کار جاری
</a-menu-item>
<a-menu-item key="delete" danger>حذف</a-menu-item>
<a-menu-item key="close">بستن</a-menu-item>
</a-menu>
</div>
</Teleport>
<UiModal
<a-modal
:open="showCreate && canCreatePipeline"
title="مسیر فروش جدید"
@close="showCreate = false"
:confirm-loading="saving"
ok-text="ساخت"
cancel-text="انصراف"
:ok-button-props="{ disabled: newName.trim().length < 2 }"
destroy-on-close
@ok="createPipeline"
@cancel="showCreate = false"
>
<form class="create-form" @submit.prevent="createPipeline">
<label class="field">
<span>نام مسیر فروش</span>
<UiInput
v-model="newName"
<a-form layout="vertical" @finish="createPipeline">
<a-form-item label="نام مسیر فروش" required>
<a-input
v-model:value="newName"
placeholder="نام مسیر فروش جدید…"
:maxlength="120"
required
:minlength="2"
/>
</label>
</form>
<template #footer>
<UiButton
type="button"
:disabled="saving || newName.trim().length < 2"
@click="createPipeline"
>
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="showCreate = false">
انصراف
</UiButton>
</template>
</UiModal>
</a-form-item>
</a-form>
</a-modal>
<UiModal
:open="Boolean(deleteStageId)"
title="حذف ستون"
@close="deleteStageId = null"
>
<p class="delete-copy">
ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید
اول آن‌ها را جابه‌جا کنید.
</p>
<template #footer>
<UiButton
type="button"
:disabled="stageBusy"
@click="confirmDeleteStage"
>
{{ stageBusy ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteStageId = null">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
:open="deleteDealIds.length > 0"
:title="deleteDealIds.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله'"
@close="deleteDealIds = []"
>
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
<template #footer>
<UiButton type="button" :disabled="dealDeleting" @click="confirmDeleteDeal">
{{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteDealIds = []">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
<a-modal
:open="Boolean(dealModal)"
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
@close="dealModal = null"
:footer="null"
destroy-on-close
width="720px"
@cancel="dealModal = null"
>
<DealFormView
v-if="dealModal"
@@ -847,117 +885,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@saved="onDealSaved"
@cancel="dealModal = null"
/>
</UiModal>
</section>
</a-modal>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.15rem;
min-width: 0;
max-width: 100%;
direction: rtl;
text-align: right;
container-type: inline-size;
container-name: pipeline;
}
.create-form .field {
display: grid;
gap: 0.35rem;
}
.create-form .field span {
font-size: 0.85rem;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.delete-copy {
/* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
.pipeline-page {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.select-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.65rem 1rem;
padding: 0.55rem 0.85rem;
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 10%, #fff);
border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line));
font-size: 0.86rem;
font-weight: 700;
color: var(--ink);
}
.select-bar span {
flex: 1;
min-width: 8rem;
}
.select-bar button {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.select-bar .select-del {
color: var(--danger);
}
.select-bar .select-cancel {
color: var(--accent);
}
.menu-pop {
position: fixed;
z-index: 80;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
display: grid;
gap: 0.15rem;
}
.menu-pop button {
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--ink);
text-align: right;
}
.menu-pop button:hover {
background: var(--surface-2, #f1f5f9);
border-radius: calc(var(--radius) - 4px);
}
.menu-pop button.danger {
color: var(--danger);
width: 100%;
}
</style>
@@ -1,13 +1,36 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Row as ARow,
Select as ASelect,
Space as ASpace,
Statistic as AStatistic,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
@@ -116,110 +139,176 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="صندوق و حساب‌ها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div class="stats">
<article>
<span>مجموع موجودی حساب‌ها</span>
<strong>{{ toman(total) }}</strong>
</article>
<article>
<span>ورودی این فهرست</span>
<strong class="in">{{ toman(periodIn) }}</strong>
</article>
<article>
<span>خروجی این فهرست</span>
<strong class="out">{{ toman(periodOut) }}</strong>
</article>
<article>
<span>حساب‌های فعال</span>
<strong>{{ active.length }} حساب</strong>
</article>
</div>
<div class="list">
<article v-for="account in accounts" :key="account.id" class="account">
<div>
<strong>{{ account.name }}</strong>
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
<p>مشاهده گردش حساب و جزئیات</p>
</div>
<div class="side">
<b>{{ toman(account.balance) }}</b>
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
</div>
</article>
</div>
<div class="actions">
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
</div>
<UiCard v-if="openForm" title="حساب جدید">
<div class="grid">
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
<UiField label="نوع">
<select v-model="form.type" class="select">
<option value="cash">صندوق نقدی</option>
<option value="bank">حساب بانکی</option>
<option value="petty">تنخواه</option>
</select>
</UiField>
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
<UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حساب‌ها</a-typography-title>
<a-typography-text type="secondary">
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه می‌شود.
</a-typography-text>
</div>
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
</UiCard>
<UiCard v-if="transferOpen" title="انتقال بین حساب‌ها">
<div class="grid">
<UiField label="از حساب">
<select v-model="transfer.fromAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="به حساب">
<select v-model="transfer.toAccountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
</div>
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.</p>
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
</UiCard>
</section>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="مجموع موجودی حساب‌ها" :value="toman(total)" />
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="ورودی این فهرست"
:value="toman(periodIn)"
:value-style="{ color: 'var(--ant-color-success)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic
title="خروجی این فهرست"
:value="toman(periodOut)"
:value-style="{ color: 'var(--ant-color-error)' }"
/>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false">
<a-statistic title="حساب‌های فعال" :value="`${active.length} حساب`" />
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-space>
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
</a-space>
<template v-if="openForm">
<a-divider orientation="right">حساب جدید</a-divider>
<a-form layout="vertical" @finish="createAccount">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام حساب" name="name">
<a-input v-model:value="form.name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="type">
<a-select v-model:value="form.type" style="width: 100%">
<a-select-option value="cash">صندوق نقدی</a-select-option>
<a-select-option value="bank">حساب بانکی</a-select-option>
<a-select-option value="petty">تنخواه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
<a-form-item label="نام بانک" name="bankName">
<a-input v-model:value="form.bankName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شماره یا شناسه" name="accountRef">
<a-input v-model:value="form.accountRef" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مسئول" name="responsibleName">
<a-input v-model:value="form.responsibleName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="موجودی اولیه" name="openingBalance">
<a-input v-model:value="form.openingBalance" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع" name="openingDate">
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
</a-form>
</template>
<template v-if="transferOpen">
<a-divider orientation="right">انتقال بین حساب‌ها</a-divider>
<a-form layout="vertical" @finish="sendTransfer">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="از حساب" name="fromAccountId">
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="به حساب" name="toAccountId">
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="transfer.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="شرح" name="memo">
<a-input v-model:value="transfer.memo" />
</a-form-item>
</a-col>
</a-row>
<a-typography-text type="secondary">
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمی‌شود.
</a-typography-text>
<div>
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
</div>
</a-form>
</template>
<a-divider orientation="right">فهرست حساب‌ها</a-divider>
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="accounts">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
</template>
<a-list-item-meta>
<template #title>
{{ item.name }}
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
</template>
<template #description>
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
.stats article, .account {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
}
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
.in { color: #15803d; }
.out { color: #b91c1c; }
.list { display: grid; gap: 0.65rem; }
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.side { display: grid; justify-items: end; gap: 0.4rem; }
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,11 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Row as ARow,
Select as ASelect,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialCategoryDto } from '@mento/shared';
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const rows = ref<FinancialCategoryDto[]>([]);
const name = ref('');
const kind = ref<'income' | 'expense'>('expense');
@@ -35,36 +56,61 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="دسته‌بندی‌های مالی" description="دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div class="add">
<UiField label="نام"><UiInput v-model="name" /></UiField>
<UiField label="نوع">
<select v-model="kind" class="select">
<option value="income">درآمد</option>
<option value="expense">هزینه</option>
</select>
</UiField>
<UiButton @click="add">افزودن</UiButton>
</div>
<ul>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.name }}</strong>
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
</li>
</ul>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">دسته‌بندی‌های مالی</a-typography-title>
<a-typography-text type="secondary">
دسته‌های پیش‌فرض با اولین بازدید ساخته می‌شوند.
</a-typography-text>
</div>
<style scoped>
.page { display: grid; gap: 1rem; }
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
li span { color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">افزودن دسته</a-divider>
<a-form layout="vertical" @finish="add">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام" name="name">
<a-input v-model:value="name" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="kind" style="width: 100%">
<a-select-option value="income">درآمد</a-select-option>
<a-select-option value="expense">هزینه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="دسته‌ای ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
</a-tag>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,6 +1,261 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const kind = 'expense' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const form = ref({
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
});
const title = 'پرداخت‌ها و هزینه‌ها';
const settleLabel = 'مبلغ پرداخت‌شده';
const statusLabel: Record<string, string> = {
open: 'پرداخت‌نشده',
partial: 'جزئی',
settled: 'پرداخت‌شده',
void: 'باطل‌شده',
};
const methodOptions = [
{ value: 'cash', label: 'نقدی' },
{ value: 'card_to_card', label: 'کارت‌به‌کارت' },
{ value: 'pos', label: 'کارت‌خوان' },
{ value: 'transfer', label: 'حواله' },
{ value: 'other', label: 'سایر' },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const [docs, cats, acc] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
]);
rows.value = docs.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
});
form.value = {
title: '',
amount: '',
categoryId: '',
partyName: '',
accountId: '',
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
};
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<TreasuryDocsView kind="expense" />
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
<a-select-option value="">بدون دسته</a-select-option>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
<a-select-option value="">انتخاب حساب</a-select-option>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
+129 -79
View File
@@ -1,11 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import {
Alert as AAlert,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Row as ARow,
Statistic as AStatistic,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { TreasuryDashboardDto } from '@mento/shared';
import { UiCard, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const data = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
@@ -31,82 +46,117 @@ onMounted(async () => {
</script>
<template>
<section class="page">
<UiPageHeader
title="مدیریت مالی"
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<div v-if="data" class="stats">
<article v-for="card in cards" :key="card.key">
<span>{{ card.label }}</span>
<strong>{{ toman(data[card.key]) }}</strong>
<small>{{ card.hint }}</small>
</article>
</div>
<div v-if="data" class="split">
<UiCard title="اقساط نزدیک یا معوق">
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingInstallments" :key="row.id">
<div>
<strong>قسط {{ row.position }}</strong>
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
<UiCard title="پرداخت‌های آینده">
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
<ul v-else class="list">
<li v-for="row in data.upcomingPayables" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</div>
<UiCard v-if="data" title="آخرین اسناد">
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
<ul v-else class="list">
<li v-for="row in data.recent" :key="row.id">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
</div>
<b>{{ toman(row.amount) }}</b>
</li>
</ul>
</UiCard>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
<a-typography-text type="secondary">
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
</a-typography-text>
</div>
<style scoped>
.page { display: grid; gap: 1rem; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.stats article {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 16px;
padding: 0.9rem 1rem;
display: grid;
gap: 0.25rem;
}
.stats span, .stats small, .muted { color: #64748b; }
.stats strong { font-size: 1.05rem; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
.list small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<template v-if="data">
<a-row :gutter="[16, 16]">
<a-col
v-for="card in cards"
:key="card.key"
:xs="24"
:sm="12"
:md="8"
:xl="6"
>
<a-card :bordered="false">
<a-statistic :title="card.label" :value="toman(data[card.key])" />
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
<a-empty
v-if="!data.upcomingInstallments.length"
description="قسط بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingInstallments"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>قسط {{ item.position }}</template>
<template #description>
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
<a-col :xs="24" :lg="12">
<a-divider orientation="right">پرداخت‌های آینده</a-divider>
<a-empty
v-if="!data.upcomingPayables.length"
description="تعهد پرداخت بازی نیست."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.upcomingPayables"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>مانده {{ toman(item.remaining) }}</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-col>
</a-row>
<a-divider orientation="right">آخرین اسناد</a-divider>
<a-empty
v-if="!data.recent.length"
description="هنوز سندی ثبت نشده است."
/>
<a-list
v-else
item-layout="horizontal"
:data-source="data.recent"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
</template>
</a-list-item-meta>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</template>
</a-flex>
</div>
</template>
@@ -1,6 +1,765 @@
<script setup lang="ts">
import TreasuryDocsView from './TreasuryDocsView.vue';
import { computed, h, onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Grid,
Input as AInput,
List as AList,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Textarea as ATextarea,
Typography,
theme,
} from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue';
import {
ArrowDownOutlined,
ArrowLeftOutlined,
BankOutlined,
CheckOutlined,
ClockCircleOutlined,
CreditCardOutlined,
FileTextOutlined,
InfoCircleOutlined,
NumberOutlined,
PlusOutlined,
TagOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { formatLocalDateInput } from '@/lib/jalali';
import type {
FinancialAccountDto,
FinancialCategoryDto,
FinancialDocumentDto,
TreasuryDashboardDto,
} from '@mento/shared';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = computed(() => !screens.value.md);
const kind = 'income' as const;
const rows = ref<FinancialDocumentDto[]>([]);
const categories = ref<FinancialCategoryDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const dashboard = ref<TreasuryDashboardDto | null>(null);
const error = ref('');
const ok = ref('');
const saving = ref(false);
const q = ref('');
const formTab = ref<'main' | 'extra'>('main');
const categoryModalOpen = ref(false);
const newCategoryName = ref('');
const categorySaving = ref(false);
const mobileFormRef = ref<FormInstance>();
function emptyForm() {
return {
title: '',
amount: '',
categoryId: undefined as string | undefined,
partyName: '',
accountId: undefined as string | undefined,
settleAmount: '',
method: 'transfer',
reference: '',
notes: '',
bookedAt: formatLocalDateInput(new Date()),
};
}
const form = ref(emptyForm());
const pageTitle = 'دریافت‌ها و درآمدها';
const settleLabel = 'مبلغ دریافت‌شده';
const statusLabel: Record<string, string> = {
open: 'دریافت‌نشده',
partial: 'جزئی',
settled: 'دریافت‌شده',
void: 'باطل‌شده',
};
const methodOptions = [
{ value: 'cash', label: 'نقدی' },
{ value: 'card_to_card', label: 'کارت‌به‌کارت' },
{ value: 'pos', label: 'کارت‌خوان' },
{ value: 'transfer', label: 'حواله' },
{ value: 'other', label: 'سایر' },
];
const formTabOptions = [
{
value: 'main',
payload: 'main',
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
},
{
value: 'extra',
payload: 'extra',
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
},
];
const remainingAmount = computed(() => {
const total = Number(form.value.amount) || 0;
const settled = Number(form.value.settleAmount) || 0;
return Math.max(0, total - settled);
});
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
}
async function load() {
const [docs, cats, acc, dash] = await Promise.all([
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
api.get<FinancialAccountDto[]>('/treasury/accounts'),
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
]);
rows.value = docs.data;
categories.value = cats.data.filter((row) => row.isActive);
accounts.value = acc.data.filter((row) => row.isActive);
if (dash) dashboard.value = dash.data;
}
function resetForm() {
form.value = emptyForm();
formTab.value = 'main';
error.value = '';
ok.value = '';
}
async function createDoc() {
saving.value = true;
error.value = '';
ok.value = '';
try {
await api.post('/treasury/documents', {
kind,
title: form.value.title,
amount: form.value.amount,
categoryId: form.value.categoryId || null,
partyName: form.value.partyName || null,
accountId: form.value.accountId || null,
settleAmount: form.value.settleAmount || 0,
method: form.value.method,
reference: form.value.reference || null,
notes: form.value.notes || null,
bookedAt: form.value.bookedAt || undefined,
});
resetForm();
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمی‌کند.';
await load();
} catch (e) {
error.value = apiMessage(e);
} finally {
saving.value = false;
}
}
async function submitMobileForm() {
try {
await mobileFormRef.value?.validate();
} catch {
formTab.value = 'main';
return;
}
await createDoc();
}
async function voidDoc(id: string) {
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
try {
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
await load();
} catch (e) {
error.value = apiMessage(e);
}
}
async function createCategoryQuick() {
const name = newCategoryName.value.trim();
if (name.length < 2) {
error.value = 'نام دسته‌بندی کوتاه است.';
return;
}
categorySaving.value = true;
error.value = '';
try {
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
name,
kind,
});
await load();
form.value.categoryId = data.id;
newCategoryName.value = '';
categoryModalOpen.value = false;
} catch (e) {
error.value = apiMessage(e);
} finally {
categorySaving.value = false;
}
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری ناموفق بود.';
});
});
</script>
<template>
<TreasuryDocsView kind="income" />
<div
:style="
isMobile
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
: undefined
"
>
<!-- ─── Mobile app experience ─── -->
<template v-if="isMobile">
<a-flex vertical :gap="token.margin">
<div>
<a-flex align="center" :gap="token.marginXS">
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
ثبت دریافت جدید
</a-typography-title>
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
</a-flex>
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
</div>
<TreasuryNav />
<a-row :gutter="[12, 12]">
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
دریافت‌های این ماه
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.actualReceipts) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ArrowDownOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col :span="12">
<a-card
:bordered="false"
:body-style="{ padding: `${token.paddingSM}px` }"
:style="{
background: token.colorPrimaryBg,
borderRadius: `${token.borderRadiusLG}px`,
boxShadow: token.boxShadow,
}"
>
<a-flex align="center" justify="space-between" :gap="8">
<div :style="{ minWidth: 0, flex: 1 }">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
در انتظار دریافت
</a-typography-text>
<div>
<a-typography-text
strong
:style="{ fontSize: '15px', color: token.colorPrimary }"
>
{{ toman(dashboard?.receivables) }}
</a-typography-text>
</div>
</div>
<a-avatar
:size="36"
:style="{
background: token.colorPrimary,
color: token.colorTextLightSolid,
flexShrink: 0,
}"
>
<template #icon><ClockCircleOutlined /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-segmented
v-model:value="formTab"
block
:options="formTabOptions"
:style="{ background: token.colorFillTertiary }"
/>
<a-card :bordered="false">
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
<div v-show="formTab === 'main'">
<a-form-item
label="عنوان درآمد"
name="title"
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
>
<a-input
v-model:value="form.title"
placeholder="مثلاً: دریافت قسط پروژه الف"
>
<template #prefix><FileTextOutlined /></template>
</a-input>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="مشتری / طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="method">
<template #label>
<a-space :size="6">
<CreditCardOutlined />
<span>روش دریافت</span>
</a-space>
</template>
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item name="categoryId">
<template #label>
<a-space :size="6">
<TagOutlined />
<span>دسته‌بندی درآمد</span>
</a-space>
</template>
<a-flex :gap="8">
<a-select
v-model:value="form.categoryId"
allow-clear
placeholder="انتخاب دسته"
style="flex: 1; min-width: 0"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
<a-button type="primary" ghost @click="categoryModalOpen = true">
<template #icon><PlusOutlined /></template>
</a-button>
</a-flex>
</a-form-item>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item
label="مبلغ کل (تومان)"
name="amount"
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
>
<a-input
v-model:value="form.amount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
:label="`${settleLabel} (تومان)`"
name="settleAmount"
:rules="[{ required: true, message: 'مبلغ دریافت‌شده را وارد کنید' }]"
>
<a-input
v-model:value="form.settleAmount"
inputmode="decimal"
placeholder="۰"
dir="ltr"
>
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
</a-col>
</a-row>
<a-flex
align="center"
justify="space-between"
:style="{
background: token.colorSuccessBg,
borderRadius: `${token.borderRadiusLG * 2}px`,
padding: `${token.paddingSM}px ${token.padding}px`,
marginBottom: `${token.margin}px`,
}"
>
<a-space :size="8">
<span
:style="{
width: '8px',
height: '8px',
borderRadius: '50%',
background: token.colorSuccess,
display: 'inline-block',
}"
/>
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
</a-space>
<a-typography-text strong :style="{ color: token.colorSuccess }">
{{ toman(remainingAmount) }}
</a-typography-text>
</a-flex>
<a-row :gutter="[12, 0]">
<a-col :span="12">
<a-form-item label="تاریخ دریافت" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item name="accountId">
<template #label>
<a-space :size="6">
<BankOutlined />
<span>حساب مقصد</span>
</a-space>
</template>
<a-select
v-model:value="form.accountId"
allow-clear
placeholder="انتخاب حساب"
style="width: 100%"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
<div v-show="formTab === 'extra'">
<a-form-item label="شماره پیگیری" name="reference">
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
<template #prefix><NumberOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="توضیحات" name="notes">
<a-textarea
v-model:value="form.notes"
:rows="4"
placeholder="توضیحات اختیاری…"
/>
</a-form-item>
</div>
</a-form>
</a-card>
<a-card :bordered="false">
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
<div
:style="{
position: 'fixed',
left: 0,
right: 0,
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
zIndex: 90,
background: token.colorBgElevated,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: `${token.paddingSM}px ${token.padding}px`,
boxShadow: token.boxShadowSecondary,
}"
>
<a-row :gutter="12">
<a-col :span="8">
<a-button block @click="resetForm">انصراف</a-button>
</a-col>
<a-col :span="16">
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
<template #icon><CheckOutlined /></template>
ثبت دریافت
</a-button>
</a-col>
</a-row>
</div>
</template>
<!-- ─── Desktop (unchanged interaction model) ─── -->
<template v-else>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
<a-typography-text type="secondary">
ثبت سند، موجودی را عوض نمی‌کند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
</a-typography-text>
</div>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">سند جدید</a-divider>
<a-form layout="vertical" @finish="createDoc">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item :label="settleLabel" name="settleAmount">
<a-input v-model:value="form.settleAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دسته‌بندی" name="categoryId">
<a-select
v-model:value="form.categoryId"
allow-clear
style="width: 100%"
placeholder="بدون دسته"
>
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select
v-model:value="form.accountId"
allow-clear
style="width: 100%"
placeholder="انتخاب حساب"
>
<a-select-option
v-for="account in accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="روش" name="method">
<a-select v-model:value="form.method" style="width: 100%">
<a-select-option
v-for="opt in methodOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ" name="bookedAt">
<JalaliDateInput v-model="form.bookedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیگیری" name="reference">
<a-input v-model:value="form.reference" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="16">
<a-form-item label="توضیحات" name="notes">
<a-input v-model:value="form.notes" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
</a-form>
<a-divider orientation="right">فهرست اسناد</a-divider>
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو…"
@press-enter="load"
@change="load"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-empty v-if="!rows.length" description="سندی نیست." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
<a-button
v-if="item.status !== 'void'"
type="link"
danger
@click="voidDoc(item.id)"
>
ابطال
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ statusLabel[item.status] }}
· مانده {{ toman(item.remaining) }}
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</template>
<a-modal
v-model:open="categoryModalOpen"
title="دسته جدید"
ok-text="افزودن"
cancel-text="انصراف"
:confirm-loading="categorySaving"
@ok="createCategoryQuick"
>
<a-form layout="vertical">
<a-form-item label="نام دسته" required>
<a-input
v-model:value="newCategoryName"
placeholder="مثلاً: فروش خدمات"
@press-enter="createCategoryQuick"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -1,13 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type PayrollRow = {
id: string;
fullName: string;
@@ -69,6 +88,10 @@ async function payRow() {
}
}
function startPay(row: PayrollRow) {
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری حقوق ناموفق بود.';
@@ -77,59 +100,109 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را می‌بینند." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="ثبت دوره">
<div class="grid">
<UiField label="پرسنل">
<select v-model="form.userId" class="select">
<option value="">انتخاب</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</UiField>
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
</div>
<UiButton @click="createRow">ثبت حقوق</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.fullName }}</strong>
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را می‌بینند.</a-typography-text>
</div>
<div class="side">
<b>{{ toman(row.payable) }}</b>
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
</div>
</article>
<UiCard v-if="pay.id" title="پرداخت حقوق">
<div class="grid">
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
<UiField label="حساب">
<select v-model="pay.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton @click="payRow">ثبت پرداخت</UiButton>
</UiCard>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.side { display: grid; justify-items: end; }
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">ثبت دوره</a-divider>
<a-form layout="vertical" @finish="createRow">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="پرسنل" name="userId">
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
{{ user.fullName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="period">
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پایه" name="baseAmount">
<a-input v-model:value="form.baseAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مزایا" name="benefits">
<a-input v-model:value="form.benefits" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پاداش" name="bonus">
<a-input v-model:value="form.bonus" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="کسورات" name="deductions">
<a-input v-model:value="form.deductions" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
</a-form>
<template v-if="pay.id">
<a-divider orientation="right">پرداخت حقوق</a-divider>
<a-form layout="vertical" @finish="payRow">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="pay.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب" name="accountId">
<a-select v-model:value="pay.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
</a-form>
</template>
<a-divider orientation="right">فهرست</a-divider>
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
<a-button
v-if="item.status !== 'settled' && item.status !== 'void'"
type="link"
@click="startPay(item)"
>
پرداخت
</a-button>
</template>
<a-list-item-meta>
<template #title>{{ item.fullName }}</template>
<template #description>
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,14 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Row as ARow,
Select as ASelect,
Space as ASpace,
Table as ATable,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { api } from '@/lib/api';
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const ASelectOption = ASelect.Option;
type InstallmentRow = FinancialReceivableDto['installments'][number];
const rows = ref<FinancialReceivableDto[]>([]);
const accounts = ref<FinancialAccountDto[]>([]);
const error = ref('');
@@ -36,6 +59,31 @@ const statusLabel: Record<string, string> = {
void: 'باطل',
};
const installmentColumns: TableColumnsType<InstallmentRow> = [
{ title: 'قسط', dataIndex: 'position', key: 'position' },
{
title: 'سررسید',
key: 'dueDate',
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
},
{
title: 'مبلغ',
key: 'amount',
customRender: ({ record }) => toman(record.amount),
},
{
title: 'مانده',
key: 'remaining',
customRender: ({ record }) => toman(record.remaining),
},
{
title: 'وضعیت',
key: 'status',
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
},
{ title: '', key: 'actions' },
];
function apiMessage(e: unknown) {
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -112,6 +160,15 @@ async function collectInstallment() {
}
}
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
collect.value = {
installmentId: item.id,
receivableId: row.id,
amount: item.remaining,
accountId: accounts.value[0]?.id || '',
};
}
onMounted(() => {
void load().catch(() => {
error.value = 'بارگذاری مطالبات ناموفق بود.';
@@ -120,113 +177,166 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader
title="اقساط و مطالبات"
description="دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند."
/>
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="قرارداد جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
<UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
<UiField label="پیش‌پرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
<UiField label="حساب پیش‌پرداخت">
<select v-model="form.accountId" class="select">
<option value="">انتخاب</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
<div>
<a-flex vertical :gap="16">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
<a-typography-text type="secondary">
دریافت هر قسط فقط یک بار وارد حساب می‌شود و مانده همان قسط را کم می‌کند.
</a-typography-text>
</div>
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
<div v-if="custom" class="custom">
<div v-for="(line, index) in customLines" :key="index" class="grid">
<UiField label="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
</div>
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
</div>
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="contract">
<header>
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
</div>
<b>{{ toman(row.totalAmount) }}</b>
</header>
<table>
<thead>
<tr>
<th>قسط</th>
<th>سررسید</th>
<th>مبلغ</th>
<th>مانده</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="item in row.installments" :key="item.id">
<td>{{ item.position }}</td>
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
<td>{{ toman(item.amount) }}</td>
<td>{{ toman(item.remaining) }}</td>
<td>{{ statusLabel[item.status] }}</td>
<td>
<button
v-if="item.status !== 'settled'"
type="button"
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
>
دریافت
</button>
</td>
</tr>
</tbody>
</table>
</article>
<TreasuryNav />
<UiCard v-if="collect.installmentId" title="دریافت قسط">
<div class="grid">
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
<UiField label="حساب مقصد">
<select v-model="collect.accountId" class="select">
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
</UiField>
</div>
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
</UiCard>
</section>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">قرارداد جدید</a-divider>
<a-form layout="vertical" @finish="createContract">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="طرف حساب" name="partyName">
<a-input v-model:value="form.partyName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ کل" name="totalAmount">
<a-input v-model:value="form.totalAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="پیش‌پرداخت" name="prepaidAmount">
<a-input v-model:value="form.prepaidAmount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب پیش‌پرداخت" name="accountId">
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
<a-select-option value="">انتخاب</a-select-option>
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="تعداد قسط" name="count">
<a-input v-model:value="form.count" :disabled="custom" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24">
<a-form-item>
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
</a-form-item>
</a-col>
</a-row>
<template v-if="custom">
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item :label="`مبلغ قسط ${index + 1}`">
<a-input v-model:value="line.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سررسید">
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
</a-form-item>
</template>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
<a-button @click="downloadExcel">خروجی اکسل</a-button>
</a-space>
</a-form>
<template v-if="collect.installmentId">
<a-divider orientation="right">دریافت قسط</a-divider>
<a-form layout="vertical" @finish="collectInstallment">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="collect.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="حساب مقصد" name="accountId">
<a-select v-model:value="collect.accountId" style="width: 100%">
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
</a-form>
</template>
<a-divider orientation="right">قراردادها</a-divider>
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
<template v-else>
<div v-for="row in rows" :key="row.id">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
<div>
<a-typography-text strong>{{ row.title }}</a-typography-text>
<br />
<a-typography-text type="secondary">
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
</a-typography-text>
</div>
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
</a-flex>
<a-table
:columns="installmentColumns"
:data-source="row.installments"
:pagination="false"
row-key="id"
:scroll="{ x: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="record.status !== 'settled'"
type="link"
@click="startCollect(row, record as InstallmentRow)"
>
دریافت
</a-button>
</template>
</template>
</a-table>
<a-divider />
</div>
</template>
</a-card>
</a-flex>
</div>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
.contract small { display: block; color: #64748b; }
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
@@ -1,13 +1,32 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Row as ARow,
Select as ASelect,
Typography,
} from 'ant-design-vue';
import { api } from '@/lib/api';
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
import MoneyInput from '@/components/MoneyInput.vue';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
import { formatJalaliNumeric } from '@/lib/jalali';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const ASelectOption = ASelect.Option;
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
const rows = ref<Row[]>([]);
const error = ref('');
@@ -38,48 +57,80 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<UiCard title="الگوی جدید">
<div class="grid">
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
<UiField label="نوع">
<select v-model="form.kind" class="select">
<option value="expense">هزینه</option>
<option value="income">درآمد</option>
</select>
</UiField>
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
<UiField label="دوره">
<select v-model="form.interval" class="select">
<option value="monthly">ماهانه</option>
<option value="weekly">هفتگی</option>
<option value="yearly">سالانه</option>
</select>
</UiField>
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
</div>
<UiButton @click="createItem">ثبت الگو</UiButton>
</UiCard>
<article v-for="row in rows" :key="row.id" class="row">
<div>
<a-flex vertical :gap="16">
<div>
<strong>{{ row.title }}</strong>
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
<a-typography-text type="secondary">
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمی‌شود.
</a-typography-text>
</div>
<b>{{ toman(row.amount) }}</b>
</article>
</section>
</template>
<style scoped>
.page { display: grid; gap: 1rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
.row small { display: block; color: #64748b; }
.err { color: #b91c1c; font-weight: 700; }
.ok { color: #0f766e; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-alert v-if="ok" type="success" show-icon :message="ok" />
<a-card :bordered="false">
<a-divider orientation="right">الگوی جدید</a-divider>
<a-form layout="vertical" @finish="createItem">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="عنوان" name="title">
<a-input v-model:value="form.title" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="نوع" name="kind">
<a-select v-model:value="form.kind" style="width: 100%">
<a-select-option value="expense">هزینه</a-select-option>
<a-select-option value="income">درآمد</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مبلغ" name="amount">
<a-input v-model:value="form.amount" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره" name="interval">
<a-select v-model:value="form.interval" style="width: 100%">
<a-select-option value="monthly">ماهانه</a-select-option>
<a-select-option value="weekly">هفتگی</a-select-option>
<a-select-option value="yearly">سالانه</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="شروع" name="startDate">
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
</a-form>
<a-divider orientation="right">فهرست الگوها</a-divider>
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
<a-list v-else item-layout="horizontal" :data-source="rows">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.title }}</template>
<template #description>
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
· {{ item.interval }}
· بعدی {{ formatJalaliNumeric(new Date(item.nextDue)) }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-flex>
</div>
</template>
@@ -1,10 +1,26 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
List as AList,
Table as ATable,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { DownloadOutlined } from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { UiButton, UiPageHeader } from '@/components/ui';
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
import { toman } from './treasury-format';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
type Period = {
bookedIncome: string;
bookedExpense: string;
@@ -14,6 +30,14 @@ type Period = {
cashNet: string;
expenseCategories: Array<{ name: string; amount: string; share: string }>;
};
type MetricRow = {
key: string;
label: string;
current: string;
previous: string;
};
const current = ref<Period | null>(null);
const previous = ref<Period | null>(null);
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
@@ -29,6 +53,22 @@ const lines = [
['خالص جریان نقدی', 'cashNet'],
] as const;
const metricColumns: TableColumnsType<MetricRow> = [
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
];
const metricRows = computed<MetricRow[]>(() => {
if (!current.value || !previous.value) return [];
return lines.map(([label, key]) => ({
key,
label,
current: toman(current.value![key]),
previous: toman(previous.value![key]),
}));
});
async function load() {
const { data } = await api.get<{
current: Period;
@@ -60,49 +100,85 @@ onMounted(() => {
</script>
<template>
<section class="page">
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه." />
<TreasuryNav />
<p v-if="error" class="err">{{ error }}</p>
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
<table v-if="current && previous">
<thead>
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
</thead>
<tbody>
<tr v-for="line in lines" :key="line[1]">
<td>{{ line[0] }}</td>
<td>{{ toman(current[line[1]]) }}</td>
<td>{{ toman(previous[line[1]]) }}</td>
</tr>
</tbody>
</table>
<h2>مطالبات به تفکیک طرف حساب</h2>
<ul>
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
</ul>
<h2>تعهدات به تفکیک ماه</h2>
<ul>
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
</ul>
<h2 v-if="current">سهم هزینه‌ها</h2>
<ul v-if="current">
<li v-for="row in current.expenseCategories" :key="row.name">
<span>{{ row.name }}</span>
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
</li>
</ul>
</section>
</template>
<div>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
<a-typography-text type="secondary">
سود عملیاتی از درآمد و هزینه ثبت‌شده است، نه از خالص وجه.
</a-typography-text>
</div>
<a-button @click="download">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-flex>
<style scoped>
.page { display: grid; gap: 1rem; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
h2 { margin: 0; font-size: 1rem; }
.err { color: #b91c1c; font-weight: 700; }
</style>
<TreasuryNav />
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card :bordered="false">
<a-divider orientation="right">مقایسه دوره‌ها</a-divider>
<a-table
v-if="metricRows.length"
:columns="metricColumns"
:data-source="metricRows"
:pagination="false"
row-key="key"
/>
<a-empty v-else description="داده‌ای برای نمایش نیست." />
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.month }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<template v-if="current">
<a-divider orientation="right">سهم هزینه‌ها</a-divider>
<a-empty v-if="!current.expenseCategories.length" description="دسته‌ای نیست." />
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-typography-text strong>
{{ toman(item.amount) }} · {{ item.share }}٪
</a-typography-text>
</template>
<a-list-item-meta>
<template #title>{{ item.name }}</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</a-card>
</a-flex>
</div>
</template>
+233
View File
@@ -0,0 +1,233 @@
# وضعیت دیزاین‌سیستم منتکت (Montact CRM)
سند audit وضعیت واقعی لایهٔ UI در `apps/web` — نه آرزو، نه Figma.
آخرین به‌روزرسانی: ۲۶ مهر ۱۴۰۵
**راهنما:**
- **آماده** = در کد وجود دارد و به‌طور منسجم قابل اتکا است
- **نیمه‌آماده** = وجود دارد ولی پوشش/انضباط ناقص است
- **نیست** = در کدبیس پیاده نشده
---
## خلاصهٔ اجرایی
دیزاین‌سیستم منتکت **نیمه‌آماده / در حال رشد** است: توکن‌های CSS، کلاس‌های الگوی `crm-*`، و یک کیت داخلی ۱۸تایی `Ui*` وجود دارد؛ اما API دوگانهٔ لگسی (`.btn` / `.field`)، hardcode گستردهٔ رنگ و فاصله در scoped CSS، نبود Storybook، و نبود چند primitive پرکاربرد مانع «سیستم واقعی» است.
| معیار | مقدار |
|---|---|
| امتیاز کلی آمادگی | **حدود ۵۰٪** (بازهٔ واقع‌بینانه ۴۵–۵۵٪) |
| استک UI | Vue 3 SFC + Composition API — بدون Tailwind / UI kit شخص‌ثالث |
| primitives `Ui*` | ۱۸ کامپوننت + barrel |
| adoption مستقیم در views | **۵۲ از ۷۶** (~۶۸٪) |
| تم تاریک / چندتم | نیست |
| کاتالوگ زنده (Storybook و مشابه) | نیست |
**حکم یک‌خطی:** زبان بصری teal + RTL برای CRM شکل گرفته؛ انضباط و پوشش کامپوننت هنوز به سطح design system کامل نرسیده.
---
## استک و مرز سیستم
```
tokens (main.css :root)
↓
utilities / patterns (tokens.css → .crm-*)
↓
primitives (components/ui/Ui*)
↓
domain composites (pipeline/, gamification/, nav/, …)
↓
views + layouts
```
| لایه | مسیر | نقش |
|---|---|---|
| تعریف توکن‌ها | [`apps/web/src/styles/main.css`](../apps/web/src/styles/main.css) | `:root` — رنگ، تایپو، فاصله، سایه، z-index، کنترل‌ها؛ همچنین استایل لگسی `.btn` / `.field` |
| الگوهای CRM | [`apps/web/src/styles/tokens.css`](../apps/web/src/styles/tokens.css) | utilityهای `.crm-page`, `.crm-card`, `.crm-toolbar`, … (نام فایل «tokens» است؛ تعریف توکن اینجا نیست) |
| فونت | [`apps/web/src/styles/fonts.css`](../apps/web/src/styles/fonts.css) | IRANSans FaNum خودمیزبان |
| کیت UI | [`apps/web/src/components/ui/`](../apps/web/src/components/ui/) | ۱۸ SFC + [`index.ts`](../apps/web/src/components/ui/index.ts) |
| آیکن | [`apps/web/src/components/UiIcon.vue`](../apps/web/src/components/UiIcon.vue) | بیرون از barrel `ui/` |
| شِل اپ | [`layouts/AppLayout.vue`](../apps/web/src/layouts/AppLayout.vue)، [`layouts/AdmLayout.vue`](../apps/web/src/layouts/AdmLayout.vue) | سایدبار + workspace / ادمین پلتفرم |
**وابستگی‌های UI در `package.json` وب:** فقط Vue، Vue Router، Pinia، Axios، ECharts / vue-echarts، xlsx. هیچ Element Plus / Vuetify / Naive / Headless / Tailwind نیست.
---
## ماتریس آمادگی
| لایه | وضعیت | توضیح کوتاه |
|---|---|---|
| توکن رنگ / تایپو / فاصله / سایه | **آماده** | پالت teal روی slate؛ scale فونت و spacing و shadow تعریف شده |
| مقیاس radius | **نیمه‌آماده** | همهٔ `--radius-*` (جز full) روی ۱۰px قفل‌اند — سلسله‌مراتب واقعی نیست |
| spacing کامل | **نیمه‌آماده** | `--sp-1`…`6` و `8` و `10`؛ `--sp-7` / `--sp-9` نیست |
| الگوی صفحه `crm-*` | **نیمه‌آماده** | مجموعهٔ خوب برای page/header/card/table/form؛ adoption ناقص |
| primitives `Ui*` | **نیمه‌آماده** | ۱۸ قطعهٔ کاربردی؛ چند کنترل پایهٔ فرم/فیدبک غایب |
| آیکن `UiIcon` | **آماده** | ~۷۰ آیکن stroke، `currentColor`، بدون پکیج خارجی |
| Layout اپ / ادمین | **آماده** | شِل دسکتاپ + موبایل tabbar؛ ادم جدا |
| RTL + IRANSans + جلالی | **آماده** | `dir="rtl"`، فونت فارسی، `lib/jalali.ts`، ارقام فارسی |
| Dark mode / multi-theme | **نیست** | فقط تم روشن |
| Storybook / Histoire / playground | **نیست** | اسکریپت lint وب هم placeholder است |
| Checkbox / Switch / Tabs / Toast / Tooltip / Table / DatePicker به‌عنوان `Ui*` | **نیست** | معادل‌ها اغلب ad-hoc یا دامنهٔ خاص (`JalaliDateInput` و …) |
| vue-i18n | **نیست** | متن‌ها فارسی hardcode در template |
| lint / اجبار توکن (منع hex خام) | **نیست** | رنگ و rem زیاد در scoped CSS تکرار شده |
---
## توکن‌ها (آنچه واقعاً هست)
تعریف در `:root` داخل `main.css`:
| دسته | نمونه |
|---|---|
| سطح | `--bg`, `--surface`, `--surface-subtle`, `--surface-hover` |
| متن / خط | `--ink`, `--ink-secondary`, `--muted`, `--line`, … |
| accent | `--accent` (#14b8a6) و hover/deep/soft/subtle/contrast |
| معنایی | `--danger`, `--success`, `--warning`, `--info` (+ soft/deep) |
| تایپو | `--fs-xs`…`--fs-3xl`, `--fw-*`, `--lh-*`, `--font` |
| فاصله | `--sp-1`…`--sp-6`, `--sp-8`, `--sp-10` |
| کنترل | `--control-h-sm/h/md/lg`, `--pad-modal` |
| سایه / حرکت / z | `--shadow-*`, `--transition-*`, `--z-dropdown`…`--z-toast` |
| alias لگسی | `--color-primary`, `--color-text`, `--color-border`, … |
**ضعف‌ها:** radius تخت؛ `--fw-extrabold` برابر bold؛ توکن‌های محلی در `AppLayout` (مثل `--sidebar-card`) بیرون از `:root` سراسری؛ `CHART_PALETTE` در `UiChart` تا حدی جدا از توکن‌ها.
---
## کاتالوگ کامپوننت‌های `Ui*`
منبع: barrel [`components/ui/index.ts`](../apps/web/src/components/ui/index.ts).
| کامپوننت | نقش |
|---|---|
| `UiButton` | دکمه — variant / size / loading / block |
| `UiInput` | ورودی متن — `defineModel`، prefix/suffix |
| `UiTextarea` | متن چندخطی |
| `UiSelect` | select بومی با slot برای `<option>` |
| `UiField` | برچسب + hint + error دور کنترل |
| `UiCard` | کارت با header / actions / footer |
| `UiModal` | مودال + قفل اسکرول body |
| `UiDrawer` | کشو از کنار |
| `UiBusyOverlay` | پوشش busy / progress |
| `UiPageHeader` | عنوان صفحه + lead + actions + back |
| `UiBadge` | نشان وضعیت |
| `UiPagination` | صفحه‌بندی + page size |
| `UiEmptyState` | حالت خالی |
| `UiSearch` | جستجوی ساده |
| `UiFilterBar` | نوار جستجو + پنل فیلتر |
| `UiSegmented` | کنترل segmented |
| `UiChart` | ECharts + `CHART_PALETTE` |
| `UiDropdownMenu` | منوی کشویی با trigger slot |
**مرتبط ولی خارج از barrel:** `UiIcon.vue`.
**domain composites (دیزاین‌سیستم نیستند):** پوشه‌هایی مثل `pipeline/`, `gamification/`, `dashboard/`, `routines/`, `nav/`, `person-profile/` — ترکیب ویژگی‌محور روی primitives یا استایل محلی.
---
## پترن‌های UI واقعی در پروژه
### ۱) الگوی ترجیحی (هدف فعلی)
```ts
import { UiButton, UiModal, UiPageHeader } from '@/components/ui';
```
صفحه با `.crm-page` + `UiPageHeader` / `UiFilterBar` / `UiCard` / جدول `.crm-table*`.
### ۲) الگوی موازی / لگسی
- کلاس‌های سراسری `.btn`, `.btn-ghost`, `.field` در `main.css`
- `<button>` / `<input>` خام + scoped CSS در viewهای قدیمی‌تر یا خاص (Login، Notes، adm، …)
- import مستقیم فایل به‌جای barrel در بعضی viewها (`import UiButton from '@/components/ui/UiButton.vue'`)
### ۳) الگوی غیرمستقیم
بعضی viewها خودشان `ui/` را import نمی‌کنند ولی childها می‌کنند (مثلاً `HomeView` → `components/dashboard/*` → `UiChart` / `UiButton`).
### لایه‌بندی مفهومی
```mermaid
flowchart TB
tokens[CSS_tokens_main_css]
utils[crm_utilities_tokens_css]
ui[Ui_primitives]
domain[Domain_composites]
views[Views_and_Layouts]
legacy[Legacy_btn_field]
tokens --> utils
tokens --> ui
utils --> views
ui --> domain
ui --> views
domain --> views
legacy --> views
```
---
## Adoption کمی (views)
| متریک | عدد |
|---|---:|
| کل viewهای `apps/web/src/views` | ۷۶ |
| view با import مستقیم از `components/ui` | ۵۲ (~۶۸٪) |
| view بدون import مستقیم | ۲۴ (~۳۲٪) |
### بدون import مستقیم `ui/` (ممکن است از child یا لگسی استفاده کنند)
- `ActivitiesCalendarView`, `ActivityDetailView`
- `adm/AdmAdminsView`, `AdmBillingPanel`, `AdmDashboardView`, `AdmLoginView`, `AdmOrganizationsView`, `AdmUsersView`
- `AttachmentsView`, `ConversationsView`
- `gamification/GamificationView`, `GamificationAdminView`
- `HomeView`, `InvoiceFormView`, `LeadFormPublicView`
- `LoginView`, `NotesView`, `PersonProfileView`, `PortalView`, `ProfileView`
- `ProjectCardFormView`, `QuoteFormView`, `QuotesView`, `RegisterView`
**نکتهٔ کیفیت:** حتی viewهایی که `Ui*` دارند اغلب هنوز hex/`rem` محلی در `<style scoped>` دارند؛ adoption کامپوننت ≠ انضباط توکن.
---
## ساختار پوشهٔ `components/` (خلاصه)
```
components/
├── ui/ ← primitives + index.ts (تنها barrel)
├── UiIcon.vue ← آیکن مشترک
├── activities|auth|automations|brand|dashboard|
│ gamification|nav|person-*|pipeline|routines|…
└── *.vue ← ورودی‌های متقاطع (JalaliDateInput, MoneyInput, …)
```
الگوی غالب: **Composition API** با `<script setup lang="ts">`؛ استایل scoped روی کامپوننت + مصرف `var(--…)`.
---
## شکاف‌ها و اولویت پیشنهادی
ترتیب پیشنهادی برای نزدیک‌شدن به design system واقعی (فقط نقشه — خارج از scope این سند):
1. **یک API برای دکمه/فرم** — مهاجرت تدریجی از `.btn` / `.field` به `UiButton` / `UiField` + حذف یا thin-wrapper کردن لگسی
2. **تکمیل primitives پرکاربرد** — حداقل Toast، Tabs، Checkbox/Switch، Table (یا wrapper روی الگوی `.crm-table`)
3. **اجبار توکن** — جایگزینی تدریجی hex تکراری با `var(--…)`؛ در حد ممکن rule/lint
4. **کاتالوگ زنده** — Storybook / Histoire یا یک صفحهٔ داخلی فقط برای `Ui*`
5. **تم** — فقط اگر محصول dark/multi-brand می‌خواهد؛ فعلاً پیش‌نیاز انضباط توکن است
---
## چک‌لیست «آیا design system هستیم؟»
| قابلیت | وضعیت |
|---|---|
| توکن‌های مشترک | بله |
| لایهٔ الگوی صفحه | بله (ناقص) |
| کیت primitive داخلی | بله (ناقص) |
| آیکن یکپارچه | بله |
| شِل و RTL فارسی | بله |
| پوشش اکثر صفحات | نسبتاً بله (~۶۸٪ مستقیم) |
| یک منبع حقیقت بدون لگسی موازی | خیر |
| مستند/کاتالوگ زنده | خیر |
| تم / i18n / lint توکن | خیر |
**جمع‌بندی:** پروژه یک **design language + UI kit در حال رشد** دارد، نه یک design system کامل و منضبط. برای کار روزمرهٔ فیچر کافی است؛ برای اسکیل بدون drift بصری، اولویت‌های ۱–۴ بالا ضروری‌اند.
)
+247 -3
View File
@@ -60,7 +60,9 @@
"name": "@mento/web",
"version": "0.1.0",
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@mento/shared": "*",
"ant-design-vue": "^4.2.6",
"axios": "^1.8.4",
"echarts": "^6.1.0",
"pinia": "^3.0.1",
@@ -184,6 +186,34 @@
"tslib": "^2.1.0"
}
},
"node_modules/@ant-design/colors": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.0"
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz",
"integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
"license": "MIT"
},
"node_modules/@ant-design/icons-vue": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
"integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-svg": "^4.2.1"
},
"peerDependencies": {
"vue": ">=3.0.3"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"dev": true,
@@ -224,6 +254,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"license": "MIT",
@@ -252,6 +291,27 @@
"node": ">=0.1.90"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"cpu": [
@@ -263,7 +323,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -279,7 +338,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1070,6 +1128,16 @@
"win32"
]
},
"node_modules/@simonwep/pickr": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
"integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
"license": "MIT",
"dependencies": {
"core-js": "^3.15.1",
"nanopop": "^2.1.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"devOptional": true,
@@ -1739,6 +1807,46 @@
"node": ">=14"
}
},
"node_modules/ant-design-vue": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
"integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-vue": "^7.0.0",
"@babel/runtime": "^7.10.5",
"@ctrl/tinycolor": "^3.5.0",
"@emotion/hash": "^0.9.0",
"@emotion/unitless": "^0.8.0",
"@simonwep/pickr": "~1.8.0",
"array-tree-filter": "^2.1.0",
"async-validator": "^4.0.0",
"csstype": "^3.1.1",
"dayjs": "^1.10.5",
"dom-align": "^1.12.1",
"dom-scroll-into-view": "^2.0.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.15",
"resize-observer-polyfill": "^1.5.1",
"scroll-into-view-if-needed": "^2.2.25",
"shallow-equal": "^1.0.0",
"stylis": "^4.1.3",
"throttle-debounce": "^5.0.0",
"vue-types": "^3.0.0",
"warning": "^4.0.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ant-design-vue"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"license": "MIT"
@@ -1830,10 +1938,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"license": "MIT"
@@ -2355,6 +2475,12 @@
"node": ">= 10"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"license": "MIT"
@@ -2427,6 +2553,20 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"license": "MIT"
@@ -2586,6 +2726,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dom-scroll-into-view": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.4.1",
"license": "BSD-2-Clause",
@@ -3530,6 +3682,15 @@
"node": ">=8"
}
},
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"license": "MIT"
@@ -3593,7 +3754,6 @@
},
"node_modules/js-tokens": {
"version": "4.0.0",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -3812,6 +3972,12 @@
"version": "4.18.1",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"license": "MIT"
@@ -3899,6 +4065,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.5.2",
"dev": true,
@@ -4119,6 +4297,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.1.0",
"license": "MIT",
@@ -4685,6 +4869,12 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -4896,6 +5086,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"license": "MIT",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"license": "ISC",
@@ -4955,6 +5154,12 @@
"version": "1.2.0",
"license": "ISC"
},
"node_modules/shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.1",
"license": "MIT",
@@ -5147,6 +5352,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/stylis": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.6",
"license": "MIT",
@@ -5317,6 +5528,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
"license": "MIT",
"engines": {
"node": ">=12.22"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"devOptional": true,
@@ -5820,6 +6040,30 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
"integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
"license": "MIT",
"dependencies": {
"is-plain-object": "3.0.1"
},
"engines": {
"node": ">=10.15.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": {
"version": "2.5.2",
"dev": true,