feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 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\"" "lint": "echo \"web lint placeholder\""
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@mento/shared": "*", "@mento/shared": "*",
"ant-design-vue": "^4.2.6",
"axios": "^1.8.4", "axios": "^1.8.4",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
+11 -1
View File
@@ -1,7 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'; 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> </script>
<template> <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> </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"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; 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 { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiField, UiInput, UiSelect } from '@/components/ui';
import type { import type {
CustomFieldDefinitionDto, CustomFieldDefinitionDto,
CustomFieldEntity, CustomFieldEntity,
} from '@mento/shared'; } from '@mento/shared';
const { Title: ATypographyTitle } = Typography;
const props = defineProps<{ const props = defineProps<{
entityType: CustomFieldEntity; entityType: CustomFieldEntity;
modelValue: Record<string, unknown>; modelValue: Record<string, unknown>;
@@ -46,6 +58,10 @@ function displayValue(key: string) {
return String(v); return String(v);
} }
function selectOptions(field: CustomFieldDefinitionDto) {
return (field.options ?? []).map((opt) => ({ value: opt, label: opt }));
}
watch( watch(
() => props.entityType, () => props.entityType,
() => { () => {
@@ -57,110 +73,61 @@ onMounted(load);
</script> </script>
<template> <template>
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div> <a-spin v-if="loading" />
<div v-else-if="fields.length" class="custom-block"> <template v-else-if="fields.length">
<h3 class="custom-title">فیلدهای سفارشی</h3> <a-typography-title :level="5" :style="{ marginTop: '16px' }">
<div class="crm-form-grid-2"> فیلدهای سفارشی
<UiField </a-typography-title>
v-for="field in fields" <a-row :gutter="[16, 0]">
:key="field.id" <a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
:label="field.label" <a-form-item :label="field.label" :required="field.required">
:required="field.required" <a-select
:for-id="`cf-${field.key}`" v-if="field.fieldType === 'select'"
>
<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
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
type="checkbox" allow-clear
:checked="Boolean(props.modelValue[field.key])" :value="String(displayValue(field.key)) || undefined"
@change=" :options="selectOptions(field)"
setValue(field.key, ($event.target as HTMLInputElement).checked) placeholder="انتخاب کنید"
" style="width: 100%"
@update:value="setValue(field.key, $event ?? '')"
/> />
<span>فعال</span>
</label>
<UiInput <a-checkbox
v-else-if="field.fieldType === 'number'" v-else-if="field.fieldType === 'boolean'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
type="number" :checked="Boolean(props.modelValue[field.key])"
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''" @update:checked="setValue(field.key, $event)"
@update:model-value=" >
setValue( فعال
field.key, </a-checkbox>
$event === '' || $event == null ? null : Number($event),
)
"
/>
<JalaliDateInput <a-input-number
v-else-if="field.fieldType === 'date'" v-else-if="field.fieldType === 'number'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
:model-value="String(displayValue(field.key))" :value="
@update:model-value="setValue(field.key, $event || null)" 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 <JalaliDateInput
v-else v-else-if="field.fieldType === 'date'"
:id="`cf-${field.key}`" :id="`cf-${field.key}`"
type="text" :model-value="String(displayValue(field.key))"
:model-value="String(displayValue(field.key))" @update:model-value="setValue(field.key, $event || null)"
@update:model-value="setValue(field.key, $event)" />
/>
</UiField> <a-input
</div> v-else
</div> :id="`cf-${field.key}`"
:value="String(displayValue(field.key))"
@update:value="setValue(field.key, $event)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</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"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; 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 { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali'; import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
@@ -11,6 +29,9 @@ import type {
PaginatedResponse, PaginatedResponse,
} from '@mento/shared'; } from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const props = defineProps<{ const props = defineProps<{
entityType: 'person' | 'company' | 'deal'; entityType: 'person' | 'company' | 'deal';
entityId: string; entityId: string;
@@ -187,210 +208,102 @@ onMounted(load);
</script> </script>
<template> <template>
<section class="timeline"> <a-card :bordered="false" :style="{ marginTop: '16px' }">
<header class="head"> <a-typography-title :level="5" :style="{ margin: 0 }">تایم‌لاین</a-typography-title>
<h3>تایم‌لاین</h3> <a-typography-text type="secondary">
<p class="muted">یادداشت‌ها و فعالیت‌های مرتبط با این پرونده</p> یادداشت‌ها و فعالیت‌های مرتبط با این پرونده
</header> </a-typography-text>
<div class="composers"> <a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
<div class="composer"> <a-textarea
<textarea v-model:value="noteDraft"
v-model="noteDraft" :rows="2"
rows="2" placeholder="یادداشت سریع…"
placeholder="یادداشت سریع…" @keydown.ctrl.enter.prevent="addNote"
@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"> <a-button
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" /> type="primary"
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote"> :disabled="saving || !noteDraft.trim()"
ثبت یادداشت :loading="saving"
</button> @click="addNote"
</div> >
</div> ثبت یادداشت
<div class="composer"> </a-button>
<input </a-flex>
v-model="activityDraft"
type="text" <a-space-compact block style="width: 100%">
<a-input
v-model:value="activityDraft"
placeholder="عنوان فعالیت / پیگیری…" placeholder="عنوان فعالیت / پیگیری…"
@keydown.enter.prevent="addActivity" style="width: 100%"
@pressEnter="addActivity"
/> />
<button <a-button
class="btn btn-ghost"
type="button"
:disabled="saving || !activityDraft.trim()" :disabled="saving || !activityDraft.trim()"
@click="addActivity" @click="addActivity"
> >
افزودن فعالیت افزودن فعالیت
</button> </a-button>
</div> </a-space-compact>
</div> </a-flex>
<p v-if="error" class="error">{{ error }}</p> <a-alert
<p v-if="loading" class="muted">در حال بارگذاری…</p> v-if="error"
<ul v-else-if="items.length" class="list"> type="error"
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]"> show-icon
<div class="item-main"> :message="error"
<span class="badge">{{ item.meta }}</span> :style="{ marginTop: '12px' }"
<strong>{{ item.title }}</strong> />
<p v-if="item.body" class="body">{{ item.body }}</p>
<time>{{ formatTime(item.at) }}</time> <a-spin :spinning="loading">
</div> <a-list
<div class="item-actions"> v-if="items.length"
<RouterLink :to="item.href">باز کردن</RouterLink> item-layout="horizontal"
<button :data-source="items"
v-if="item.kind === 'activity' && !item.done" :style="{ marginTop: '16px' }"
class="linkish" >
type="button" <template #renderItem="{ item }">
@click="completeActivity(item)" <a-list-item>
> <a-list-item-meta>
انجام شد <template #title>
</button> <a-space :size="8">
</div> <a-tag :bordered="false">{{ item.meta }}</a-tag>
</li> <span>{{ item.title }}</span>
</ul> </a-space>
<p v-else class="muted">هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p> </template>
</section> <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> </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, readMappedRowsClient,
type ClientExcelReadOptions, type ClientExcelReadOptions,
} from '@/lib/excel-client'; } 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<{ const props = defineProps<{
previewPath: string; previewPath: string;
@@ -48,7 +61,6 @@ const emit = defineEmits<{
settled: []; settled: [];
}>(); }>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null); const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null); const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({}); const mapping = reactive<Record<string, string>>({});
@@ -77,11 +89,6 @@ const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null, phase.value === 'import' ? progressPct.value : null,
); );
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() { function reset() {
pending.value = null; pending.value = null;
preview.value = null; preview.value = null;
@@ -367,11 +374,13 @@ function apiMessage(e: unknown, fallback: string) {
return `${fallback} (کد ${status})`; return `${fallback} (کد ${status})`;
} }
async function onFile(ev: Event) { const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const el = ev.target as HTMLInputElement; void handleExcelFile(file as File);
const file = el.files?.[0]; return false;
el.value = ''; };
if (!file) return;
async function handleExcelFile(file: File) {
if (props.disabled || submitting.value) return;
emit('start'); emit('start');
submitting.value = true; submitting.value = true;
phase.value = 'preview'; phase.value = 'preview';
@@ -413,7 +422,6 @@ async function onFile(ev: Event) {
} }
phase.value = 'idle'; phase.value = 'idle';
submitting.value = false; submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled'); emit('settled');
} catch (e) { } catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود')); emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
@@ -443,126 +451,85 @@ async function confirmMapping() {
</script> </script>
<template> <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" /> <slot :loading="submitting" :disabled="disabled || submitting" />
<input </a-upload>
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
<UiBusyOverlay <a-modal
:open="busyOpen" :open="busyOpen"
:message="busyMessage" :footer="null"
:progress="busyProgress" :closable="false"
nest :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"> <a-modal
<p class="lead"> :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> </a-typography-paragraph>
<p v-if="preview?.largeFile" class="large-hint"> <a-typography-paragraph v-if="preview?.largeFile" type="secondary">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود. فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p> </a-typography-paragraph>
<div class="rows"> <a-flex vertical :gap="12">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row"> <a-row
<div class="source"> v-for="col in preview?.columns ?? []"
<strong>{{ col.header }}</strong> :key="col.header"
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small> :gutter="[16, 8]"
</div> align="middle"
<UiSelect >
:model-value="mapping[col.header] ?? ''" <a-col :xs="24" :md="12">
:disabled="submitting" <a-typography-text strong>{{ col.header }}</a-typography-text>
@update:model-value="setColumnField(col.header, $event)" <div v-if="col.samples.length">
> <a-typography-text type="secondary">
<option value="">نادیده بگیر</option> نمونه: {{ col.samples.join('، ') }}
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key"> </a-typography-text>
{{ field.label }}{{ field.required ? ' *' : '' }} </div>
</option> </a-col>
</UiSelect> <a-col :xs="24" :md="12">
</div> <a-select
</div> :value="mapping[col.header] ?? ''"
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p> :disabled="submitting"
<template #footer> style="width: 100%"
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton> :options="[
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping"> { value: '', label: 'نادیده بگیر' },
ورود فایل ...(preview?.fields ?? []).map((field) => ({
</UiButton> value: field.key,
</template> label: field.required ? `${field.label} *` : field.label,
</UiModal> })),
]"
@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> </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"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; 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 { import {
addJalaliMonths, addJalaliMonths,
dateToJalali, dateToJalali,
@@ -12,7 +19,6 @@ import {
parseLocalInput, parseLocalInput,
toFaDigits, toFaDigits,
} from '@/lib/jalali'; } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const model = defineModel<string>({ default: '' }); const model = defineModel<string>({ default: '' });
const props = withDefaults( const props = withDefaults(
@@ -39,7 +45,7 @@ const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
const MOBILE_MQ = '(max-width: 720px)'; const MOBILE_MQ = '(max-width: 720px)';
const open = ref(false); const open = ref(false);
const triggerRef = ref<HTMLButtonElement | null>(null); const triggerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null); const panelRef = ref<HTMLElement | null>(null);
const clockRef = ref<HTMLElement | null>(null); const clockRef = ref<HTMLElement | null>(null);
const hourColRef = ref<HTMLElement | null>(null); const hourColRef = ref<HTMLElement | null>(null);
@@ -439,41 +445,28 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="wrap" :class="{ disabled, 'time-only': mode === 'time' }"> <div
<button ref="triggerRef"
class="wrap"
:class="{ disabled, 'time-only': mode === 'time' }"
style="width: 100%"
>
<a-input
:id="id" :id="id"
ref="triggerRef" readonly
class="trigger" :value="display"
:class="{ 'clock-trigger': mode === 'time' }" :placeholder="emptyLabel"
type="button"
:disabled="disabled" :disabled="disabled"
:aria-expanded="open" :aria-expanded="open"
:aria-required="required || undefined" :aria-required="required || undefined"
style="width: 100%; cursor: pointer"
@click="toggle" @click="toggle"
> >
<template v-if="mode === 'time'"> <template #suffix>
<span class="clock-face" aria-hidden="true"> <ClockCircleOutlined v-if="triggerIcon === 'clock'" />
<i <CalendarOutlined v-else />
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> </template>
<template v-else> </a-input>
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
<UiIcon :name="triggerIcon" :size="18" />
</template>
</button>
<Teleport to="body"> <Teleport to="body">
<div <div
@@ -490,13 +483,13 @@ onBeforeUnmount(() => {
:style="{ top: `${top}px`, left: `${left}px` }" :style="{ top: `${top}px`, left: `${left}px` }"
> >
<header v-if="mode !== 'time'" class="nav"> <header v-if="mode !== 'time'" class="nav">
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)"> <a-button type="text" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
<UiIcon name="chevron" :size="16" /> <template #icon><RightOutlined /></template>
</button> </a-button>
<strong>{{ monthTitle }}</strong> <strong>{{ monthTitle }}</strong>
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)"> <a-button type="text" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
<UiIcon name="chevron" :size="16" /> <template #icon><LeftOutlined /></template>
</button> </a-button>
</header> </header>
<template v-if="mode !== 'time'"> <template v-if="mode !== 'time'">
@@ -504,16 +497,16 @@ onBeforeUnmount(() => {
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span> <span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
</div> </div>
<div class="grid"> <div class="grid">
<button <a-button
v-for="cell in cells" v-for="cell in cells"
:key="cell.key" :key="cell.key"
type="button" type="text"
class="day" class="day"
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }" :class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
@click="pickDay(cell)" @click="pickDay(cell)"
> >
{{ toFaDigits(cell.jd) }} {{ toFaDigits(cell.jd) }}
</button> </a-button>
</div> </div>
</template> </template>
@@ -521,25 +514,25 @@ onBeforeUnmount(() => {
<template v-if="isMobile"> <template v-if="isMobile">
<p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p> <p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p>
<div class="md-digits" dir="ltr"> <div class="md-digits" dir="ltr">
<button <a-button
type="button" type="text"
class="md-chip" class="md-chip"
:class="{ on: clockFace === 'hour' }" :class="{ on: clockFace === 'hour' }"
aria-label="ساعت" aria-label="ساعت"
@click="clockFace = 'hour'" @click="clockFace = 'hour'"
> >
{{ faPad(draftHour) }} {{ faPad(draftHour) }}
</button> </a-button>
<span class="md-colon">:</span> <span class="md-colon">:</span>
<button <a-button
type="button" type="text"
class="md-chip" class="md-chip"
:class="{ on: clockFace === 'minute' }" :class="{ on: clockFace === 'minute' }"
aria-label="دقیقه" aria-label="دقیقه"
@click="clockFace = 'minute'" @click="clockFace = 'minute'"
> >
{{ faPad(draftMinute) }} {{ faPad(draftMinute) }}
</button> </a-button>
</div> </div>
<div <div
ref="clockRef" ref="clockRef"
@@ -561,10 +554,10 @@ onBeforeUnmount(() => {
</span> </span>
<i class="md-hub" /> <i class="md-hub" />
<template v-if="clockFace === 'hour'"> <template v-if="clockFace === 'hour'">
<button <a-button
v-for="h in hours" v-for="h in hours"
:key="`h-${h}`" :key="`h-${h}`"
type="button" type="text"
class="md-tick" class="md-tick"
:class="{ on: draftHour === h, inner: h >= 12 }" :class="{ on: draftHour === h, inner: h >= 12 }"
:style="hourDialStyle(h)" :style="hourDialStyle(h)"
@@ -572,13 +565,13 @@ onBeforeUnmount(() => {
@click.stop="setHour(h)" @click.stop="setHour(h)"
> >
{{ toFaDigits(h) }} {{ toFaDigits(h) }}
</button> </a-button>
</template> </template>
<template v-else> <template v-else>
<button <a-button
v-for="m in clockMinutes" v-for="m in clockMinutes"
:key="`m-${m}`" :key="`m-${m}`"
type="button" type="text"
class="md-tick" class="md-tick"
:class="{ on: draftMinute === m }" :class="{ on: draftMinute === m }"
:style="minuteDialStyle(m)" :style="minuteDialStyle(m)"
@@ -586,7 +579,7 @@ onBeforeUnmount(() => {
@click.stop="setMinute(m)" @click.stop="setMinute(m)"
> >
{{ faPad(m) }} {{ faPad(m) }}
</button> </a-button>
</template> </template>
</div> </div>
</template> </template>
@@ -596,31 +589,31 @@ onBeforeUnmount(() => {
<div class="col"> <div class="col">
<span class="col-label">ساعت</span> <span class="col-label">ساعت</span>
<div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت"> <div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت">
<button <a-button
v-for="h in hours" v-for="h in hours"
:key="`col-h-${h}`" :key="`col-h-${h}`"
type="button" type="text"
class="col-item" class="col-item"
:class="{ on: draftHour === h }" :class="{ on: draftHour === h }"
@click="setHour(h)" @click="setHour(h)"
> >
{{ faPad(h) }} {{ faPad(h) }}
</button> </a-button>
</div> </div>
</div> </div>
<div class="col"> <div class="col">
<span class="col-label">دقیقه</span> <span class="col-label">دقیقه</span>
<div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه"> <div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه">
<button <a-button
v-for="m in columnMinutes" v-for="m in columnMinutes"
:key="`col-m-${m}`" :key="`col-m-${m}`"
type="button" type="text"
class="col-item" class="col-item"
:class="{ on: draftMinute === m }" :class="{ on: draftMinute === m }"
@click="setMinute(m)" @click="setMinute(m)"
> >
{{ faPad(m) }} {{ faPad(m) }}
</button> </a-button>
</div> </div>
</div> </div>
</div> </div>
@@ -628,18 +621,17 @@ onBeforeUnmount(() => {
</div> </div>
<footer class="foot"> <footer class="foot">
<button type="button" class="link" @click="pickToday"> <a-button type="link" @click="pickToday">
{{ mode === 'time' ? 'الان' : 'امروز' }} {{ mode === 'time' ? 'الان' : 'امروز' }}
</button> </a-button>
<button <a-button
v-if="clearable && !required && model" v-if="clearable && !required && model"
type="button" type="link"
class="link"
@click="clear" @click="clear"
> >
پاک کردن پاک کردن
</button> </a-button>
<button type="button" class="done" @click="hide">تأیید</button> <a-button type="primary" @click="hide">تأیید</a-button>
</footer> </footer>
</div> </div>
</Teleport> </Teleport>
+95 -150
View File
@@ -1,10 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; 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 { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali'; 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<{ const props = defineProps<{
personId: string; personId: string;
}>(); }>();
@@ -86,15 +103,24 @@ async function saveEdit(id: string) {
} }
} }
async function removeItem(id: string) { function removeItem(id: string) {
if (!confirm('این مورد از حافظه حذف شود؟')) return; Modal.confirm({
try { title: 'حذف از حافظه',
await api.delete(`/memory/${id}`); content: 'این مورد از حافظه حذف شود؟',
if (editingId.value === id) cancelEdit(); okText: 'حذف',
await load(); cancelText: 'انصراف',
} catch { okButtonProps: { danger: true },
error.value = 'حذف ناموفق بود'; 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) { function formatWhen(value: string) {
@@ -110,152 +136,71 @@ onMounted(load);
</script> </script>
<template> <template>
<section class="memory-panel"> <a-card :bordered="false" :style="{ marginTop: '16px' }">
<header> <a-typography-text type="secondary" strong>{{ brand.memoryEn }}</a-typography-text>
<div> <a-typography-title :level="5" :style="{ margin: '4px 0' }">حافظه مشتری</a-typography-title>
<p class="eyebrow">{{ brand.memoryEn }}</p> <a-typography-paragraph type="secondary" :style="{ marginBottom: '12px' }">
<h2>حافظه مشتری</h2> نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره می‌شود.</p> </a-typography-paragraph>
</div>
</header>
<form class="add-form" @submit.prevent="addItem"> <a-flex :gap="8" align="flex-start" :style="{ marginBottom: '12px' }">
<textarea <a-textarea
v-model="draft" v-model:value="draft"
rows="2" :rows="2"
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبح‌ها" 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> </a-button>
</form> </a-flex>
<p v-if="error" class="error">{{ error }}</p> <a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
<ul v-else class="items"> <a-spin :spinning="loading">
<li v-for="item in items" :key="item.id"> <a-typography-text v-if="!loading && !hasItems" type="secondary">
<template v-if="editingId === item.id"> هنوز موردی در حافظه ثبت نشده.
<textarea v-model="editContent" rows="2" /> </a-typography-text>
<div class="row-actions">
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)"> <a-list v-else-if="hasItems" item-layout="vertical" :data-source="items">
ذخیره <template #renderItem="{ item }">
</button> <a-list-item>
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button> <template v-if="editingId === item.id">
</div> <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>
<template v-else> </a-list>
<p class="content">{{ item.content }}</p> </a-spin>
<p class="meta"> </a-card>
<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>
</template> </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"> <script setup lang="ts">
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch } from 'vue';
import { Input as AInput } from 'ant-design-vue';
import { import {
caretIndexForDigitCount, caretIndexForDigitCount,
countDigitChars, countDigitChars,
@@ -34,7 +35,14 @@ const props = withDefaults(
const text = ref(''); const text = ref('');
const focused = ref(false); 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 { function readModelNumber(): number | null {
if (model.value === '' || model.value == null) return null; if (model.value === '' || model.value == null) return null;
@@ -83,7 +91,6 @@ async function onInput(event: Event) {
const n = parseMoneyNumber(raw, props.decimals); const n = parseMoneyNumber(raw, props.decimals);
if (n == null) { if (n == null) {
// اجازهٔ تایپ ناقص مثل "-" یا خالی
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, ''); const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
text.value = cleaned; text.value = cleaned;
writeModel(null); writeModel(null);
@@ -99,7 +106,7 @@ async function onInput(event: Event) {
writeModel(n); writeModel(n);
await nextTick(); await nextTick();
const input = inputRef.value; const input = nativeInput();
if (!input) return; if (!input) return;
const nextCaret = caretIndexForDigitCount(display, digitsBefore); const nextCaret = caretIndexForDigitCount(display, digitsBefore);
input.setSelectionRange(nextCaret, nextCaret); input.setSelectionRange(nextCaret, nextCaret);
@@ -111,27 +118,20 @@ function normalizeSep(raw: string): string {
</script> </script>
<template> <template>
<input <a-input
:id="id" :id="id"
ref="inputRef" ref="inputRef"
class="money-input"
type="text" type="text"
inputmode="decimal" inputmode="decimal"
autocomplete="off" autocomplete="off"
:dir="dir" :dir="dir"
:placeholder="placeholder" :placeholder="placeholder"
:required="required"
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:value="text" :value="text"
style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@input="onInput" @input="onInput"
/> />
</template> </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 { api } from '@/lib/api';
import type { CreatePersonRequest, PersonDto } from '@mento/shared'; import type { CreatePersonRequest, PersonDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui'; import {
import UiIcon from '@/components/UiIcon.vue'; 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 { import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
@@ -15,6 +28,8 @@ import {
type PhoneContactDraft, type PhoneContactDraft,
} from '@/lib/phone-contacts'; } from '@/lib/phone-contacts';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
disabled?: boolean; disabled?: boolean;
@@ -38,7 +53,6 @@ const importing = ref(false);
const drafts = ref<PhoneContactDraft[]>([]); const drafts = ref<PhoneContactDraft[]>([]);
const selected = ref<Set<string>>(new Set()); const selected = ref<Set<string>>(new Set());
const listQuery = ref(''); const listQuery = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
const modalOpen = computed(() => phase.value !== 'closed'); const modalOpen = computed(() => phase.value !== 'closed');
const modalTitle = computed(() => { const modalTitle = computed(() => {
@@ -65,7 +79,6 @@ async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolea
drafts.value = sortPhoneContacts(rows); drafts.value = sortPhoneContacts(rows);
listQuery.value = ''; listQuery.value = '';
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set(); selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
phase.value = 'closed'; phase.value = 'closed';
await nextTick(); await nextTick();
phase.value = 'review'; phase.value = 'review';
@@ -83,7 +96,6 @@ function selectAll(on: boolean) {
selected.value = new Set(); selected.value = new Set();
return; return;
} }
// اگر جستجو فعال است فقط نتایج را انتخاب کن، وگرنه همه
if (listQuery.value.trim()) { if (listQuery.value.trim()) {
const next = new Set(selected.value); const next = new Set(selected.value);
for (const row of visibleDrafts.value) next.add(row.key); for (const row of visibleDrafts.value) next.add(row.key);
@@ -121,7 +133,6 @@ async function runDevicePicker() {
emit('fail', 'مخاطبی انتخاب نشد.'); emit('fail', 'مخاطبی انتخاب نشد.');
return; return;
} }
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
await showReview(rows, { selectAll: false }); await showReview(rows, { selectAll: false });
} catch (e) { } catch (e) {
const name = e instanceof DOMException ? e.name : ''; const name = e instanceof DOMException ? e.name : '';
@@ -143,11 +154,12 @@ function startPicker() {
openHelp(); openHelp();
} }
async function onVcfFile(ev: Event) { const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
const el = ev.target as HTMLInputElement; void handleVcfFile(file as File);
const file = el.files?.[0]; return false;
el.value = ''; };
if (!file) return;
async function handleVcfFile(file: File) {
try { try {
const rows = await parseVCardFile(file); const rows = await parseVCardFile(file);
if (!rows.length) { if (!rows.length) {
@@ -223,68 +235,70 @@ async function importSelected() {
</script> </script>
<template> <template>
<span class="wrap" @click.stop> <span @click.stop>
<span class="trigger" @click="startPicker"> <span @click="startPicker">
<slot /> <slot />
</span> </span>
<input <a-modal
ref="fileInput" :open="modalOpen"
class="hidden" :title="modalTitle"
type="file" :confirm-loading="importing"
accept=".vcf,text/vcard,text/x-vcard" @cancel="cancel"
@change="onVcfFile" >
/>
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
<template v-if="phase === 'choose'"> <template v-if="phase === 'choose'">
<p class="lead"> <a-typography-paragraph type="secondary">
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید. مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید. بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
</p> </a-typography-paragraph>
<div class="choose-actions"> <a-flex vertical :gap="8">
<UiButton variant="primary" block @click="runDevicePicker"> <a-button type="primary" block @click="runDevicePicker">
<UiIcon name="phone" :size="16" /> <template #icon><PhoneOutlined /></template>
از دفترچه تلفن از دفترچه تلفن
</UiButton> </a-button>
<UiButton variant="ghost" block @click="fileInput?.click()"> <a-upload
از فایل .vcf :show-upload-list="false"
</UiButton> accept=".vcf,text/vcard,text/x-vcard"
</div> :before-upload="beforeVcfUpload"
>
<a-button block>از فایل .vcf</a-button>
</a-upload>
</a-flex>
</template> </template>
<template v-else-if="phase === 'help'"> <template v-else-if="phase === 'help'">
<p class="lead"> <a-typography-paragraph type="secondary">
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود. در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمی‌شود.
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید. از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید. بعد از بارگذاری می‌توانید یک، چند، یا همه مخاطبین را انتخاب کنید.
</p> </a-typography-paragraph>
<ul class="tips"> <a-typography-paragraph type="secondary">
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li> اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li> Export → فایل vCard.
</ul> </a-typography-paragraph>
</template> </template>
<template v-else-if="phase === 'review'"> <template v-else-if="phase === 'review'">
<p class="lead"> <a-typography-paragraph type="secondary">
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی). {{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتب‌شده الفبایی).
موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند. موارد دلخواه را تیک بزنید؛ فقط انتخاب‌شده‌ها وارد می‌شوند.
</p> </a-typography-paragraph>
<div class="search"> <a-input
<UiIcon name="search" :size="15" /> v-model:value="listQuery"
<input allow-clear
v-model="listQuery" placeholder="جستجو نام یا موبایل…"
type="search" style="width: 100%; margin-bottom: 12px"
placeholder="جستجو نام یا موبایل…" >
autocomplete="off" <template #prefix><SearchOutlined /></template>
/> </a-input>
</div> <a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<div class="toolbar"> <a-space :size="8">
<button type="button" class="link" @click="selectAll(true)"> <a-button type="link" @click="selectAll(true)">
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }} {{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
</button> </a-button>
<button type="button" class="link" @click="selectAll(false)">هیچ‌کدام</button> <a-button type="link" @click="selectAll(false)">هیچ‌کدام</a-button>
<span class="count"> </a-space>
<a-typography-text type="secondary">
{{ toFaDigits(selectedCount) }} انتخاب {{ toFaDigits(selectedCount) }} انتخاب
<template v-if="listQuery.trim()"> <template v-if="listQuery.trim()">
· {{ toFaDigits(visibleDrafts.length) }} نتیجه · {{ toFaDigits(visibleDrafts.length) }} نتیجه
@@ -292,194 +306,72 @@ async function importSelected() {
<template v-else> <template v-else>
از {{ toFaDigits(drafts.length) }} از {{ toFaDigits(drafts.length) }}
</template> </template>
</span> </a-typography-text>
</div> </a-flex>
<ul v-if="visibleDrafts.length" class="list"> <a-list
<li v-for="row in visibleDrafts" :key="row.key"> v-if="visibleDrafts.length"
<label> :data-source="visibleDrafts"
<input :style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
type="checkbox" >
:checked="selected.has(row.key)" <template #renderItem="{ item }">
<a-list-item>
<a-checkbox
:checked="selected.has(item.key)"
:disabled="importing" :disabled="importing"
@change="toggle(row.key)" @click.prevent="toggle(item.key)"
/> >
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span> <a-space :size="8">
<span class="meta"> <a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
<strong>{{ row.fullName }}</strong> <span>
<small>{{ draftLine(row) }}</small> <div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
</span> <a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
</label> </span>
</li> </a-space>
</ul> </a-checkbox>
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p> </a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">
مخاطبی با این جستجو پیدا نشد.
</a-typography-text>
</template> </template>
<template #footer> <template #footer>
<template v-if="phase === 'choose'"> <template v-if="phase === 'choose'">
<UiButton variant="ghost" @click="cancel">انصراف</UiButton> <a-button @click="cancel">انصراف</a-button>
</template> </template>
<template v-else-if="phase === 'help'"> <template v-else-if="phase === 'help'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton> <a-button :disabled="importing" @click="cancel">انصراف</a-button>
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()"> <a-upload
انتخاب فایل .vcf :show-upload-list="false"
</UiButton> accept=".vcf,text/vcard,text/x-vcard"
:before-upload="beforeVcfUpload"
>
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
</a-upload>
</template> </template>
<template v-else-if="phase === 'review'"> <template v-else-if="phase === 'review'">
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton> <a-button :disabled="importing" @click="cancel">انصراف</a-button>
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()"> <a-upload
فایل دیگر :show-upload-list="false"
</UiButton> accept=".vcf,text/vcard,text/x-vcard"
<UiButton :before-upload="beforeVcfUpload"
variant="primary" :disabled="importing"
>
<a-button :disabled="importing">فایل دیگر</a-button>
</a-upload>
<a-button
type="primary"
:disabled="importing || !selectedCount" :disabled="importing || !selectedCount"
:loading="importing" :loading="importing"
@click="importSelected" @click="importSelected"
> >
<UiIcon name="user-plus" :size="16" /> <template #icon><UserAddOutlined /></template>
ورود {{ toFaDigits(selectedCount) }} ورود {{ toFaDigits(selectedCount) }}
{{ target === 'leads' ? 'لید' : 'نفر' }} {{ target === 'leads' ? 'لید' : 'نفر' }}
</UiButton> </a-button>
</template> </template>
</template> </template>
</UiModal> </a-modal>
</span> </span>
</template> </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>
+105 -183
View File
@@ -1,37 +1,58 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; 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 { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
import type { SavedViewDto, SavedViewScope } from '@mento/shared'; import type { SavedViewDto, SavedViewScope } from '@mento/shared';
const props = defineProps<{ const props = withDefaults(
scope: SavedViewScope; defineProps<{
/** فیلتر فعلی صفحه برای ذخیره */ scope: SavedViewScope;
currentFilters: Record<string, unknown>; currentFilters: Record<string, unknown>;
}>(); /** فشرده برای قرار گرفتن داخل یک ردیف فیلتر */
compact?: boolean;
}>(),
{ compact: false },
);
const emit = defineEmits<{ const emit = defineEmits<{
apply: [filters: Record<string, unknown>]; apply: [filters: Record<string, unknown>];
}>(); }>();
const views = ref<SavedViewDto[]>([]); const views = ref<SavedViewDto[]>([]);
const activeId = ref(''); const activeId = ref<string | undefined>(undefined);
const loading = ref(false); const loading = ref(false);
const busy = ref(false); const busy = ref(false);
const error = ref('');
const saveOpen = ref(false); const saveOpen = ref(false);
const saveName = ref(''); const saveName = ref('');
const saveShared = ref(false); const saveShared = ref(false);
useEscClose(() => saveOpen.value, () => { saveOpen.value = false; });
const activeView = computed( const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null, () => 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() { async function loadViews() {
loading.value = true; loading.value = true;
error.value = '';
try { try {
const { data } = await api.get<SavedViewDto[]>('/saved-views', { const { data } = await api.get<SavedViewDto[]>('/saved-views', {
params: { scope: props.scope }, params: { scope: props.scope },
@@ -39,16 +60,17 @@ async function loadViews() {
views.value = data; views.value = data;
} catch { } catch {
views.value = []; views.value = [];
error.value = 'بارگذاری فیلترهای ذخیره‌شده ناموفق بود.'; message.error('بارگذاری فیلترهای ذخیره‌شده ناموفق بود.');
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function onPick(id: string) { function onPick(id: unknown) {
activeId.value = id; const next = typeof id === 'string' ? id : '';
if (!id) return; activeId.value = next || undefined;
const view = views.value.find((v) => v.id === id); if (!next) return;
const view = views.value.find((v) => v.id === next);
if (view) emit('apply', view.filters ?? {}); if (view) emit('apply', view.filters ?? {});
} }
@@ -56,14 +78,12 @@ function openSave() {
saveName.value = activeView.value?.name ?? ''; saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? false; saveShared.value = activeView.value?.isShared ?? false;
saveOpen.value = true; saveOpen.value = true;
error.value = '';
} }
async function saveView() { async function saveView() {
const name = saveName.value.trim(); const name = saveName.value.trim();
if (name.length < 2 || busy.value) return; if (name.length < 2 || busy.value) return;
busy.value = true; busy.value = true;
error.value = '';
try { try {
const body = { const body = {
name, name,
@@ -86,32 +106,42 @@ async function saveView() {
activeId.value = data.id; activeId.value = data.id;
} }
saveOpen.value = false; saveOpen.value = false;
message.success('فیلتر ذخیره شد.');
} catch { } catch {
error.value = 'ذخیره فیلتر ناموفق بود.'; message.error('ذخیره فیلتر ناموفق بود.');
} finally { } finally {
busy.value = false; busy.value = false;
} }
} }
async function removeView() { function removeView() {
if (!activeId.value || busy.value) return; if (!activeId.value || busy.value) return;
if (!confirm('این فیلتر ذخیره‌شده حذف شود؟')) return; Modal.confirm({
busy.value = true; title: 'حذف فیلتر',
try { content: 'این فیلتر ذخیره‌شده حذف شود؟',
await api.delete(`/saved-views/${activeId.value}`); okText: 'حذف',
views.value = views.value.filter((v) => v.id !== activeId.value); cancelText: 'انصراف',
activeId.value = ''; okButtonProps: { danger: true },
} catch { async onOk() {
error.value = 'حذف فیلتر ناموفق بود.'; busy.value = true;
} finally { try {
busy.value = false; 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( watch(
() => props.scope, () => props.scope,
() => { () => {
activeId.value = ''; activeId.value = undefined;
void loadViews(); void loadViews();
}, },
); );
@@ -122,161 +152,53 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="saved-filters" dir="rtl"> <!-- یک خط فشرده: سلکت + ذخیره (+ حذف) -->
<label class="pick"> <a-space-compact block dir="rtl" :style="{ width: '100%' }">
<span class="lbl">فیلتر ذخیره‌شده</span> <a-select
<select :value="activeId"
:value="activeId" allow-clear
:disabled="loading" :disabled="loading"
@change="onPick(($event.target as HTMLSelectElement).value)" :loading="loading"
> :options="selectOptions"
<option value="">— انتخاب —</option> :placeholder="compact ? 'فیلتر ذخیره‌شده' : '— انتخاب —'"
<option v-for="v in views" :key="v.id" :value="v.id"> :style="{ flex: 1, minWidth: 0, width: '100%' }"
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }} @update:value="onPick"
</option> />
</select> <a-button :disabled="loading" title="ذخیره فیلتر فعلی" @click="openSave">
</label> <template #icon><FlagOutlined /></template>
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
<UiIcon name="flag" :size="14" />
ذخیره ذخیره
</button> </a-button>
<button <a-button
v-if="activeId" v-if="activeId"
type="button" danger
class="btn danger"
title="حذف فیلتر"
:disabled="busy" :disabled="busy"
title="حذف فیلتر"
@click="removeView" @click="removeView"
> >
حذف <template #icon><DeleteOutlined /></template>
</button> </a-button>
<p v-if="error" class="err">{{ error }}</p> </a-space-compact>
<div v-if="saveOpen" class="sheet"> <a-modal
<div class="sheet-backdrop" aria-hidden="true" /> v-model:open="saveOpen"
<div class="sheet-panel" role="dialog" aria-modal="true"> :title="activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر'"
<header class="sheet-head"> :confirm-loading="busy"
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3> ok-text="ذخیره"
<button type="button" class="x-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="saveOpen = false"> cancel-text="لغو"
<UiIcon name="x" :size="16" /> destroy-on-close
</button> @ok="saveView"
</header> >
<label> <a-form layout="vertical">
نام <a-form-item label="نام" required>
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخ‌های این هفته" /> <a-input
</label> v-model:value="saveName"
<label class="check"> :maxlength="120"
<input v-model="saveShared" type="checkbox" /> placeholder="مثلاً سرنخ‌های این هفته"
اشتراک با سازمان />
</label> </a-form-item>
<div class="actions"> <a-form-item :style="{ marginBottom: 0 }">
<button type="button" class="btn primary" :disabled="busy" @click="saveView"> <a-checkbox v-model:checked="saveShared">اشتراک با سازمان</a-checkbox>
{{ busy ? '…' : 'ذخیره' }} </a-form-item>
</button> </a-form>
<button type="button" class="btn" @click="saveOpen = false">لغو</button> </a-modal>
</div>
</div>
</div>
</div>
</template> </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"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, ref } from 'vue';
import UiIcon from '@/components/UiIcon.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 { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
tableKey: string; tableKey: string;
@@ -14,164 +23,49 @@ const props = withDefaults(
); );
const open = ref(false); const open = ref(false);
const root = ref<HTMLElement | null>(null);
const cols = useTableColumns(() => props.tableKey); const cols = useTableColumns(() => props.tableKey);
const columns = computed(() => cols.columns()); const columns = computed(() => cols.columns());
const hiddenCount = computed(() => cols.hiddenCount()); const hiddenCount = computed(() => cols.hiddenCount());
function onDocClick(e: MouseEvent) { const placement = computed(() =>
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false; props.align === 'end' ? 'bottomLeft' : 'bottomRight',
} );
onMounted(() => document.addEventListener('click', onDocClick));
onBeforeUnmount(() => document.removeEventListener('click', onDocClick));
</script> </script>
<template> <template>
<div ref="root" class="tc-pop"> <a-popover v-model:open="open" trigger="click" :placement="placement">
<button <template #content>
class="tc-tool" <a-flex
type="button" vertical
:class="{ active: hiddenCount > 0 }" :gap="8"
:title="label" style="min-width: 13rem; max-width: calc(100vw - 1.5rem)"
@click.stop="open = !open" >
> <a-typography-text type="secondary" strong>
<UiIcon name="sliders" :size="16" /> نمایش ستون‌ها
<span class="tc-label">{{ label }}</span> </a-typography-text>
<em v-if="hiddenCount" class="tc-badge">{{ toFaDigits(hiddenCount) }} مخفی</em> <a-typography-text v-if="!columns.length" type="secondary">
</button> ستونی برای نمایش وجود ندارد.
<div v-if="open" class="tc-menu" :class="align" @click.stop> </a-typography-text>
<p class="tc-head">نمایش ستون‌ها</p> <a-checkbox
<p v-if="!columns.length" class="tc-empty">ستونی برای نمایش وجود ندارد.</p> v-for="col in columns"
<label v-for="col in columns" :key="col" class="tc-item"> :key="col"
<input type="checkbox" :checked="cols.isVisible(col)" @change="cols.toggle(col)" /> :checked="cols.isVisible(col)"
<span>{{ col }}</span> @click.prevent="cols.toggle(col)"
</label> >
<button v-if="hiddenCount" type="button" class="tc-reset" @click="cols.showAll()"> {{ col }}
نمایش همه ستون‌ها </a-checkbox>
</button> <a-button v-if="hiddenCount" type="link" @click="cols.showAll()">
</div> نمایش همه ستون‌ها
</div> </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> </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"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue'; import { Select as ASelect } from 'ant-design-vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { matchesSearch } from '@/lib/search';
type PickUser = { id: string; fullName: string }; type PickUser = { id: string; fullName: string };
@@ -21,335 +19,35 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [ids: string[]]; 'update:modelValue': [ids: string[]];
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
change: [ids: string[]]; change: [ids: string[]];
}>(); }>();
const open = ref(false); const options = computed(() =>
const query = ref(''); props.users.map((u) => ({ value: u.id, label: u.fullName })),
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 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[]) { function onUpdate(value: unknown) {
return a.length === b.length && a.every((id) => b.includes(id)); 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> </script>
<template> <template>
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey"> <a-select
<div class="chips"> mode="multiple"
<span v-for="u in selected" :key="u.id" class="chip"> allow-clear
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" /> show-search
<span class="chip-name">{{ u.fullName }}</span> option-filter-prop="label"
<button :value="modelValue"
v-if="canRemove && !disabled" :options="options"
type="button" :disabled="disabled"
class="chip-x" :placeholder="emptyLabel"
:aria-label="`حذف ${u.fullName}`" style="width: 100%"
@click.stop="remove(u.id)" @update:value="onUpdate"
> />
<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>
</template> </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"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; 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 { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const { Text: ATypographyText } = Typography;
type SpeechResult = { type SpeechResult = {
isFinal: boolean; isFinal: boolean;
@@ -186,102 +189,23 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<div class="vtt-wrap" :class="[variant, { live: recording }]"> <div>
<button <a-button
class="vtt" :type="recording ? 'primary' : 'default'"
type="button" :danger="recording"
:class="{ on: recording }"
:disabled="disabled" :disabled="disabled"
:aria-label="ariaLabel" :aria-label="ariaLabel"
:title="ariaLabel" :title="ariaLabel"
@click="toggle" @click="toggle"
> >
<span class="glyph" aria-hidden="true"> <template #icon>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" /> <PauseCircleOutlined v-if="recording" />
</span> <AudioOutlined v-else />
<span v-if="recording" class="timer">{{ timerLabel }}</span> </template>
</button> <span v-if="recording">{{ timerLabel }}</span>
<p v-if="error && !hideError" class="err">{{ error }}</p> </a-button>
<div v-if="error && !hideError">
<a-typography-text type="danger">{{ error }}</a-typography-text>
</div>
</div> </div>
</template> </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"> <script setup lang="ts">
import UiButton from '@/components/ui/UiButton.vue'; import { PlusOutlined } from '@ant-design/icons-vue';
import UiIcon from '@/components/UiIcon.vue'; import { Button as AButton } from 'ant-design-vue';
import type { PipelineAccent } from '@/lib/pipeline-board'; import type { PipelineAccent } from '@/lib/pipeline-board';
import { PIPELINE_ACCENT } from '@/lib/pipeline-board'; import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
@@ -18,15 +18,14 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<UiButton <a-button
variant="dashed" type="dashed"
block block
class="add-deal"
:style="{ color: PIPELINE_ACCENT[accent].text }" :style="{ color: PIPELINE_ACCENT[accent].text }"
:aria-label="label" :aria-label="label"
@click="emit('add')" @click="emit('add')"
> >
<UiIcon name="plus" :size="16" /> <template #icon><PlusOutlined /></template>
{{ label }} {{ label }}
</UiButton> </a-button>
</template> </template>
+147 -291
View File
@@ -1,6 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; 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 { userAvatarSrc } from '@/lib/userAvatars';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import { import {
@@ -30,6 +45,9 @@ const emit = defineEmits<{
bulkDelete: []; bulkDelete: [];
}>(); }>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const showAmount = computed( const showAmount = computed(
() => !props.copy.hideAmount && props.deal.amount != null, () => !props.copy.hideAmount && props.deal.amount != null,
); );
@@ -47,306 +65,144 @@ const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1), Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
); );
const showTrash = computed(() => props.selectMode && props.selected); 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> </script>
<template> <template>
<article <a-card
class="deal-card" hoverable
:class="{ :bordered="true"
dragging, :body-style="{ padding: '12px 14px', margin: 0 }"
'no-amount': !showAmount, :style="cardStyle"
'select-mode': selectMode,
selected,
}"
:data-card-id="deal.id" :data-card-id="deal.id"
:draggable="!selectMode" :draggable="!selectMode"
role="listitem" role="listitem"
:aria-grabbed="dragging ? 'true' : 'false'" :aria-grabbed="dragging ? 'true' : 'false'"
:style="{
'--badge-bg': colors.soft,
'--badge-fg': colors.text,
}"
@dragstart="!selectMode && emit('dragStart', deal, $event)" @dragstart="!selectMode && emit('dragStart', deal, $event)"
@dragend="emit('dragEnd')" @dragend="emit('dragEnd')"
@dblclick="!selectMode && emit('open', deal.id)" @dblclick="!selectMode && emit('open', deal.id)"
@click="selectMode && emit('toggleSelect', deal.id)" @click="selectMode && emit('toggleSelect', deal.id)"
> >
<button <a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
v-if="selectMode" <a-flex :gap="10" align="flex-start" :style="{ minWidth: 0 }">
type="button" <a-button
class="tick" v-if="selectMode"
:class="{ on: selected }" type="text"
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'" shape="circle"
:aria-pressed="selected" :aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
@click.stop="emit('toggleSelect', deal.id)" :aria-pressed="selected"
> :style="{
<UiIcon v-if="selected" name="check" :size="14" /> width: '28px',
</button> height: '28px',
<span v-else class="badge" aria-hidden="true">{{ deal.initials }}</span> minWidth: '28px',
<div class="titles"> color: selected ? token.colorTextLightSolid : token.colorTextTertiary,
<h3 @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"> border: `2px solid ${selected ? token.colorPrimary : token.colorBorder}`,
{{ deal.title }} background: selected ? token.colorPrimary : token.colorBgContainer,
</h3> }"
<p v-if="deal.description" class="desc">{{ deal.description }}</p> @click.stop="emit('toggleSelect', deal.id)"
</div> >
<button <template #icon>
v-if="showTrash" <CheckOutlined v-if="selected" />
type="button" </template>
class="menu trash" </a-button>
aria-label="حذف انتخاب‌شده‌ها" <a-avatar
@click.stop="emit('bulkDelete')" v-else
> :style="{
<UiIcon name="trash" :size="16" /> background: colors.soft,
</button> color: colors.text,
<button flexShrink: 0,
v-else fontWeight: 700,
type="button" fontSize: `${token.fontSizeSM}px`,
class="menu" }"
:aria-label="copy.itemMenuAria" >
@click.stop="emit('menu', deal.id)" {{ deal.initials }}
> </a-avatar>
<UiIcon name="dots-v" :size="16" />
</button> <div :style="{ flex: 1, minWidth: 0 }">
<img <a-typography-text
class="avatar" strong
:src="avatarSrc" ellipsis
alt="" :style="{ display: 'block', margin: 0, lineHeight: 1.35, cursor: 'pointer' }"
width="32" :title="deal.title"
height="32" @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
:title="assigneeTitle" >
/> {{ deal.title }}
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle"> </a-typography-text>
+{{ extraAssignees }} <a-typography-text
</span> v-if="deal.description"
<span class="date"> type="secondary"
{{ deal.date }} ellipsis
<UiIcon name="calendar" :size="13" /> :style="{
</span> display: 'block',
<div v-if="showAmount" class="amount"> margin: 0,
<strong>{{ money.number }}</strong> marginTop: '2px',
<span>{{ money.unit }}</span> fontSize: `${token.fontSizeSM}px`,
</div> }"
</article> :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> </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"> <script setup lang="ts">
import { nextTick, ref } from 'vue'; 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 PipelineColumn from './PipelineColumn.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
import { DEAL_BOARD_COPY } from '@/lib/pipeline-board'; import { DEAL_BOARD_COPY } from '@/lib/pipeline-board';
@@ -39,13 +46,40 @@ const emit = defineEmits<{
const adding = ref(false); const adding = ref(false);
const newName = ref(''); 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() { async function startAdd() {
adding.value = true; adding.value = true;
newName.value = ''; newName.value = '';
await nextTick(); await nextTick();
addInput.value?.focus(); addInput.value?.focus?.();
} }
function cancelAdd() { function cancelAdd() {
@@ -63,193 +97,88 @@ function submitAdd() {
</script> </script>
<template> <template>
<div class="board-shell" dir="rtl"> <div class="pipeline-board-shell" dir="rtl" :style="shellStyle">
<div class="board"> <div class="pipeline-board-track" :style="trackStyle">
<PipelineColumn <PipelineColumn
v-for="(stage, index) in stages" v-for="(stage, index) in stages"
:key="stage.id" :key="stage.id"
:stage="stage" :stage="stage"
:dragging-id="draggingId" :dragging-id="draggingId"
:drop-active="dropStageId === stage.id" :drop-active="dropStageId === stage.id"
:can-manage="canManage" :can-manage="canManage"
:copy="copy" :copy="copy"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:is-first="index === 0" :is-first="index === 0"
:is-last="index === stages.length - 1" :is-last="index === stages.length - 1"
@drag-start="(d, e) => emit('dragStart', d, e)" @drag-start="(d, e) => emit('dragStart', d, e)"
@drag-end="emit('dragEnd')" @drag-end="emit('dragEnd')"
@drag-over="(id, e) => emit('dragOver', id, e)" @drag-over="(id, e) => emit('dragOver', id, e)"
@drop="(id, e, index) => emit('drop', id, e, index)" @drop="(id, e, index) => emit('drop', id, e, index)"
@add-deal="emit('addDeal', $event)" @add-deal="emit('addDeal', $event)"
@open-deal="emit('openDeal', $event)" @open-deal="emit('openDeal', $event)"
@menu-deal="emit('menuDeal', $event)" @menu-deal="emit('menuDeal', $event)"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@rename="(id, title) => emit('renameStage', id, title)" @rename="(id, title) => emit('renameStage', id, title)"
@delete="emit('deleteStage', $event)" @delete="emit('deleteStage', $event)"
@move="(id, dir) => emit('moveStage', id, dir)" @move="(id, dir) => emit('moveStage', id, dir)"
@column-drag-start="(id, e) => emit('columnDragStart', id, e)" @column-drag-start="(id, e) => emit('columnDragStart', id, e)"
@column-drag-end="emit('columnDragEnd')" @column-drag-end="emit('columnDragEnd')"
@load-more="emit('loadMore', $event)" @load-more="emit('loadMore', $event)"
/> />
<section v-if="canManage" class="add-col"> <div
<form v-if="adding" class="add-form" @submit.prevent="submitAdd"> v-if="canManage"
<input :style="{
ref="addInput" flex: '0 0 280px',
v-model="newName" width: '280px',
maxlength="80" alignSelf: 'stretch',
placeholder="نام ستون جدید…" display: 'flex',
aria-label="نام ستون جدید" alignItems: 'flex-start',
@keydown.esc.prevent="cancelAdd" }"
/> >
<div class="add-actions"> <a-card v-if="adding" :style="{ width: '100%' }" :body-style="{ padding: '12px' }">
<button class="ok" type="submit" :disabled="newName.trim().length < 1"> <a-flex vertical :gap="12">
افزودن <a-input
</button> ref="addInput"
<button class="cancel" type="button" @click="cancelAdd">انصراف</button> v-model:value="newName"
</div> :maxlength="80"
</form> placeholder="نام ستون جدید…"
<button v-else type="button" class="add-btn" @click="startAdd"> aria-label="نام ستون جدید"
<UiIcon name="plus" :size="18" /> @pressEnter="submitAdd"
افزودن ستون @keydown.esc.prevent="cancelAdd"
</button> />
</section> <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>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.board-shell { .pipeline-board-track {
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;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
} }
.pipeline-board-track::-webkit-scrollbar {
.board::-webkit-scrollbar {
height: 8px; height: 8px;
} }
.pipeline-board-track::-webkit-scrollbar-thumb {
.board::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 38%, transparent);
border-radius: 999px; border-radius: 999px;
} background: color-mix(in srgb, currentColor 28%, transparent);
.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;
}
} }
</style> </style>
@@ -1,6 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, ref } from 'vue';
import UiIcon from '@/components/UiIcon.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 AddDealButton from './AddDealButton.vue';
import DealCard from './DealCard.vue'; import DealCard from './DealCard.vue';
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
@@ -39,11 +52,63 @@ const emit = defineEmits<{
loadMore: [stageId: string]; loadMore: [stageId: string];
}>(); }>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const renaming = ref(false); const renaming = ref(false);
const draft = ref(''); const draft = ref('');
const titleInput = ref<HTMLInputElement | null>(null); const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
const menuOpen = ref(false); 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) { function onCardDragOver(stageId: string, event: DragEvent) {
event.preventDefault(); event.preventDefault();
@@ -62,7 +127,11 @@ function startRename() {
draft.value = props.stage.title; draft.value = props.stage.title;
renaming.value = true; renaming.value = true;
menuOpen.value = false; 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() { function commitRename() {
@@ -78,144 +147,133 @@ function cancelRename() {
draft.value = props.stage.title; draft.value = props.stage.title;
} }
function toggleMenu() {
menuOpen.value = !menuOpen.value;
}
function onColumnDragStart(event: DragEvent) { function onColumnDragStart(event: DragEvent) {
event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`); event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`);
event.dataTransfer!.effectAllowed = 'move'; event.dataTransfer!.effectAllowed = 'move';
emit('columnDragStart', props.stage.id, event); emit('columnDragStart', props.stage.id, event);
} }
function onDocPointerDown(event: PointerEvent) { const menuItems = computed(() => [
const target = event.target as Node | null; { key: 'rename', label: 'تغییر نام' },
const root = menuRoot.value; { key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
if (root && target && !root.contains(target)) menuOpen.value = false; { 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> </script>
<template> <template>
<section <section
class="column" :style="columnStyle"
: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,
}"
@dragover.prevent="emit('dragOver', stage.id, $event)" @dragover.prevent="emit('dragOver', stage.id, $event)"
@drop.prevent="emit('drop', stage.id, $event)" @drop.prevent="emit('drop', stage.id, $event)"
> >
<header class="column-head"> <div :style="headStyle">
<span <span
v-if="canManage" v-if="canManage"
class="grip"
draggable="true" draggable="true"
title="کشیدن برای جابه‌جایی" title="کشیدن برای جابه‌جایی"
aria-label="جابه‌جایی ستون" 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" @dragstart="onColumnDragStart"
@dragend="emit('columnDragEnd')" @dragend="emit('columnDragEnd')"
> >
<UiIcon name="grip" :size="14" /> <HolderOutlined />
</span> </span>
<div class="title-wrap">
<span class="dot" aria-hidden="true" /> <span
<input :style="{
v-if="renaming" width: '8px',
ref="titleInput" height: '8px',
v-model="draft" borderRadius: '50%',
class="title-input" background: accent.solid,
maxlength="80" display: 'inline-block',
aria-label="نام ستون" flexShrink: 0,
@keydown.enter.prevent="commitRename" }"
@keydown.esc.prevent="cancelRename" aria-hidden="true"
@blur="commitRename" />
/>
<h2 <a-input
v-else v-if="renaming"
:class="{ editable: canManage }" ref="titleInput"
:title="canManage ? 'برای تغییر نام کلیک کنید' : undefined" v-model:value="draft"
@click="startRename" :maxlength="80"
> aria-label="نام ستون"
{{ stage.title }} :style="{ flex: 1, minWidth: 0 }"
</h2> @pressEnter="commitRename"
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small> @keydown.esc.prevent="cancelRename"
</div> @blur="commitRename"
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span> />
<div v-if="canManage" ref="menuRoot" class="more-wrap"> <a-typography-text
<button v-else
type="button" strong
class="icon-btn" ellipsis
aria-label="گزینه‌های ستون" :style="{
:aria-expanded="menuOpen" flex: 1,
@click.stop="toggleMenu" minWidth: 0,
> margin: 0,
<UiIcon name="dots-v" :size="16" /> cursor: canManage ? 'text' : 'default',
</button> }"
<div v-if="menuOpen" class="col-menu" role="menu"> :title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
<button type="button" role="menuitem" @click="startRename"> @click="startRename"
تغییر نام >
</button> {{ stage.title }}
<button </a-typography-text>
type="button"
role="menuitem" <a-tag v-if="stage.badge && !renaming" :bordered="false">
:disabled="isFirst" {{ stage.badge }}
@click="emit('move', stage.id, -1); menuOpen = false" </a-tag>
>
انتقال به راست <a-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
</button> {{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
<button </a-typography-text>
type="button"
role="menuitem" <a-dropdown
:disabled="isLast" v-if="canManage"
@click="emit('move', stage.id, 1); menuOpen = false" v-model:open="menuOpen"
> :trigger="['click']"
انتقال به چپ placement="bottomLeft"
</button> >
<button <a-button type="text" aria-label="گزینه‌های ستون" @click.stop>
type="button" <template #icon><MoreOutlined /></template>
role="menuitem" </a-button>
class="danger" <template #overlay>
@click="emit('delete', stage.id); menuOpen = false" <a-menu :items="menuItems" @click="onMenuClick" />
> </template>
حذف ستون </a-dropdown>
</button>
</div> <a-button
</div> type="text"
<button
type="button"
class="plus"
:aria-label="copy.addItemAria" :aria-label="copy.addItemAria"
:style="{ color: accent.solid }"
@click="emit('addDeal', stage.id)" @click="emit('addDeal', stage.id)"
> >
<UiIcon name="plus" :size="16" /> <template #icon><PlusOutlined /></template>
</button> </a-button>
</header> </div>
<div class="cards" role="list" :aria-label="stage.title"> <div role="list" :aria-label="stage.title" :style="bodyStyle">
<div <div
v-for="(deal, index) in stage.deals" v-for="(deal, index) in stage.deals"
:key="deal.id" :key="deal.id"
class="card-slot"
@dragover="onCardDragOver(stage.id, $event)" @dragover="onCardDragOver(stage.id, $event)"
@drop="onCardDrop(stage.id, index, $event)" @drop="onCardDrop(stage.id, index, $event)"
> >
@@ -234,256 +292,26 @@ onBeforeUnmount(() => {
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
/> />
</div> </div>
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p> <a-typography-text
<button 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" v-if="stage.hasMore"
type="button" type="dashed"
class="load-more" block
:disabled="stage.loadingMore" :loading="stage.loadingMore"
@click="emit('loadMore', stage.id)" @click="emit('loadMore', stage.id)"
> >
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }} {{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
</button> </a-button>
</div> </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> </section>
</template> </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"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; 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 TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import UiIcon from '@/components/UiIcon.vue'; import type {
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board'; PipelineAccent,
PipelineDeal,
PipelineStage,
PipelineUiCopy,
} from '@/lib/pipeline-board';
import { import {
DEAL_BOARD_COPY, DEAL_BOARD_COPY,
formatCurrencyParts, formatCurrencyParts,
@@ -26,14 +49,28 @@ const emit = defineEmits<{
bulkDelete: []; bulkDelete: [];
}>(); }>();
const { Text: ATypographyText } = Typography;
const { token } = theme.useToken();
const listRoot = ref<HTMLElement | null>(null); const listRoot = ref<HTMLElement | null>(null);
/** فقط یکی از جدول/کارت در DOM باشد تا منوی انتخاب گم نشود */
const useCards = ref(false); const useCards = ref(false);
let resizeObs: ResizeObserver | null = null; 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) => props.stages.flatMap((stage) =>
stage.deals.map((deal) => ({ stage.deals.map((deal) => ({
key: deal.id,
deal, deal,
stageTitle: stage.title, stageTitle: stage.title,
accent: stage.color, accent: stage.color,
@@ -48,6 +85,33 @@ function money(deal: PipelineDeal) {
const hideAmount = computed(() => Boolean(props.copy.hideAmount)); const hideAmount = computed(() => Boolean(props.copy.hideAmount));
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals')); 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(() => { onMounted(() => {
const el = listRoot.value; const el = listRoot.value;
if (!el || typeof ResizeObserver === 'undefined') return; if (!el || typeof ResizeObserver === 'undefined') return;
@@ -66,445 +130,211 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div ref="listRoot" class="list" dir="rtl"> <div ref="listRoot" dir="rtl" :style="{ minWidth: 0 }">
<p v-if="!rows.length" class="empty">{{ copy.empty }}</p> <a-empty v-if="!rows.length" :description="copy.empty" />
<div v-if="rows.length && !useCards" class="tc-row"> <template v-else-if="!useCards">
<TableColumnsMenu :table-key="tableKey" align="end" /> <a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
</div> <TableColumnsMenu :table-key="tableKey" align="end" />
<div v-if="rows.length && !useCards" class="table-wrap"> </a-flex>
<table v-table-cols="tableKey" class="data-table"> <a-table
<thead> :columns="columns"
<tr> :data-source="rows"
<th v-if="selectMode" class="tick-col"></th> :pagination="false"
<th>{{ copy.listColTitle }}</th> row-key="key"
<th>مرحله</th> :custom-row="customRow"
<th v-if="!hideAmount">مبلغ</th> :row-class-name="(record: Row) => (selectedIds.has(record.deal.id) ? 'pipeline-row-selected' : '')"
<th>تاریخ</th> >
<th class="menu-col">عملیات</th> <template #bodyCell="{ column, record }">
</tr> <template v-if="column.key === 'select'">
</thead> <a-button
<tbody> type="text"
<tr shape="circle"
v-for="row in rows" :aria-label="selectedIds.has(record.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
:key="row.deal.id" @click.stop="emit('toggleSelect', record.deal.id)"
:data-card-id="row.deal.id" >
:class="{ selected: selectedIds.has(row.deal.id) }" <template #icon>
@click="selectMode && emit('toggleSelect', row.deal.id)" <CheckOutlined v-if="selectedIds.has(record.deal.id)" />
> </template>
<td v-if="selectMode" class="tick-col" @click.stop> </a-button>
<button </template>
type="button" <template v-else-if="column.key === 'title'">
class="tick" <a-flex
:class="{ on: selectedIds.has(row.deal.id) }" :gap="10"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'" align="center"
:aria-pressed="selectedIds.has(row.deal.id)" :style="{ minWidth: 0, cursor: 'pointer' }"
@click="emit('toggleSelect', row.deal.id)" @click.stop="
> selectMode
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" /> ? emit('toggleSelect', record.deal.id)
</button> : emit('openDeal', record.deal.id)
</td> "
<td> >
<button <a-avatar
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"
:style="{ :style="{
background: PIPELINE_ACCENT[row.accent].soft, background: accentColors(record.accent).soft,
color: PIPELINE_ACCENT[row.accent].text, color: accentColors(record.accent).text,
flexShrink: 0,
}" }"
> >
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" /> {{ record.deal.initials }}
{{ row.stageTitle }} </a-avatar>
</span> <div :style="{ minWidth: 0 }">
</td> <a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
<td v-if="!hideAmount"> {{ record.deal.title }}
<span class="amount" dir="ltr"> </a-typography-text>
<strong>{{ money(row.deal).number }}</strong> <a-typography-text
<small>{{ money(row.deal).unit }}</small> v-if="record.deal.description"
</span> type="secondary"
</td> ellipsis
<td> :style="{ display: 'block', margin: 0, fontSize: `${token.fontSizeSM}px` }"
<span class="date"> >
<UiIcon name="calendar" :size="14" /> {{ record.deal.description }}
{{ row.deal.date }} </a-typography-text>
</span> </div>
</td> </a-flex>
<td class="menu-cell" @click.stop> </template>
<button <template v-else-if="column.key === 'stage'">
v-if="selectMode && selectedIds.has(row.deal.id)" <a-tag
type="button" :bordered="false"
class="menu trash" :style="{
aria-label="حذف انتخاب‌شده‌ها" background: accentColors(record.accent).soft,
@click="emit('bulkDelete')" color: accentColors(record.accent).text,
> }"
<UiIcon name="trash" :size="18" /> >
</button> {{ record.stageTitle }}
<button </a-tag>
v-else </template>
type="button" <template v-else-if="column.key === 'amount'">
class="menu" <span dir="ltr">
:aria-label="copy.itemMenuAria" <a-typography-text strong>{{ money(record.deal).number }}</a-typography-text>
@click="emit('menuDeal', row.deal.id)" <a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
> {{ money(record.deal).unit }}
<UiIcon name="dots-v" :size="18" /> </a-typography-text>
</button> </span>
</td> </template>
</tr> <template v-else-if="column.key === 'date'">
</tbody> <a-space :size="6">
</table> <CalendarOutlined />
</div> <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"> <a-flex v-else vertical :gap="12">
<article <a-card
v-for="row in rows" v-for="row in rows"
:key="row.deal.id" :key="row.deal.id"
class="card" hoverable
:class="{ selected: selectedIds.has(row.deal.id) }"
:data-card-id="row.deal.id" :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)" @dblclick="!selectMode && emit('openDeal', row.deal.id)"
@click="selectMode && emit('toggleSelect', row.deal.id)" @click="selectMode && emit('toggleSelect', row.deal.id)"
> >
<div class="card-top"> <a-flex vertical :gap="12">
<button <a-flex align="center" :gap="10" :style="{ minWidth: 0 }">
v-if="selectMode" <a-button
type="button" v-if="selectMode"
class="tick" type="text"
:class="{ on: selectedIds.has(row.deal.id) }" shape="circle"
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'" @click.stop="emit('toggleSelect', row.deal.id)"
@click.stop="emit('toggleSelect', row.deal.id)" >
> <template #icon>
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" /> <CheckOutlined v-if="selectedIds.has(row.deal.id)" />
</button> </template>
<button </a-button>
type="button" <a-avatar
class="identity"
@click.stop="
selectMode
? emit('toggleSelect', row.deal.id)
: emit('openDeal', row.deal.id)
"
>
<span
class="badge"
:style="{ :style="{
background: PIPELINE_ACCENT[row.accent].soft, background: accentColors(row.accent).soft,
color: PIPELINE_ACCENT[row.accent].text, color: accentColors(row.accent).text,
}" }"
>{{ row.deal.initials }}</span> >
<span class="titles"> {{ row.deal.initials }}
<strong>{{ row.deal.title }}</strong> </a-avatar>
<em v-if="row.deal.description">{{ row.deal.description }}</em> <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> </span>
</button> </a-flex>
<button </a-flex>
v-if="selectMode && selectedIds.has(row.deal.id)" </a-card>
type="button" </a-flex>
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>
</div> </div>
</template> </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"> <script setup lang="ts">
import UiFilterBar from '@/components/ui/UiFilterBar.vue'; import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue'; import {
import UiSegmented from '@/components/ui/UiSegmented.vue'; AppstoreOutlined,
import UiSelect from '@/components/ui/UiSelect.vue'; 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'; export type PipelineViewMode = 'kanban' | 'list';
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
pipelineName: string; pipelineName: string;
pipelineOptions: Array<{ id: string; name: string }>; pipelineOptions: Array<{ id: string; name: string }>;
@@ -27,6 +38,9 @@ const emit = defineEmits<{
create: []; create: [];
}>(); }>();
const { Title: ATypographyTitle } = Typography;
const { token } = theme.useToken();
const viewMode = defineModel<PipelineViewMode>('viewMode', { required: true }); const viewMode = defineModel<PipelineViewMode>('viewMode', { required: true });
const search = defineModel<string>('search', { default: '' }); const search = defineModel<string>('search', { default: '' });
const selectedPipelineId = defineModel<string>('selectedPipelineId', { required: true }); const selectedPipelineId = defineModel<string>('selectedPipelineId', { required: true });
@@ -34,165 +48,112 @@ const amountMin = defineModel<string>('amountMin', { default: '' });
const amountMax = defineModel<string>('amountMax', { default: '' }); const amountMax = defineModel<string>('amountMax', { default: '' });
const viewOptions = [ const viewOptions = [
{ value: 'kanban' as const, label: 'نمای ستونی', icon: 'grid' as const }, { value: 'kanban' as const, label: 'ستونی' },
{ value: 'list' as const, label: 'نمای لیست', icon: 'list' as const }, { value: 'list' as const, label: 'لیست' },
]; ];
function onPick(value: string) { const pipelineSelectOptions = computed(() => {
if (value === '__create__') { 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'); emit('create');
return; 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> </script>
<template> <template>
<header class="toolbar" dir="rtl"> <a-flex vertical :gap="12" dir="rtl">
<div class="title-block"> <a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
<span class="brand-ico" aria-hidden="true"> <a-space :size="10" align="center">
<UiIcon :name="icon" :size="18" /> <span
</span> :style="{
<h1>{{ pipelineName }}</h1> width: `${token.controlHeight}px`,
</div> 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"> <a-space wrap :size="8">
<template #icon="{ option }"> <a-segmented v-model:value="viewMode" :options="viewOptions" />
<UiIcon :name="option.icon ?? 'grid'" :size="16" /> <a-select
</template> :value="selectedPipelineId"
</UiSegmented> :options="pipelineSelectOptions"
:style="{ width: '180px' }"
@update:value="onPick"
/>
<slot name="excel" />
</a-space>
</a-flex>
<UiSelect <!-- یک نوار فیلتر تک‌ردیفه؛ بدون a-row و بدون فاصلهٔ ول -->
class="pipeline-pick" <a-flex
:model-value="selectedPipelineId" wrap="wrap"
@update:model-value="onPick" align="center"
> :gap="8"
<option :style="{ width: '100%', minWidth: 0 }"
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-input-search
v-model:value="search"
allow-clear
placeholder="جستجو در معاملات…"
:style="{ flex: '1 1 220px', minWidth: '180px', maxWidth: '100%' }"
/>
<template v-if="showAmountFilters"> <template v-if="showAmountFilters">
<label class="field"> <a-input-number
<span>حداقل مبلغ</span> v-model:value="amountMinNum"
<input v-model="amountMin" type="number" min="0" placeholder="۰" /> :min="0"
</label> placeholder="حداقل مبلغ"
<label class="field"> :style="{ width: '140px', flex: '0 0 auto' }"
<span>حداکثر مبلغ</span> />
<input v-model="amountMax" type="number" min="0" placeholder="—" /> <a-input-number
</label> v-model:value="amountMaxNum"
:min="0"
placeholder="حداکثر مبلغ"
:style="{ width: '140px', flex: '0 0 auto' }"
/>
</template> </template>
</UiFilterBar> <div :style="{ flex: '1 1 240px', minWidth: '200px', maxWidth: '100%' }">
</header> <slot name="filters" />
</div>
</a-flex>
</a-flex>
</template> </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"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared'; import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue'; import {
import { UiInput, UiSelect } from '@/components/ui'; 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 { api } from '@/lib/api';
import { ENTITY_TYPE_LABELS } from '@/lib/routines'; import { ENTITY_TYPE_LABELS } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string }; export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
const props = withDefaults( const props = withDefaults(
@@ -33,6 +43,10 @@ const typeOptions = computed(() => {
return list; 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 type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
const q = ref(''); const q = ref('');
const results = ref<PickedEntity[]>([]); const results = ref<PickedEntity[]>([]);
@@ -99,126 +113,55 @@ function pick(item: PickedEntity) {
</script> </script>
<template> <template>
<div class="ent-picker"> <a-flex vertical :gap="8" style="width: 100%">
<div v-if="model" class="picked"> <a-flex v-if="model" justify="space-between" align="center" :gap="8">
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span> <a-space :size="8">
<strong>{{ model.label }}</strong> <a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[model.type] }}</a-tag>
<button type="button" aria-label="حذف اتصال" @click="model = null"> <a-typography-text strong>{{ model.label }}</a-typography-text>
<UiIcon name="x" :size="14" /> </a-space>
</button> <a-button type="text" aria-label="حذف اتصال" @click="model = null">
</div> <template #icon><CloseOutlined /></template>
</a-button>
</a-flex>
<template v-else-if="typeOptions.length"> <template v-else-if="typeOptions.length">
<div class="search-row"> <a-flex :gap="8" wrap="wrap">
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel"> <a-select
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option> v-if="typeOptions.length > 1"
</UiSelect> v-model:value="type"
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" /> :options="selectOptions"
</div> style="width: 120px"
<ul v-if="results.length || loading || q.trim().length >= 2" class="results"> />
<li v-if="loading" class="muted">در حال جستجو…</li> <a-input
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li> v-model:value="q"
<li v-for="r in results" :key="r.id"> :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`"
<button type="button" @click="pick(r)">{{ r.label }}</button> style="flex: 1; min-width: 160px"
</li> />
</ul> </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> </template>
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
</div> <a-typography-text v-else type="secondary">
ماژول لید/مشتری برای سازمان فعال نیست.
</a-typography-text>
</a-flex>
</template> </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, RoutineTaskDto,
RoutineTaskSource, RoutineTaskSource,
} from '@mento/shared'; } 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 JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.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 { api } from '@/lib/api';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
@@ -22,6 +35,8 @@ import {
} from '@/lib/routines'; } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
open: boolean; open: boolean;
@@ -54,7 +69,23 @@ const form = reactive({
const saving = ref(false); const saving = ref(false);
const error = ref(''); 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() { async function ensureContext() {
if (ctx.value) return; if (ctx.value) return;
@@ -105,7 +136,7 @@ async function submit() {
error.value = ''; error.value = '';
if (form.title.trim().length < 2) { if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.'; error.value = 'عنوان کار را وارد کنید.';
return; return Promise.reject();
} }
const link = props.preset ?? form.entity; const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = { const body: CreateRoutineTaskRequest = {
@@ -128,6 +159,7 @@ async function submit() {
emit('close'); emit('close');
} catch (e) { } catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.'); error.value = apiError(e, 'ثبت کار ممکن نشد.');
return Promise.reject();
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -135,119 +167,90 @@ async function submit() {
</script> </script>
<template> <template>
<UiModal :open="open" title="کار جدید" @close="emit('close')"> <a-modal
<form class="qt-form" @submit.prevent="submit"> :open="open"
<UiField label="عنوان" required> title="کار جدید"
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیش‌فاکتور" :maxlength="200" /> :confirm-loading="saving"
</UiField> ok-text="ثبت کار"
cancel-text="انصراف"
<UiField label="توضیحات"> destroy-on-close
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" /> @ok="submit"
</UiField> @cancel="emit('close')"
>
<div class="grid-2"> <a-row :gutter="[16, 0]">
<UiField label="تاریخ" required> <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" /> <JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
</UiField> </a-form-item>
<UiField label="ساعت" hint="خالی = تا پایان روز"> </a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
<JalaliDateInput v-model="form.time" mode="time" /> <JalaliDateInput v-model="form.time" mode="time" />
</UiField> </a-form-item>
<UiField label="اولویت"> </a-col>
<UiSelect v-model="form.priority"> <a-col :xs="24" :md="12">
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option> <a-form-item label="اولویت">
</UiSelect> <a-select
</UiField> v-model:value="form.priority"
<UiField label="دسته‌بندی"> :options="priorityOptions"
<UiSelect v-model="form.categoryId"> style="width: 100%"
<option value="">بدون دسته</option> />
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option> </a-form-item>
</UiSelect> </a-col>
</UiField> <a-col :xs="24" :md="12">
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2"> <a-form-item label="دسته‌بندی">
<UiSelect v-model="form.assigneeId"> <a-select
<option v-for="u in assignees" :key="u.id" :value="u.id"> v-model:value="form.categoryId"
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }} :options="categoryOptions"
</option> style="width: 100%"
</UiSelect> />
</UiField> </a-form-item>
</div> </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="اتصال به پرونده"> <a-alert
<div v-if="preset" class="preset"> v-if="error"
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span> type="error"
<strong>{{ preset.label }}</strong> show-icon
</div> :message="error"
<EntityLinkPicker v-else v-model="form.entity" /> :style="{ marginTop: '12px' }"
</UiField> />
</a-modal>
<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>
</template> </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"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; 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 type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue'; import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue'; import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue'; import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
import { UiButton } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { apiError, isOpenStatus } from '@/lib/routines'; import { apiError, isOpenStatus } from '@/lib/routines';
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions'; import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
const { Text: ATypographyText, Title: ATypographyTitle } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
entity: RoutineEntityRefDto; entity: RoutineEntityRefDto;
@@ -85,223 +97,90 @@ function onCreated(t: RoutineTaskDto) {
</script> </script>
<template> <template>
<section v-if="enabled" class="related"> <a-card v-if="enabled" :bordered="false" :style="{ marginTop: '16px' }">
<header> <a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<h3> <a-space :size="8">
<span class="mark"><UiIcon name="check" :size="15" /></span> <CheckOutlined />
{{ title }} <a-typography-title :level="5" :style="{ margin: 0 }">{{ title }}</a-typography-title>
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em> <a-tag v-if="open.length" :bordered="false">{{ toFaDigits(open.length) }} باز</a-tag>
</h3> </a-space>
<UiButton size="sm" variant="outline" @click="quickOpen = true"> <a-button @click="quickOpen = true">
<template #icon><UiIcon name="plus" :size="14" /></template> <template #icon><PlusOutlined /></template>
ایجاد Task ایجاد Task
</UiButton> </a-button>
</header> </a-flex>
<p v-if="error" class="error">{{ error }}</p> <a-alert
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p> v-if="error"
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div> type="error"
<div v-else-if="!items.length" class="empty"> show-icon
<div class="empty-art" aria-hidden="true"> :message="error"
<svg viewBox="0 0 72 72" width="72" height="72"> :style="{ marginTop: '12px' }"
<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" /> <a-alert
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /> v-if="actions.error.value"
</svg> type="error"
</div> show-icon
<p class="muted">{{ emptyTitle }}</p> :message="actions.error.value"
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p> :style="{ marginTop: '12px' }"
<button class="empty-cta" type="button" @click="quickOpen = true"> />
ایجاد Task
<UiIcon name="plus" :size="14" />
</button>
</div>
<div v-else class="rows"> <a-spin :spinning="loading && !items.length">
<RoutineTaskRow <a-empty
v-for="t in open" v-if="!loading && !items.length"
:key="t.id" :description="emptyTitle"
:task="t" :style="{ margin: '16px 0' }"
:busy="actions.busyId.value === t.id" >
show-date <template v-if="emptyHint" #description>
show-assignee <div>
show-delay <div>{{ emptyTitle }}</div>
@toggle="actions.toggle" <a-typography-text type="secondary">{{ emptyHint }}</a-typography-text>
@open="openTask" </div>
@action="onAction" </template>
/> <a-button type="primary" @click="quickOpen = true">
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed"> <template #icon><PlusOutlined /></template>
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجام‌شده / بسته ایجاد Task
</button> </a-button>
<template v-if="showClosed"> </a-empty>
<a-flex v-else vertical :gap="8" :style="{ marginTop: '12px' }">
<RoutineTaskRow <RoutineTaskRow
v-for="t in closed" v-for="t in open"
:key="t.id" :key="t.id"
:task="t" :task="t"
:busy="actions.busyId.value === t.id" :busy="actions.busyId.value === t.id"
show-date show-date
show-assignee show-assignee
show-delay
@toggle="actions.toggle" @toggle="actions.toggle"
@open="openTask" @open="openTask"
@action="onAction" @action="onAction"
/> />
</template> <a-button
</div> 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" /> <QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
<RoutineTaskModals :actions="actions" /> <RoutineTaskModals :actions="actions" />
</section> </a-card>
</template> </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"> <script setup lang="ts">
import { computed } from 'vue'; 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 JalaliDateInput from '@/components/JalaliDateInput.vue';
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions'; import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ actions: RoutineTaskActions }>(); const props = defineProps<{ actions: RoutineTaskActions }>();
const modal = computed(() => props.actions.modal); const modal = computed(() => props.actions.modal);
@@ -57,81 +67,63 @@ const submitLabel = computed(() => {
return 'ذخیره'; return 'ذخیره';
} }
}); });
const okDanger = computed(() => modal.value.kind === 'cancel');
</script> </script>
<template> <template>
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()"> <a-modal
<form class="rt-modal" @submit.prevent="actions.submit()"> :open="!!modal.kind"
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p> :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'"> <a-row v-if="modal.kind === 'reschedule'" :gutter="[16, 0]" :style="{ marginTop: '12px' }">
<div class="when"> <a-col :xs="24" :md="12">
<UiField label="تاریخ" required> <a-form-item label="تاریخ" required>
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" /> <JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
</UiField> </a-form-item>
<UiField label="ساعت" hint="خالی = تا پایان روز"> </a-col>
<JalaliDateInput v-model="modal.time" mode="time" /> <a-col :xs="24" :md="12">
</UiField> <a-form-item label="ساعت" extra="خالی = تا پایان روز">
</div> <JalaliDateInput v-model="modal.time" mode="time" />
</template> </a-form-item>
</a-col>
</a-row>
<UiField v-else :label="noteLabel" :required="noteRequired"> <a-form-item
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" /> v-else
</UiField> :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"> <a-typography-text v-if="modal.kind === 'cancel'" type="secondary">
کار لغو شده در محاسبه درصد انجام حساب نمی‌شود. کار لغو شده در محاسبه درصد انجام حساب نمی‌شود.
</p> </a-typography-text>
<p v-if="modal.error" class="error">{{ modal.error }}</p>
</form>
<template #footer> <a-alert
<UiButton v-if="modal.error"
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'" type="error"
:loading="modal.saving" show-icon
@click="actions.submit()" :message="modal.error"
> :style="{ marginTop: '12px' }"
{{ submitLabel }} />
</UiButton> </a-modal>
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
</template>
</UiModal>
</template> </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"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import type { RoutineTaskDto } from '@mento/shared'; 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 UserAvatar from '@/components/UserAvatar.vue';
import { UiBadge, UiDropdownMenu } from '@/components/ui';
import { import {
ENTITY_TYPE_LABELS, ENTITY_TYPE_LABELS,
PRIORITY_LABELS, PRIORITY_LABELS,
PRIORITY_VARIANTS,
ROUTINE_TASK_SOURCE_LABELS, ROUTINE_TASK_SOURCE_LABELS,
ROUTINE_TASK_STATUS_LABELS, ROUTINE_TASK_STATUS_LABELS,
STATUS_VARIANTS,
entityRoute, entityRoute,
formatDayNumeric, formatDayNumeric,
formatDelay, formatDelay,
@@ -20,6 +36,9 @@ import {
isOpenStatus, isOpenStatus,
} from '@/lib/routines'; } from '@/lib/routines';
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
Typography;
export type RoutineTaskRowAction = export type RoutineTaskRowAction =
| 'start' | 'start'
| 'complete-note' | 'complete-note'
@@ -45,396 +64,135 @@ const emit = defineEmits<{
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto]; action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
}>(); }>();
const menuOpen = ref(false);
const done = computed(() => props.task.status === 'completed'); const done = computed(() => props.task.status === 'completed');
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled'); const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
const open = computed(() => isOpenStatus(props.task.status)); const open = computed(() => isOpenStatus(props.task.status));
const showTime = computed(() => hasExplicitTime(props.task)); const showTime = computed(() => hasExplicitTime(props.task));
const checkLabel = computed(() => const checkLabel = computed(() =>
done.value ? `برگرداندن «${props.task.title}» به حالت انجام‌نشده` : `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`, done.value
? `برگرداندن «${props.task.title}» به حالت انجام‌نشده`
: `علامت‌زدن «${props.task.title}» به‌عنوان انجام‌شده`,
); );
function act(kind: RoutineTaskRowAction) { function act(kind: RoutineTaskRowAction) {
emit('action', kind, props.task); 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> </script>
<template> <template>
<article <a-flex
class="rt-row" align="flex-start"
:class="[`st-${task.status}`, { done, closed, busy }]" :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)" @click="emit('open', task)"
> >
<button <a-checkbox
type="button" :checked="done"
class="rt-check"
:class="{ on: done }"
:disabled="busy || closed" :disabled="busy || closed"
:aria-pressed="done"
:aria-label="checkLabel" :aria-label="checkLabel"
@click.stop="emit('toggle', task)" @click.stop
> @update:checked="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>
<div class="rt-main"> <a-flex vertical :gap="4" style="flex: 1; min-width: 0">
<div class="rt-title-line"> <a-space :size="6">
<h3 class="rt-title">{{ task.title }}</h3> <a-typography-title
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" /> :level="5"
</div> :style="{
<p v-if="task.description" class="rt-desc">{{ task.description }}</p> margin: 0,
textDecoration: done ? 'line-through' : undefined,
}"
>
{{ task.title }}
</a-typography-title>
<FlagOutlined v-if="task.isMandatory" />
</a-space>
<div class="rt-meta"> <a-typography-paragraph
<span v-if="showDate" class="meta-item"> v-if="task.description"
<UiIcon name="calendar" :size="13" /> type="secondary"
{{ formatDayNumeric(task.scheduledFor) }} :ellipsis="{ rows: 1 }"
</span> :style="{ marginBottom: 0 }"
<span v-if="showTime" class="meta-item"> >
<UiIcon name="clock" :size="13" /> {{ task.description }}
{{ formatTime(task.dueAt) }} </a-typography-paragraph>
</span>
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late"> <a-space wrap :size="[8, 4]">
<UiIcon name="warning" :size="13" /> <a-typography-text v-if="showDate" type="secondary">
{{ formatDelay(task.dueAt) }} تأخیر <CalendarOutlined /> {{ formatDayNumeric(task.scheduledFor) }}
</span> </a-typography-text>
<span v-if="done && task.completedAt" class="meta-item ok"> <a-typography-text v-if="showTime" type="secondary">
<UiIcon name="check" :size="13" /> <ClockCircleOutlined /> {{ formatTime(task.dueAt) }}
{{ formatTime(task.completedAt) }} </a-typography-text>
</span> <a-typography-text
<span v-if="task.category" class="cat-chip"> v-if="showDelay && task.status === 'overdue'"
<i :style="{ background: task.category.color }" /> type="danger"
{{ task.category.name }} >
</span> <WarningOutlined /> {{ formatDelay(task.dueAt) }} تأخیر
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]"> </a-typography-text>
{{ PRIORITY_LABELS[task.priority] }} <a-typography-text v-if="done && task.completedAt" type="success">
</UiBadge> <CheckOutlined /> {{ formatTime(task.completedAt) }}
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]"> </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] }} {{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
</UiBadge> </a-tag>
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`"> <a-tag :bordered="false">{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</a-tag>
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
</span>
<RouterLink <RouterLink
v-for="ent in task.entities" v-for="ent in task.entities"
:key="`${ent.type}-${ent.id}`" :key="`${ent.type}-${ent.id}`"
class="ent-link"
:to="entityRoute(ent)" :to="entityRoute(ent)"
@click.stop @click.stop
> >
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span> <a-tag color="processing" :bordered="false">
{{ ent.label }} {{ ENTITY_TYPE_LABELS[ent.type] }} · {{ ent.label }}
</a-tag>
</RouterLink> </RouterLink>
</div> </a-space>
</div> </a-flex>
<div class="rt-side" @click.stop> <a-space :size="8" @click.stop>
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName"> <a-space v-if="showAssignee && task.assignee" :size="6">
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" /> <UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
<span class="assignee-name">{{ task.assignee.fullName }}</span> <a-typography-text type="secondary">{{ task.assignee.fullName }}</a-typography-text>
</span> </a-space>
<UiDropdownMenu v-model:open="menuOpen" align="end"> <a-dropdown>
<template #trigger="{ toggle }"> <a-button type="text" aria-label="عملیات بیشتر">
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle"> <template #icon><MoreOutlined /></template>
<UiIcon name="dots" :size="18" /> </a-button>
</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> </template>
<button type="button" @click="emit('open', task)">جزئیات</button> </a-dropdown>
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button> </a-space>
<button v-if="open || done" type="button" @click="act('complete-note')"> </a-flex>
{{ 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>
</template> </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
@@ -1084,6 +1084,7 @@ onUnmounted(() => {
.workspace { .workspace {
min-width: 0; min-width: 0;
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
@@ -1904,10 +1905,10 @@ onUnmounted(() => {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 1rem; gap: 16px;
min-width: 0; min-width: 0;
/* همان فاصله بالای سایدبار شناور (14px) */ /* هم‌تراز با پدینگ ۱۶px محتوای workspace */
padding: 14px 1.5rem 0.85rem; padding: 16px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 20; z-index: 20;
@@ -2113,18 +2114,29 @@ onUnmounted(() => {
} }
.content { .content {
padding: 0 1.5rem 1.5rem; flex: 1 1 auto;
min-height: 0;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
padding: 16px;
overflow-x: clip; 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) { @media (max-width: 1280px) and (min-width: 901px) {
.content { .content {
padding-inline: 1rem; padding: 16px;
} }
.desktop-topbar { .desktop-topbar {
padding-inline: 1rem; padding: 16px;
} }
} }
@@ -2253,7 +2265,7 @@ onUnmounted(() => {
} }
.content { .content {
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h)); padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
} }
.notif-layer { .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"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios'; 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 { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared'; 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 loading = ref(false);
const moving = ref(false); const moving = ref(false);
const error = ref(''); const error = ref('');
const columns = ref<LeadBoardColumnDto[]>([]); const columns = ref<LeadBoardColumnDto[]>([]);
const draggingId = ref<string | null>(null); const draggingId = ref<string | null>(null);
const dropStageId = 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) { function formatDate(value: string | null) {
if (!value) return ''; 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) { function onDragStart(lead: LeadDto, event: DragEvent) {
suppressCardClick = true;
draggingId.value = lead.id; draggingId.value = lead.id;
event.dataTransfer?.setData('text/plain', lead.id); event.dataTransfer?.setData('text/plain', lead.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
@@ -48,6 +189,9 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
function onDragEnd() { function onDragEnd() {
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
window.setTimeout(() => {
suppressCardClick = false;
}, 0);
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
@@ -106,183 +250,278 @@ onMounted(load);
</script> </script>
<template> <template>
<section class="page"> <div class="lead-board-page" :style="pageStyle">
<UiPageHeader title="مسیر لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت"> <a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
<template #actions> <div>
<RouterLink :to="{ name: 'leads' }" class="link-btn"> <a-typography-title :level="3" :style="{ margin: 0 }">
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton> مسیر لیدها
</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> </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> </a-button>
</template> </a-space>
</UiPageHeader> </a-flex>
<p v-if="error" class="error">{{ error }}</p> <a-alert v-if="error" type="error" show-icon :message="error" />
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="columns.length" class="board"> <div :style="boardShellStyle">
<section <div
v-for="col in columns" v-if="loading || moving"
:key="col.stage.id" :style="{
class="column" position: 'absolute',
:class="{ drop: dropStageId === col.stage.id }" inset: 0,
@dragover="onDragOver(col.stage.id, $event)" zIndex: 2,
@drop="onDrop(col.stage.id, $event)" display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgMask,
}"
> >
<header class="col-head"> <a-spin />
<span </div>
class="dot"
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
</header>
<div class="cards"> <a-empty
<article v-if="!loading && !columns.length"
v-for="lead in col.leads" description="مرحله‌ای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
:key="lead.id" :style="{ margin: 'auto' }"
class="card" />
:class="{ dragging: draggingId === lead.id }"
draggable="true" <div
@dragstart="onDragStart(lead, $event)" v-else-if="columns.length"
@dragend="onDragEnd" class="lead-board-track"
> :style="boardTrackStyle"
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }"> >
{{ lead.title }} <div
</RouterLink> v-for="col in columns"
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p> :key="col.stage.id"
<p v-if="lead.city" class="line">{{ lead.city }}</p> :style="columnStyle(col.stage.id)"
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p> @dragover="onDragOver(col.stage.id, $event)"
<p v-if="lead.nextFollowUpAt" class="line muted"> @drop="onDrop(col.stage.id, $event)"
پیگیری: {{ formatDate(lead.nextFollowUpAt) }} >
</p> <div :style="columnHeadStyle">
</article> <a-space :size="8">
<p v-if="!col.leads.length" class="empty-col">خالی</p> <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> </div>
</section> </div>
</div> </div>
</div>
<UiEmptyState
v-else-if="!loading"
title="مرحله‌ای برای نمایش در مسیر لیدها نیست."
description="در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
/>
</section>
</template> </template>
<style scoped> <style scoped>
.page { /* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ری‌اسکین Ant */
display: grid; .lead-board-page {
gap: 1rem; width: 100%;
min-width: 0;
} }
.link-btn { .lead-board-track {
text-decoration: none; scrollbar-width: thin;
} }
.board { .lead-board-track::-webkit-scrollbar {
display: flex; height: 8px;
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
align-items: flex-start;
min-height: 24rem;
} }
.column { .lead-board-track::-webkit-scrollbar-thumb {
flex: 0 0 16.5rem; border-radius: 999px;
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff); background: color-mix(in srgb, currentColor 28%, transparent);
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;
} }
</style> </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 { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router'; import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; 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 { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared'; import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; 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 CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue'; import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue'; import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import { const ASpaceCompact = Space.Compact;
UiButton, const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
UiCard, Typography;
UiField, const AListItem = AList.Item;
UiInput, const AListItemMeta = AList.Item.Meta;
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported(); const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false); const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false); const sourceOpen = ref(false);
const pickOpen = ref(false); const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]); const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref(''); const pickQuery = ref('');
const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() => const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value), filterPhoneContacts(pickDrafts.value, pickQuery.value),
); );
const companyOptions = computed(() => [
{ value: '', label: 'بدون شرکت' },
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const backTo = computed(() => const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } }, isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
); );
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true; sourceOpen.value = true;
return; 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() { async function pickFromDevice() {
if (pickingContact.value || saving.value) return; if (pickingContact.value || saving.value) return;
pickingContact.value = true; pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return; if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') { if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click(); triggerVcfPick();
return; return;
} }
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.'; error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
function pickFromVcf() { function pickFromVcf() {
sourceOpen.value = false; sourceOpen.value = false;
contactFileInput.value?.click(); triggerVcfPick();
}
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;
}
} }
function fill(p: PersonDto) { function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
} }
} }
async function remove() { function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return; if (!id.value) return;
try { Modal.confirm({
await api.delete(`/people/${id.value}`); title: 'حذف شخص',
await router.push({ name: 'contacts' }); content: 'این شخص حذف شود؟',
} catch { okText: 'حذف',
error.value = 'حذف ناموفق بود'; 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() { function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
</script> </script>
<template> <template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }"> <div>
<!-- Desktop Header --> <a-flex
<UiPageHeader
v-if="!embedded" v-if="!embedded"
:title="pageTitle" justify="space-between"
:back-to="backTo" align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
> >
<template #lead> <div>
<span class="crumb"> <a-typography-text type="secondary">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink> <RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span> · {{ pageTitle }}
<strong>{{ pageTitle }}</strong> </a-typography-text>
</span> <a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
</template> </div>
<template #actions> <a-space :size="8" wrap>
<UiButton variant="primary" :disabled="saving" @click="submit"> <a-button @click="cancel">
<UiIcon name="check" :size="16" /> <template #icon><ArrowLeftOutlined /></template>
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }} انصراف
</UiButton> </a-button>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton> <a-button v-if="!isNew" danger :disabled="saving" @click="remove">
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove"> <template #icon><DeleteOutlined /></template>
حذف حذف
</UiButton> </a-button>
</template> <a-button type="primary" :loading="saving" @click="submit">
</UiPageHeader> <template #icon><CheckOutlined /></template>
ذخیره
</a-button>
</a-space>
</a-flex>
<p v-if="loading" class="muted">در حال بارگذاری…</p> <a-spin :spinning="loading">
<form v-else class="crm-form" @submit.prevent="submit"> <a-form v-if="!loading" layout="vertical" @finish="submit">
<UiCard> <a-card :bordered="false">
<div class="crm-form-grid-2"> <a-divider orientation="right">اطلاعات اصلی</a-divider>
<UiField label="نام" for-id="firstName"> <a-row :gutter="[16, 0]">
<UiInput id="firstName" v-model="firstName" /> <a-col :xs="24" :md="12">
</UiField> <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"> <a-divider orientation="right">آدرس‌ها</a-divider>
<UiInput id="lastName" v-model="lastName" /> <a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
</UiField> <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"> <a-divider orientation="right">وضعیت و شرکت</a-divider>
<template #label-extra> <a-form-item label="وضعیت مشتری">
<button <a-row :gutter="[8, 8]">
type="button" <a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
class="from-contact" <a-button
:disabled="pickingContact || saving" block
@click="fillFromPhoneContact" :type="mood === item ? 'primary' : 'default'"
> :ghost="mood === item"
<UiIcon name="phone" :size="14" /> :style="{ height: 'auto', paddingBlock: '12px' }"
{{ 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 }"
@click="mood = item" @click="mood = item"
> >
<CustomerMoodFace :mood="item" :size="40" /> <a-flex vertical align="center" :gap="6">
<span>{{ PERSON_MOOD_LABELS[item] }}</span> <CustomerMoodFace :mood="item" :size="40" />
</button> <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> </div>
</UiField> <RouterLink :to="{ name: 'loyalty' }">
</div> <a-button type="link">مدیریت باشگاه</a-button>
</RouterLink>
</a-flex>
</a-card>
<div class="crm-col-span-full"> <CustomFieldsSection entity-type="person" v-model="customFields" />
<UiField label="شرکت" for-id="companyId"> </a-card>
<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>
<div class="crm-col-span-full"> <MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<UiField label="برچسب‌ها" for-id="tags"> <EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<div class="crm-col-span-full"> <a-alert
<UiField label="یادداشت" for-id="notes"> v-if="error"
<UiTextarea id="notes" v-model="notes" :rows="3" /> type="error"
</UiField> show-icon
</div> :message="error"
:style="{ marginTop: '16px' }"
/>
<div v-if="!isNew" class="crm-col-span-full loyalty-card"> <a-space
<div class="loyalty-info"> v-if="embedded"
<span class="muted">باشگاه مشتریان</span> wrap
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong> :size="8"
</div> :style="{ marginTop: '16px' }"
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink> >
</div> <a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
</div> <a-button :disabled="saving" @click="cancel">انصراف</a-button>
</UiCard> </a-space>
<CustomFieldsSection entity-type="person" v-model="customFields" /> <a-space
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" /> v-else
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" /> 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"> <a-modal
<UiButton variant="primary" block type="submit" :disabled="saving"> :open="sourceOpen"
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }} title="از مخاطبین"
</UiButton> :footer="null"
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove"> destroy-on-close
حذف @cancel="closeSource"
</UiButton> >
</div> <a-typography-paragraph type="secondary">
</form> چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</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) --> <a-modal
<div v-if="embedded" class="embed-actions"> :open="pickOpen"
<UiButton variant="primary" :disabled="saving" @click="submit"> title="انتخاب مخاطب"
<UiIcon name="check" :size="16" /> destroy-on-close
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }} @cancel="closePick"
</UiButton> >
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton> <a-typography-paragraph type="secondary">
</div> {{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
</section> یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</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> </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, updateDealStage,
} from '@/lib/pipeline-board'; } from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue'; 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 DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.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 type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import { import {
DEAL_EXCEL_ALIASES, DEAL_EXCEL_ALIASES,
@@ -30,6 +46,9 @@ import {
EXCEL_CUSTOM_FIELD_PREFIX, EXCEL_CUSTOM_FIELD_PREFIX,
brand, brand,
} from '@mento/shared'; } 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) => ({ const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key, key: c.key,
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
rawValues: true, rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX, customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
}; };
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles'; const { token } = theme.useToken();
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu'; 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 route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -62,7 +96,6 @@ const amountMax = ref('');
const showCreate = ref(false); const showCreate = ref(false);
const newName = ref(''); const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null); const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false); const stageBusy = ref(false);
const busy = ref(false); const busy = ref(false);
const importMsg = ref(''); const importMsg = ref('');
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
} }
function onDeleteStage(stageId: 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 dealDeleting = ref(false);
const selectMode = ref(false); const selectMode = ref(false);
const selectedIds = ref<Set<string>>(new Set()); const selectedIds = ref<Set<string>>(new Set());
const selectedCount = computed(() => selectedIds.value.size); const selectedCount = computed(() => selectedIds.value.size);
const deleteDealTitle = computed(() => { function dealTitleById(dealId: string) {
const ids = deleteDealIds.value;
if (ids.length !== 1) return '';
for (const s of stages.value) { 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; if (d) return d.title;
} }
return ''; 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) { function askDeleteDeal(dealId: string) {
menuOpenId.value = null; menuOpenId.value = null;
deleteDealIds.value = [dealId]; confirmDeleteDeals([dealId]);
} }
function startSelect(dealId: string) { function startSelect(dealId: string) {
@@ -460,31 +481,43 @@ function clearSelect() {
function askBulkDelete() { function askBulkDelete() {
if (!selectedIds.value.size) return; if (!selectedIds.value.size) return;
menuOpenId.value = null; menuOpenId.value = null;
deleteDealIds.value = [...selectedIds.value]; confirmDeleteDeals([...selectedIds.value]);
} }
async function confirmDeleteDeal() { function confirmDeleteDeals(ids: string[]) {
const ids = deleteDealIds.value;
if (!ids.length || dealDeleting.value) return; if (!ids.length || dealDeleting.value) return;
dealDeleting.value = true; const copy =
error.value = ''; ids.length === 1
try { ? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`))); : `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
const failed = results.filter((r) => r.status === 'rejected').length; Modal.confirm({
deleteDealIds.value = []; title: ids.length > 1 ? 'حذف معامله‌ها' : 'حذف معامله',
clearSelect(); content: copy,
await load(); okText: 'حذف',
if (failed) { cancelText: 'انصراف',
error.value = okButtonProps: { danger: true },
failed === ids.length async onOk() {
? 'حذف معامله‌ها ناموفق بود.' dealDeleting.value = true;
: `${toFaDigits(failed)} مورد حذف نشد.`; error.value = '';
} try {
} catch (e) { const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
error.value = apiMessage(e, 'حذف معامله ناموفق بود.'); const failed = results.filter((r) => r.status === 'rejected').length;
} finally { clearSelect();
dealDeleting.value = false; 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() { 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( const unbindMenuDismiss = bindMenuDismiss(
() => menuOpenId.value != null, () => menuOpenId.value != null,
() => (menuOpenId.value = null), () => (menuOpenId.value = null),
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</script> </script>
<template> <template>
<section class="page" dir="rtl"> <div class="pipeline-page" dir="rtl" :style="pageStyle">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" /> <a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
<PipelineToolbar <PipelineToolbar
pipeline-name="مسیر فروش" pipeline-name="مسیر فروش"
:pipeline-options="pipelineOptions" :pipeline-options="pipelineOptions"
v-model:selected-pipeline-id="selectedPipelineId" v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode" v-model:view-mode="viewMode"
v-model:search="search" v-model:search="search"
v-model:amount-min="amountMin" v-model:amount-min="amountMin"
v-model:amount-max="amountMax" v-model:amount-max="amountMax"
:can-create="canCreatePipeline" :can-create="canCreatePipeline"
@create="showCreate = true" @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"
> >
حذف انتخاب‌شده‌ها <template #excel>
</button> <a-button :disabled="loading || busy" @click="exportExcel">
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button> <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> </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"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -734,109 +819,62 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</Teleport> </Teleport>
<Teleport to="body"> <Teleport to="body">
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined"> <div
<button type="button" role="menuitem" @click="startSelect(menuOpenId)"> v-if="menuOpenId"
انتخاب :style="{
</button> position: 'fixed',
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)"> zIndex: 1080,
مشاهده معامله ...(menuStyle ?? {}),
</button> }"
<button >
type="button" <a-menu
role="menuitem" :style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null" @click="onDealMenuClick"
> >
ویرایش <a-menu-item key="select">انتخاب</a-menu-item>
</button> <a-menu-item key="view">مشاهده معامله</a-menu-item>
<button <a-menu-item key="edit">ویرایش</a-menu-item>
v-if="canPromotePrior(findDeal(menuOpenId))" <a-menu-item
type="button" v-if="canPromotePrior(findDeal(menuOpenId))"
role="menuitem" key="promote"
@click="promotePriorDeal(menuOpenId)" >
> انتقال به کار جاری
انتقال به کار جاری </a-menu-item>
</button> <a-menu-item key="delete" danger>حذف</a-menu-item>
<button type="button" role="menuitem" class="danger" @click="askDeleteDeal(menuOpenId)"> <a-menu-item key="close">بستن</a-menu-item>
حذف </a-menu>
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
</div> </div>
</Teleport> </Teleport>
<UiModal <a-modal
:open="showCreate && canCreatePipeline" :open="showCreate && canCreatePipeline"
title="مسیر فروش جدید" 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"> <a-form layout="vertical" @finish="createPipeline">
<label class="field"> <a-form-item label="نام مسیر فروش" required>
<span>نام مسیر فروش</span> <a-input
<UiInput v-model:value="newName"
v-model="newName"
placeholder="نام مسیر فروش جدید…" placeholder="نام مسیر فروش جدید…"
:maxlength="120" :maxlength="120"
required
:minlength="2"
/> />
</label> </a-form-item>
</form> </a-form>
<template #footer> </a-modal>
<UiButton
type="button"
:disabled="saving || newName.trim().length < 2"
@click="createPipeline"
>
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="showCreate = false">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal <a-modal
: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
:open="Boolean(dealModal)" :open="Boolean(dealModal)"
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'" :title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
@close="dealModal = null" :footer="null"
destroy-on-close
width="720px"
@cancel="dealModal = null"
> >
<DealFormView <DealFormView
v-if="dealModal" v-if="dealModal"
@@ -847,117 +885,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@saved="onDealSaved" @saved="onDealSaved"
@cancel="dealModal = null" @cancel="dealModal = null"
/> />
</UiModal> </a-modal>
</section> </div>
</template> </template>
<style scoped> <style scoped>
.page { /* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
display: grid; .pipeline-page {
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 {
margin: 0; margin: 0;
line-height: 1.7; width: 100%;
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);
} }
</style> </style>
+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", "name": "@mento/web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@mento/shared": "*", "@mento/shared": "*",
"ant-design-vue": "^4.2.6",
"axios": "^1.8.4", "axios": "^1.8.4",
"echarts": "^6.1.0", "echarts": "^6.1.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
@@ -184,6 +186,34 @@
"tslib": "^2.1.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"dev": true, "dev": true,
@@ -224,6 +254,15 @@
"node": ">=6.0.0" "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": { "node_modules/@babel/types": {
"version": "7.29.8", "version": "7.29.8",
"license": "MIT", "license": "MIT",
@@ -252,6 +291,27 @@
"node": ">=0.1.90" "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": { "node_modules/@esbuild/linux-x64": {
"version": "0.28.2", "version": "0.28.2",
"cpu": [ "cpu": [
@@ -263,7 +323,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -279,7 +338,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1070,6 +1128,16 @@
"win32" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"devOptional": true, "devOptional": true,
@@ -1739,6 +1807,46 @@
"node": ">=14" "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": { "node_modules/append-field": {
"version": "1.0.0", "version": "1.0.0",
"license": "MIT" "license": "MIT"
@@ -1830,10 +1938,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"license": "MIT" "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": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"license": "MIT" "license": "MIT"
@@ -2355,6 +2475,12 @@
"node": ">= 10" "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": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"license": "MIT" "license": "MIT"
@@ -2427,6 +2553,20 @@
"url": "https://github.com/sponsors/mesqueeb" "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": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"license": "MIT" "license": "MIT"
@@ -2586,6 +2726,18 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/dotenv": {
"version": "17.4.1", "version": "17.4.1",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
@@ -3530,6 +3682,15 @@
"node": ">=8" "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": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"license": "MIT" "license": "MIT"
@@ -3593,7 +3754,6 @@
}, },
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -3812,6 +3972,12 @@
"version": "4.18.1", "version": "4.18.1",
"license": "MIT" "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": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"license": "MIT" "license": "MIT"
@@ -3899,6 +4065,18 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lru-cache": {
"version": "11.5.2", "version": "11.5.2",
"dev": true, "dev": true,
@@ -4119,6 +4297,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/negotiator": {
"version": "1.1.0", "version": "1.1.0",
"license": "MIT", "license": "MIT",
@@ -4685,6 +4869,12 @@
"node": ">=0.10.0" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"dev": true, "dev": true,
@@ -4896,6 +5086,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"license": "ISC", "license": "ISC",
@@ -4955,6 +5154,12 @@
"version": "1.2.0", "version": "1.2.0",
"license": "ISC" "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": { "node_modules/side-channel": {
"version": "1.1.1", "version": "1.1.1",
"license": "MIT", "license": "MIT",
@@ -5147,6 +5352,12 @@
"url": "https://github.com/sponsors/Borewit" "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": { "node_modules/superjson": {
"version": "2.2.6", "version": "2.2.6",
"license": "MIT", "license": "MIT",
@@ -5317,6 +5528,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tinyexec": {
"version": "1.3.0", "version": "1.3.0",
"devOptional": true, "devOptional": true,
@@ -5820,6 +6040,30 @@
"typescript": ">=5.0.0" "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": { "node_modules/watchpack": {
"version": "2.5.2", "version": "2.5.2",
"dev": true, "dev": true,