merge: bring Ant Design UI from dev into main
Keep the design refresh together with DISC, the sales copilot, and treasury behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -10,7 +10,9 @@
|
||||
"lint": "echo \"web lint placeholder\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@mento/shared": "*",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.8.4",
|
||||
"echarts": "^6.1.0",
|
||||
"pinia": "^3.0.1",
|
||||
|
||||
+11
-1
@@ -1,7 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import {
|
||||
ConfigProvider as AConfigProvider,
|
||||
StyleProvider as AStyleProvider,
|
||||
} from 'ant-design-vue';
|
||||
import faIR from 'ant-design-vue/es/locale/fa_IR';
|
||||
import { antdTheme } from '@/theme/antdTheme';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<a-style-provider hash-priority="high">
|
||||
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
|
||||
<RouterView />
|
||||
</a-config-provider>
|
||||
</a-style-provider>
|
||||
</template>
|
||||
|
||||
Vendored
+66
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { UiField, UiInput, UiSelect } from '@/components/ui';
|
||||
import type {
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
} from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: CustomFieldEntity;
|
||||
modelValue: Record<string, unknown>;
|
||||
@@ -46,6 +58,10 @@ function displayValue(key: string) {
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function selectOptions(field: CustomFieldDefinitionDto) {
|
||||
return (field.options ?? []).map((opt) => ({ value: opt, label: opt }));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.entityType,
|
||||
() => {
|
||||
@@ -57,110 +73,61 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="muted">بارگذاری فیلدهای سفارشی…</div>
|
||||
<div v-else-if="fields.length" class="custom-block">
|
||||
<h3 class="custom-title">فیلدهای سفارشی</h3>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField
|
||||
v-for="field in fields"
|
||||
:key="field.id"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
:for-id="`cf-${field.key}`"
|
||||
>
|
||||
<UiSelect
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event)"
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option v-for="opt in field.options" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
|
||||
<label v-else-if="field.fieldType === 'boolean'" class="cf-check">
|
||||
<input
|
||||
<a-spin v-if="loading" />
|
||||
<template v-else-if="fields.length">
|
||||
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
|
||||
فیلدهای سفارشی
|
||||
</a-typography-title>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
type="checkbox"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@change="
|
||||
setValue(field.key, ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
allow-clear
|
||||
:value="String(displayValue(field.key)) || undefined"
|
||||
:options="selectOptions(field)"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event ?? '')"
|
||||
/>
|
||||
<span>فعال</span>
|
||||
</label>
|
||||
|
||||
<UiInput
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
type="number"
|
||||
:model-value="props.modelValue[field.key] != null ? Number(props.modelValue[field.key]) : ''"
|
||||
@update:model-value="
|
||||
setValue(
|
||||
field.key,
|
||||
$event === '' || $event == null ? null : Number($event),
|
||||
)
|
||||
"
|
||||
/>
|
||||
<a-checkbox
|
||||
v-else-if="field.fieldType === 'boolean'"
|
||||
:id="`cf-${field.key}`"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@update:checked="setValue(field.key, $event)"
|
||||
>
|
||||
فعال
|
||||
</a-checkbox>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
:value="
|
||||
props.modelValue[field.key] != null
|
||||
? Number(props.modelValue[field.key])
|
||||
: undefined
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||
/>
|
||||
|
||||
<UiInput
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
type="text"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event)"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</div>
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@update:value="setValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px dashed var(--line);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.cf-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--control-h-md);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-medium);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cf-check input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const ASpaceCompact = ASpace.Compact;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDateTime } from '@/lib/jalali';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
@@ -11,6 +29,9 @@ import type {
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: 'person' | 'company' | 'deal';
|
||||
entityId: string;
|
||||
@@ -187,210 +208,102 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="timeline">
|
||||
<header class="head">
|
||||
<h3>تایملاین</h3>
|
||||
<p class="muted">یادداشتها و فعالیتهای مرتبط با این پرونده</p>
|
||||
</header>
|
||||
<a-card :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">تایملاین</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
یادداشتها و فعالیتهای مرتبط با این پرونده
|
||||
</a-typography-text>
|
||||
|
||||
<div class="composers">
|
||||
<div class="composer">
|
||||
<textarea
|
||||
v-model="noteDraft"
|
||||
rows="2"
|
||||
placeholder="یادداشت سریع…"
|
||||
@keydown.ctrl.enter.prevent="addNote"
|
||||
<a-flex vertical :gap="12" :style="{ marginTop: '16px' }">
|
||||
<a-textarea
|
||||
v-model:value="noteDraft"
|
||||
:rows="2"
|
||||
placeholder="یادداشت سریع…"
|
||||
@keydown.ctrl.enter.prevent="addNote"
|
||||
/>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<VoiceToTextButton
|
||||
@transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))"
|
||||
/>
|
||||
<div class="compose-actions">
|
||||
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
|
||||
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
|
||||
ثبت یادداشت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<input
|
||||
v-model="activityDraft"
|
||||
type="text"
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || !noteDraft.trim()"
|
||||
:loading="saving"
|
||||
@click="addNote"
|
||||
>
|
||||
ثبت یادداشت
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-space-compact block style="width: 100%">
|
||||
<a-input
|
||||
v-model:value="activityDraft"
|
||||
placeholder="عنوان فعالیت / پیگیری…"
|
||||
@keydown.enter.prevent="addActivity"
|
||||
style="width: 100%"
|
||||
@pressEnter="addActivity"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
<a-button
|
||||
:disabled="saving || !activityDraft.trim()"
|
||||
@click="addActivity"
|
||||
>
|
||||
افزودن فعالیت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</a-button>
|
||||
</a-space-compact>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<ul v-else-if="items.length" class="list">
|
||||
<li v-for="item in items" :key="item.key" :class="['item', item.kind, { done: item.done }]">
|
||||
<div class="item-main">
|
||||
<span class="badge">{{ item.meta }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p v-if="item.body" class="body">{{ item.body }}</p>
|
||||
<time>{{ formatTime(item.at) }}</time>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<RouterLink :to="item.href">باز کردن</RouterLink>
|
||||
<button
|
||||
v-if="item.kind === 'activity' && !item.done"
|
||||
class="linkish"
|
||||
type="button"
|
||||
@click="completeActivity(item)"
|
||||
>
|
||||
انجام شد
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">هنوز چیزی در تایملاین نیست. یک یادداشت یا فعالیت ثبت کنید.</p>
|
||||
</section>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-list
|
||||
v-if="items.length"
|
||||
item-layout="horizontal"
|
||||
:data-source="items"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<a-space :size="8">
|
||||
<a-tag :bordered="false">{{ item.meta }}</a-tag>
|
||||
<span>{{ item.title }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #description>
|
||||
<a-typography-paragraph
|
||||
v-if="item.body"
|
||||
type="secondary"
|
||||
:style="{ marginBottom: '4px' }"
|
||||
>
|
||||
{{ item.body }}
|
||||
</a-typography-paragraph>
|
||||
<a-typography-text type="secondary">{{ formatTime(item.at) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<RouterLink :to="item.href">باز کردن</RouterLink>
|
||||
<a-button
|
||||
v-if="item.kind === 'activity' && !item.done"
|
||||
type="link"
|
||||
@click="completeActivity(item)"
|
||||
>
|
||||
انجام شد
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-empty
|
||||
v-else-if="!loading"
|
||||
description="هنوز چیزی در تایملاین نیست. یک یادداشت یا فعالیت ثبت کنید."
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--line);
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.head h3 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.head .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.composers {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.composer textarea,
|
||||
.composer input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fbfcfb;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.item {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
justify-items: start;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.item.note {
|
||||
border-right: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.item.activity {
|
||||
border-right: 3px solid #b7791f;
|
||||
}
|
||||
|
||||
.item.done {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.item-main {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
justify-items: end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-actions a,
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,20 @@ import {
|
||||
readMappedRowsClient,
|
||||
type ClientExcelReadOptions,
|
||||
} from '@/lib/excel-client';
|
||||
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
||||
import {
|
||||
Col as ACol,
|
||||
Flex as AFlex,
|
||||
Modal as AModal,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
Upload as AUpload,
|
||||
} from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
const props = defineProps<{
|
||||
previewPath: string;
|
||||
@@ -48,7 +61,6 @@ const emit = defineEmits<{
|
||||
settled: [];
|
||||
}>();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const pending = ref<File | null>(null);
|
||||
const preview = ref<ExcelPreviewResult | null>(null);
|
||||
const mapping = reactive<Record<string, string>>({});
|
||||
@@ -77,11 +89,6 @@ const busyProgress = computed(() =>
|
||||
phase.value === 'import' ? progressPct.value : null,
|
||||
);
|
||||
|
||||
function pick() {
|
||||
if (props.disabled || submitting.value) return;
|
||||
input.value?.click();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
pending.value = null;
|
||||
preview.value = null;
|
||||
@@ -367,11 +374,13 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return `${fallback} (کد ${status})`;
|
||||
}
|
||||
|
||||
async function onFile(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const file = el.files?.[0];
|
||||
el.value = '';
|
||||
if (!file) return;
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
void handleExcelFile(file as File);
|
||||
return false;
|
||||
};
|
||||
|
||||
async function handleExcelFile(file: File) {
|
||||
if (props.disabled || submitting.value) return;
|
||||
emit('start');
|
||||
submitting.value = true;
|
||||
phase.value = 'preview';
|
||||
@@ -413,7 +422,6 @@ async function onFile(ev: Event) {
|
||||
}
|
||||
phase.value = 'idle';
|
||||
submitting.value = false;
|
||||
// Allow parent toolbar while user maps columns.
|
||||
emit('settled');
|
||||
} catch (e) {
|
||||
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
|
||||
@@ -443,126 +451,85 @@ async function confirmMapping() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
:disabled="disabled || submitting"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
:before-upload="beforeUpload"
|
||||
@click.stop
|
||||
>
|
||||
<slot :loading="submitting" :disabled="disabled || submitting" />
|
||||
<input
|
||||
ref="input"
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
:disabled="disabled || submitting"
|
||||
@change="onFile"
|
||||
@click.stop
|
||||
/>
|
||||
</span>
|
||||
</a-upload>
|
||||
|
||||
<UiBusyOverlay
|
||||
<a-modal
|
||||
:open="busyOpen"
|
||||
:message="busyMessage"
|
||||
:progress="busyProgress"
|
||||
nest
|
||||
/>
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:keyboard="false"
|
||||
centered
|
||||
>
|
||||
<a-flex vertical align="center" :gap="16">
|
||||
<a-spin />
|
||||
<a-typography-text>{{ busyMessage }}</a-typography-text>
|
||||
<a-progress
|
||||
v-if="busyProgress != null"
|
||||
:percent="busyProgress"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
|
||||
<UiModal :open="!!preview" title="تطبیق ستونهای اکسل" @close="closeMapper">
|
||||
<p class="lead">
|
||||
<a-modal
|
||||
:open="!!preview"
|
||||
title="تطبیق ستونهای اکسل"
|
||||
:confirm-loading="submitting"
|
||||
ok-text="ورود فایل"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ disabled: !canImport }"
|
||||
@ok="confirmMapping"
|
||||
@cancel="closeMapper"
|
||||
>
|
||||
<a-typography-paragraph type="secondary">
|
||||
ستونهای این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
|
||||
</p>
|
||||
<p v-if="preview?.largeFile" class="large-hint">
|
||||
</a-typography-paragraph>
|
||||
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
|
||||
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود بهصورت چندمرحلهای انجام میشود.
|
||||
</p>
|
||||
<div class="rows">
|
||||
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
|
||||
<div class="source">
|
||||
<strong>{{ col.header }}</strong>
|
||||
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
|
||||
</div>
|
||||
<UiSelect
|
||||
:model-value="mapping[col.header] ?? ''"
|
||||
:disabled="submitting"
|
||||
@update:model-value="setColumnField(col.header, $event)"
|
||||
>
|
||||
<option value="">نادیده بگیر</option>
|
||||
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
|
||||
{{ field.label }}{{ field.required ? ' *' : '' }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!canImport" class="hint">فیلدهای ستارهدار را حتماً به یک ستون وصل کنید.</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
|
||||
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
|
||||
ورود فایل
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</a-typography-paragraph>
|
||||
<a-flex vertical :gap="12">
|
||||
<a-row
|
||||
v-for="col in preview?.columns ?? []"
|
||||
:key="col.header"
|
||||
:gutter="[16, 8]"
|
||||
align="middle"
|
||||
>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-typography-text strong>{{ col.header }}</a-typography-text>
|
||||
<div v-if="col.samples.length">
|
||||
<a-typography-text type="secondary">
|
||||
نمونه: {{ col.samples.join('، ') }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-select
|
||||
:value="mapping[col.header] ?? ''"
|
||||
:disabled="submitting"
|
||||
style="width: 100%"
|
||||
:options="[
|
||||
{ value: '', label: 'نادیده بگیر' },
|
||||
...(preview?.fields ?? []).map((field) => ({
|
||||
value: field.key,
|
||||
label: field.required ? `${field.label} *` : field.label,
|
||||
})),
|
||||
]"
|
||||
@update:value="(v) => setColumnField(col.header, String(v ?? ''))"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
<a-typography-text v-if="!canImport" type="danger">
|
||||
فیلدهای ستارهدار را حتماً به یک ستون وصل کنید.
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.excel-import {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.excel-import.isBusy {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.large-hint {
|
||||
margin: -0.35rem 0 1rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
|
||||
color: var(--fg, inherit);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source strong {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.source small {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Modal as AModal,
|
||||
Segmented as ASegmented,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PrinterOutlined } from '@ant-design/icons-vue';
|
||||
import { brand } from '@mento/shared';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatMoney, currencySuffix } from '@/lib/money';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
export type PreviewLine = {
|
||||
name: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type PaperSize = 'A4' | 'A5';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
docMode: 'invoice' | 'quote' | 'credit';
|
||||
orgName: string;
|
||||
number: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
issuedAt: string;
|
||||
dueOrValid: string;
|
||||
dueOrValidLabel: string;
|
||||
customerName: string;
|
||||
customerSub?: string;
|
||||
notes: string;
|
||||
currency: string;
|
||||
lines: PreviewLine[];
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
taxAmount: number;
|
||||
taxPercent: number;
|
||||
payable: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const paperSize = ref<PaperSize>('A4');
|
||||
|
||||
const thStyle = {
|
||||
padding: '7px 8px',
|
||||
textAlign: 'right' as const,
|
||||
color: '#6b7280',
|
||||
fontWeight: '700',
|
||||
fontSize: '11px',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
};
|
||||
const tdStyle = {
|
||||
padding: '7px 8px',
|
||||
textAlign: 'right' as const,
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
verticalAlign: 'top' as const,
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) paperSize.value = 'A4';
|
||||
},
|
||||
);
|
||||
|
||||
const paperOptions = [
|
||||
{ value: 'A4', label: 'A4' },
|
||||
{ value: 'A5', label: 'A5' },
|
||||
];
|
||||
|
||||
const docTypeLabel = computed(() => {
|
||||
if (props.docMode === 'quote') return 'پیشفاکتور';
|
||||
if (props.docMode === 'credit') return 'فاکتور برگشتی';
|
||||
return 'فاکتور فروش';
|
||||
});
|
||||
|
||||
const accent = '#10b981';
|
||||
|
||||
const displayNumber = computed(() => props.number?.trim() || 'پیشنویس');
|
||||
|
||||
const paperWidth = computed(() => (paperSize.value === 'A5' ? 520 : 720));
|
||||
|
||||
function faDate(value: string) {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Button as AButton, Input as AInput } from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
@@ -12,7 +19,6 @@ import {
|
||||
parseLocalInput,
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
const props = withDefaults(
|
||||
@@ -39,7 +45,7 @@ const WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as const;
|
||||
const MOBILE_MQ = '(max-width: 720px)';
|
||||
|
||||
const open = ref(false);
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const panelRef = ref<HTMLElement | null>(null);
|
||||
const clockRef = ref<HTMLElement | null>(null);
|
||||
const hourColRef = ref<HTMLElement | null>(null);
|
||||
@@ -439,41 +445,28 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" :class="{ disabled, 'time-only': mode === 'time' }">
|
||||
<button
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="wrap"
|
||||
:class="{ disabled, 'time-only': mode === 'time' }"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-input
|
||||
:id="id"
|
||||
ref="triggerRef"
|
||||
class="trigger"
|
||||
:class="{ 'clock-trigger': mode === 'time' }"
|
||||
type="button"
|
||||
readonly
|
||||
:value="display"
|
||||
:placeholder="emptyLabel"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="open"
|
||||
:aria-required="required || undefined"
|
||||
style="width: 100%; cursor: pointer"
|
||||
@click="toggle"
|
||||
>
|
||||
<template v-if="mode === 'time'">
|
||||
<span class="clock-face" aria-hidden="true">
|
||||
<i
|
||||
class="hand hour-hand"
|
||||
:style="{
|
||||
transform: `rotate(${(draftHour % 12) * 30 + draftMinute * 0.5}deg)`,
|
||||
}"
|
||||
/>
|
||||
<i
|
||||
class="hand minute-hand"
|
||||
:style="{ transform: `rotate(${draftMinute * 6}deg)` }"
|
||||
/>
|
||||
<i class="hub" />
|
||||
</span>
|
||||
<span class="clock-text" :class="{ empty: !display }">
|
||||
{{ display || emptyLabel }}
|
||||
</span>
|
||||
<template #suffix>
|
||||
<ClockCircleOutlined v-if="triggerIcon === 'clock'" />
|
||||
<CalendarOutlined v-else />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span :class="{ empty: !display }">{{ display || emptyLabel }}</span>
|
||||
<UiIcon :name="triggerIcon" :size="18" />
|
||||
</template>
|
||||
</button>
|
||||
</a-input>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
@@ -490,13 +483,13 @@ onBeforeUnmount(() => {
|
||||
:style="{ top: `${top}px`, left: `${left}px` }"
|
||||
>
|
||||
<header v-if="mode !== 'time'" class="nav">
|
||||
<button type="button" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<a-button type="text" class="nav-btn" aria-label="ماه بعد" @click="goMonth(1)">
|
||||
<template #icon><RightOutlined /></template>
|
||||
</a-button>
|
||||
<strong>{{ monthTitle }}</strong>
|
||||
<button type="button" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<a-button type="text" class="nav-btn next" aria-label="ماه قبل" @click="goMonth(-1)">
|
||||
<template #icon><LeftOutlined /></template>
|
||||
</a-button>
|
||||
</header>
|
||||
|
||||
<template v-if="mode !== 'time'">
|
||||
@@ -504,16 +497,16 @@ onBeforeUnmount(() => {
|
||||
<span v-for="day in WEEKDAYS" :key="day">{{ day }}</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<button
|
||||
<a-button
|
||||
v-for="cell in cells"
|
||||
:key="cell.key"
|
||||
type="button"
|
||||
type="text"
|
||||
class="day"
|
||||
:class="{ mute: !cell.inMonth, today: cell.isToday, on: cell.isSelected }"
|
||||
@click="pickDay(cell)"
|
||||
>
|
||||
{{ toFaDigits(cell.jd) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -521,25 +514,25 @@ onBeforeUnmount(() => {
|
||||
<template v-if="isMobile">
|
||||
<p class="md-label">{{ mode === 'time' ? 'انتخاب ساعت' : 'ساعت' }}</p>
|
||||
<div class="md-digits" dir="ltr">
|
||||
<button
|
||||
type="button"
|
||||
<a-button
|
||||
type="text"
|
||||
class="md-chip"
|
||||
:class="{ on: clockFace === 'hour' }"
|
||||
aria-label="ساعت"
|
||||
@click="clockFace = 'hour'"
|
||||
>
|
||||
{{ faPad(draftHour) }}
|
||||
</button>
|
||||
</a-button>
|
||||
<span class="md-colon">:</span>
|
||||
<button
|
||||
type="button"
|
||||
<a-button
|
||||
type="text"
|
||||
class="md-chip"
|
||||
:class="{ on: clockFace === 'minute' }"
|
||||
aria-label="دقیقه"
|
||||
@click="clockFace = 'minute'"
|
||||
>
|
||||
{{ faPad(draftMinute) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</div>
|
||||
<div
|
||||
ref="clockRef"
|
||||
@@ -561,10 +554,10 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
<i class="md-hub" />
|
||||
<template v-if="clockFace === 'hour'">
|
||||
<button
|
||||
<a-button
|
||||
v-for="h in hours"
|
||||
:key="`h-${h}`"
|
||||
type="button"
|
||||
type="text"
|
||||
class="md-tick"
|
||||
:class="{ on: draftHour === h, inner: h >= 12 }"
|
||||
:style="hourDialStyle(h)"
|
||||
@@ -572,13 +565,13 @@ onBeforeUnmount(() => {
|
||||
@click.stop="setHour(h)"
|
||||
>
|
||||
{{ toFaDigits(h) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
<a-button
|
||||
v-for="m in clockMinutes"
|
||||
:key="`m-${m}`"
|
||||
type="button"
|
||||
type="text"
|
||||
class="md-tick"
|
||||
:class="{ on: draftMinute === m }"
|
||||
:style="minuteDialStyle(m)"
|
||||
@@ -586,7 +579,7 @@ onBeforeUnmount(() => {
|
||||
@click.stop="setMinute(m)"
|
||||
>
|
||||
{{ faPad(m) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -596,31 +589,31 @@ onBeforeUnmount(() => {
|
||||
<div class="col">
|
||||
<span class="col-label">ساعت</span>
|
||||
<div ref="hourColRef" class="col-list" role="listbox" aria-label="ساعت">
|
||||
<button
|
||||
<a-button
|
||||
v-for="h in hours"
|
||||
:key="`col-h-${h}`"
|
||||
type="button"
|
||||
type="text"
|
||||
class="col-item"
|
||||
:class="{ on: draftHour === h }"
|
||||
@click="setHour(h)"
|
||||
>
|
||||
{{ faPad(h) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<span class="col-label">دقیقه</span>
|
||||
<div ref="minuteColRef" class="col-list" role="listbox" aria-label="دقیقه">
|
||||
<button
|
||||
<a-button
|
||||
v-for="m in columnMinutes"
|
||||
:key="`col-m-${m}`"
|
||||
type="button"
|
||||
type="text"
|
||||
class="col-item"
|
||||
:class="{ on: draftMinute === m }"
|
||||
@click="setMinute(m)"
|
||||
>
|
||||
{{ faPad(m) }}
|
||||
</button>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -628,18 +621,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<footer class="foot">
|
||||
<button type="button" class="link" @click="pickToday">
|
||||
<a-button type="link" @click="pickToday">
|
||||
{{ mode === 'time' ? 'الان' : 'امروز' }}
|
||||
</button>
|
||||
<button
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="clearable && !required && model"
|
||||
type="button"
|
||||
class="link"
|
||||
type="link"
|
||||
@click="clear"
|
||||
>
|
||||
پاک کردن
|
||||
</button>
|
||||
<button type="button" class="done" @click="hide">تأیید</button>
|
||||
</a-button>
|
||||
<a-button type="primary" @click="hide">تأیید</a-button>
|
||||
</footer>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Modal,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
|
||||
import { formatJalaliDateTime } from '@/lib/jalali';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const props = defineProps<{
|
||||
personId: string;
|
||||
}>();
|
||||
@@ -86,15 +103,24 @@ async function saveEdit(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: string) {
|
||||
if (!confirm('این مورد از حافظه حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/memory/${id}`);
|
||||
if (editingId.value === id) cancelEdit();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
}
|
||||
function removeItem(id: string) {
|
||||
Modal.confirm({
|
||||
title: 'حذف از حافظه',
|
||||
content: 'این مورد از حافظه حذف شود؟',
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
try {
|
||||
await api.delete(`/memory/${id}`);
|
||||
if (editingId.value === id) cancelEdit();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatWhen(value: string) {
|
||||
@@ -110,152 +136,71 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="memory-panel">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">{{ brand.memoryEn }}</p>
|
||||
<h2>حافظه مشتری</h2>
|
||||
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره میشود.</p>
|
||||
</div>
|
||||
</header>
|
||||
<a-card :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-typography-text type="secondary" strong>{{ brand.memoryEn }}</a-typography-text>
|
||||
<a-typography-title :level="5" :style="{ margin: '4px 0' }">حافظه مشتری</a-typography-title>
|
||||
<a-typography-paragraph type="secondary" :style="{ marginBottom: '12px' }">
|
||||
نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره میشود.
|
||||
</a-typography-paragraph>
|
||||
|
||||
<form class="add-form" @submit.prevent="addItem">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
rows="2"
|
||||
<a-flex :gap="8" align="flex-start" :style="{ marginBottom: '12px' }">
|
||||
<a-textarea
|
||||
v-model:value="draft"
|
||||
:rows="2"
|
||||
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبحها"
|
||||
style="flex: 1"
|
||||
@pressEnter.ctrl="addItem"
|
||||
/>
|
||||
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="saving || !draft.trim()"
|
||||
:loading="saving"
|
||||
@click="addItem"
|
||||
>
|
||||
افزودن
|
||||
</button>
|
||||
</form>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
|
||||
|
||||
<ul v-else class="items">
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<template v-if="editingId === item.id">
|
||||
<textarea v-model="editContent" rows="2" />
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-typography-text v-if="!loading && !hasItems" type="secondary">
|
||||
هنوز موردی در حافظه ثبت نشده.
|
||||
</a-typography-text>
|
||||
|
||||
<a-list v-else-if="hasItems" item-layout="vertical" :data-source="items">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template v-if="editingId === item.id">
|
||||
<a-flex vertical :gap="8" style="width: 100%">
|
||||
<a-textarea v-model:value="editContent" :rows="2" />
|
||||
<a-space :size="8">
|
||||
<a-button type="primary" :loading="saving" @click="saveEdit(item.id)">ذخیره</a-button>
|
||||
<a-button @click="cancelEdit">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-list-item-meta v-else>
|
||||
<template #title>
|
||||
<a-typography-paragraph :style="{ marginBottom: 0, whiteSpace: 'pre-wrap' }">
|
||||
{{ item.content }}
|
||||
</a-typography-paragraph>
|
||||
</template>
|
||||
<template #description>
|
||||
<a-typography-text type="secondary">
|
||||
{{ sourceLabel[item.sourceType] ?? item.sourceType }}
|
||||
<template v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</template>
|
||||
· {{ formatWhen(item.updatedAt) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template v-if="editingId !== item.id" #actions>
|
||||
<a-button type="link" @click="startEdit(item)">ویرایش</a-button>
|
||||
<a-button type="link" danger @click="removeItem(item.id)">حذف</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="content">{{ item.content }}</p>
|
||||
<p class="meta">
|
||||
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
|
||||
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
|
||||
<span> · {{ formatWhen(item.updatedAt) }}</span>
|
||||
</p>
|
||||
<div class="row-actions">
|
||||
<button type="button" @click="startEdit(item)">ویرایش</button>
|
||||
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-panel {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.items li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.row-actions button.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { Input as AInput } from 'ant-design-vue';
|
||||
import {
|
||||
caretIndexForDigitCount,
|
||||
countDigitChars,
|
||||
@@ -34,7 +35,14 @@ const props = withDefaults(
|
||||
|
||||
const text = ref('');
|
||||
const focused = ref(false);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const inputRef = ref<{ input?: HTMLInputElement } | HTMLInputElement | null>(null);
|
||||
|
||||
function nativeInput(): HTMLInputElement | null {
|
||||
const el = inputRef.value;
|
||||
if (!el) return null;
|
||||
if (el instanceof HTMLInputElement) return el;
|
||||
return el.input ?? null;
|
||||
}
|
||||
|
||||
function readModelNumber(): number | null {
|
||||
if (model.value === '' || model.value == null) return null;
|
||||
@@ -83,7 +91,6 @@ async function onInput(event: Event) {
|
||||
const n = parseMoneyNumber(raw, props.decimals);
|
||||
|
||||
if (n == null) {
|
||||
// اجازهٔ تایپ ناقص مثل "-" یا خالی
|
||||
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
|
||||
text.value = cleaned;
|
||||
writeModel(null);
|
||||
@@ -99,7 +106,7 @@ async function onInput(event: Event) {
|
||||
writeModel(n);
|
||||
|
||||
await nextTick();
|
||||
const input = inputRef.value;
|
||||
const input = nativeInput();
|
||||
if (!input) return;
|
||||
const nextCaret = caretIndexForDigitCount(display, digitsBefore);
|
||||
input.setSelectionRange(nextCaret, nextCaret);
|
||||
@@ -111,27 +118,20 @@ function normalizeSep(raw: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
<a-input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
class="money-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
autocomplete="off"
|
||||
:dir="dir"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:value="text"
|
||||
style="width: 100%; font-variant-numeric: tabular-nums"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.money-input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,21 @@ import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
Modal as AModal,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
Upload as AUpload,
|
||||
} from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
filterPhoneContacts,
|
||||
isContactPickerSupported,
|
||||
@@ -15,6 +28,8 @@ import {
|
||||
type PhoneContactDraft,
|
||||
} from '@/lib/phone-contacts';
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
@@ -38,7 +53,6 @@ const importing = ref(false);
|
||||
const drafts = ref<PhoneContactDraft[]>([]);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const listQuery = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const modalOpen = computed(() => phase.value !== 'closed');
|
||||
const modalTitle = computed(() => {
|
||||
@@ -65,7 +79,6 @@ async function showReview(rows: PhoneContactDraft[], opts?: { selectAll?: boolea
|
||||
drafts.value = sortPhoneContacts(rows);
|
||||
listQuery.value = '';
|
||||
selected.value = opts?.selectAll ? new Set(drafts.value.map((r) => r.key)) : new Set();
|
||||
// بعد از دیالوگ فایل، یک تیک صبر کن تا مودال مطمئن باز شود
|
||||
phase.value = 'closed';
|
||||
await nextTick();
|
||||
phase.value = 'review';
|
||||
@@ -83,7 +96,6 @@ function selectAll(on: boolean) {
|
||||
selected.value = new Set();
|
||||
return;
|
||||
}
|
||||
// اگر جستجو فعال است فقط نتایج را انتخاب کن، وگرنه همه
|
||||
if (listQuery.value.trim()) {
|
||||
const next = new Set(selected.value);
|
||||
for (const row of visibleDrafts.value) next.add(row.key);
|
||||
@@ -121,7 +133,6 @@ async function runDevicePicker() {
|
||||
emit('fail', 'مخاطبی انتخاب نشد.');
|
||||
return;
|
||||
}
|
||||
// مثل vcf: جستجو/انتخاب دستی، نه ورود خودکار همه
|
||||
await showReview(rows, { selectAll: false });
|
||||
} catch (e) {
|
||||
const name = e instanceof DOMException ? e.name : '';
|
||||
@@ -143,11 +154,12 @@ function startPicker() {
|
||||
openHelp();
|
||||
}
|
||||
|
||||
async function onVcfFile(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const file = el.files?.[0];
|
||||
el.value = '';
|
||||
if (!file) return;
|
||||
const beforeVcfUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
void handleVcfFile(file as File);
|
||||
return false;
|
||||
};
|
||||
|
||||
async function handleVcfFile(file: File) {
|
||||
try {
|
||||
const rows = await parseVCardFile(file);
|
||||
if (!rows.length) {
|
||||
@@ -223,68 +235,70 @@ async function importSelected() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="wrap" @click.stop>
|
||||
<span class="trigger" @click="startPicker">
|
||||
<span @click.stop>
|
||||
<span @click="startPicker">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
@change="onVcfFile"
|
||||
/>
|
||||
|
||||
<UiModal :open="modalOpen" :title="modalTitle" @close="cancel">
|
||||
<a-modal
|
||||
:open="modalOpen"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="importing"
|
||||
@cancel="cancel"
|
||||
>
|
||||
<template v-if="phase === 'choose'">
|
||||
<p class="lead">
|
||||
<a-typography-paragraph type="secondary">
|
||||
مخاطبین را از دفترچه تلفن یا فایل <strong>.vcf</strong> بگیرید.
|
||||
بعد در لیست الفبایی جستجو کنید و یک، چند، یا همه را برای ورود انتخاب کنید.
|
||||
</p>
|
||||
<div class="choose-actions">
|
||||
<UiButton variant="primary" block @click="runDevicePicker">
|
||||
<UiIcon name="phone" :size="16" />
|
||||
</a-typography-paragraph>
|
||||
<a-flex vertical :gap="8">
|
||||
<a-button type="primary" block @click="runDevicePicker">
|
||||
<template #icon><PhoneOutlined /></template>
|
||||
از دفترچه تلفن
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" block @click="fileInput?.click()">
|
||||
از فایل .vcf
|
||||
</UiButton>
|
||||
</div>
|
||||
</a-button>
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
:before-upload="beforeVcfUpload"
|
||||
>
|
||||
<a-button block>از فایل .vcf</a-button>
|
||||
</a-upload>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'help'">
|
||||
<p class="lead">
|
||||
<a-typography-paragraph type="secondary">
|
||||
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمیشود.
|
||||
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
|
||||
بعد از بارگذاری میتوانید یک، چند، یا همه مخاطبین را انتخاب کنید.
|
||||
</p>
|
||||
<ul class="tips">
|
||||
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
|
||||
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
|
||||
</ul>
|
||||
</a-typography-paragraph>
|
||||
<a-typography-paragraph type="secondary">
|
||||
اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد. آیفون: Contacts → Share Contact /
|
||||
Export → فایل vCard.
|
||||
</a-typography-paragraph>
|
||||
</template>
|
||||
|
||||
<template v-else-if="phase === 'review'">
|
||||
<p class="lead">
|
||||
<a-typography-paragraph type="secondary">
|
||||
{{ toFaDigits(drafts.length) }} مخاطب از فایل/گوشی خوانده شد (مرتبشده الفبایی).
|
||||
موارد دلخواه را تیک بزنید؛ فقط انتخابشدهها وارد میشوند.
|
||||
</p>
|
||||
<div class="search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
v-model="listQuery"
|
||||
type="search"
|
||||
placeholder="جستجو نام یا موبایل…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="link" @click="selectAll(true)">
|
||||
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
|
||||
</button>
|
||||
<button type="button" class="link" @click="selectAll(false)">هیچکدام</button>
|
||||
<span class="count">
|
||||
</a-typography-paragraph>
|
||||
<a-input
|
||||
v-model:value="listQuery"
|
||||
allow-clear
|
||||
placeholder="جستجو نام یا موبایل…"
|
||||
style="width: 100%; margin-bottom: 12px"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-space :size="8">
|
||||
<a-button type="link" @click="selectAll(true)">
|
||||
{{ listQuery.trim() ? 'انتخاب نتایج' : 'انتخاب همه' }}
|
||||
</a-button>
|
||||
<a-button type="link" @click="selectAll(false)">هیچکدام</a-button>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(selectedCount) }} انتخاب
|
||||
<template v-if="listQuery.trim()">
|
||||
· {{ toFaDigits(visibleDrafts.length) }} نتیجه
|
||||
@@ -292,194 +306,72 @@ async function importSelected() {
|
||||
<template v-else>
|
||||
از {{ toFaDigits(drafts.length) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<ul v-if="visibleDrafts.length" class="list">
|
||||
<li v-for="row in visibleDrafts" :key="row.key">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(row.key)"
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-list
|
||||
v-if="visibleDrafts.length"
|
||||
:data-source="visibleDrafts"
|
||||
:style="{ maxHeight: 'min(48vh, 360px)', overflow: 'auto' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-checkbox
|
||||
:checked="selected.has(item.key)"
|
||||
:disabled="importing"
|
||||
@change="toggle(row.key)"
|
||||
/>
|
||||
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
|
||||
<span class="meta">
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ draftLine(row) }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">مخاطبی با این جستجو پیدا نشد.</p>
|
||||
@click.prevent="toggle(item.key)"
|
||||
>
|
||||
<a-space :size="8">
|
||||
<a-avatar>{{ item.fullName.slice(0, 1) }}</a-avatar>
|
||||
<span>
|
||||
<div><a-typography-text strong>{{ item.fullName }}</a-typography-text></div>
|
||||
<a-typography-text type="secondary">{{ draftLine(item) }}</a-typography-text>
|
||||
</span>
|
||||
</a-space>
|
||||
</a-checkbox>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-typography-text v-else type="secondary">
|
||||
مخاطبی با این جستجو پیدا نشد.
|
||||
</a-typography-text>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="phase === 'choose'">
|
||||
<UiButton variant="ghost" @click="cancel">انصراف</UiButton>
|
||||
<a-button @click="cancel">انصراف</a-button>
|
||||
</template>
|
||||
<template v-else-if="phase === 'help'">
|
||||
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
||||
<UiButton variant="primary" :disabled="importing" @click="fileInput?.click()">
|
||||
انتخاب فایل .vcf
|
||||
</UiButton>
|
||||
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
:before-upload="beforeVcfUpload"
|
||||
>
|
||||
<a-button type="primary" :disabled="importing">انتخاب فایل .vcf</a-button>
|
||||
</a-upload>
|
||||
</template>
|
||||
<template v-else-if="phase === 'review'">
|
||||
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
||||
<UiButton variant="ghost" :disabled="importing" @click="fileInput?.click()">
|
||||
فایل دیگر
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
<a-button :disabled="importing" @click="cancel">انصراف</a-button>
|
||||
<a-upload
|
||||
:show-upload-list="false"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
:before-upload="beforeVcfUpload"
|
||||
:disabled="importing"
|
||||
>
|
||||
<a-button :disabled="importing">فایل دیگر</a-button>
|
||||
</a-upload>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="importing || !selectedCount"
|
||||
:loading="importing"
|
||||
@click="importSelected"
|
||||
>
|
||||
<UiIcon name="user-plus" :size="16" />
|
||||
<template #icon><UserAddOutlined /></template>
|
||||
ورود {{ toFaDigits(selectedCount) }}
|
||||
{{ target === 'leads' ? 'لید' : 'نفر' }}
|
||||
</UiButton>
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</UiModal>
|
||||
</a-modal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 0.85rem;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.choose-actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin: 0;
|
||||
padding: 0 1.1rem 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: min(48vh, 360px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta strong {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.meta small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,7 +65,10 @@ const items: Array<{
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const hidden = computed(() => route.name === 'person-detail');
|
||||
/** صفحات با نوار اکشن ثابت موبایل — FAB نباید وسط صفحه رها شود */
|
||||
const hidden = computed(
|
||||
() => route.name === 'person-detail' || route.name === 'finance-incomes',
|
||||
);
|
||||
const isQuickCreateOpen = ref(false);
|
||||
const ready = ref(false);
|
||||
const fabPos = ref<FabPos>({
|
||||
|
||||
@@ -1,37 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Select as ASelect,
|
||||
Space,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
const ASpaceCompact = Space.Compact;
|
||||
import { api } from '@/lib/api';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { useEscClose } from '@/lib/modal-stack';
|
||||
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
scope: SavedViewScope;
|
||||
/** فیلتر فعلی صفحه برای ذخیره */
|
||||
currentFilters: Record<string, unknown>;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
scope: SavedViewScope;
|
||||
currentFilters: Record<string, unknown>;
|
||||
/** فشرده برای قرار گرفتن داخل یک ردیف فیلتر */
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [filters: Record<string, unknown>];
|
||||
}>();
|
||||
|
||||
const views = ref<SavedViewDto[]>([]);
|
||||
const activeId = ref('');
|
||||
const activeId = ref<string | undefined>(undefined);
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const saveOpen = ref(false);
|
||||
const saveName = ref('');
|
||||
const saveShared = ref(false);
|
||||
useEscClose(() => saveOpen.value, () => { saveOpen.value = false; });
|
||||
|
||||
const activeView = computed(
|
||||
() => views.value.find((v) => v.id === activeId.value) ?? null,
|
||||
);
|
||||
|
||||
const selectOptions = computed(() =>
|
||||
views.value.map((v) => ({
|
||||
value: v.id,
|
||||
label: `${v.name}${v.isShared ? ' (سازمانی)' : ''}`,
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadViews() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<SavedViewDto[]>('/saved-views', {
|
||||
params: { scope: props.scope },
|
||||
@@ -39,16 +60,17 @@ async function loadViews() {
|
||||
views.value = data;
|
||||
} catch {
|
||||
views.value = [];
|
||||
error.value = 'بارگذاری فیلترهای ذخیرهشده ناموفق بود.';
|
||||
message.error('بارگذاری فیلترهای ذخیرهشده ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(id: string) {
|
||||
activeId.value = id;
|
||||
if (!id) return;
|
||||
const view = views.value.find((v) => v.id === id);
|
||||
function onPick(id: unknown) {
|
||||
const next = typeof id === 'string' ? id : '';
|
||||
activeId.value = next || undefined;
|
||||
if (!next) return;
|
||||
const view = views.value.find((v) => v.id === next);
|
||||
if (view) emit('apply', view.filters ?? {});
|
||||
}
|
||||
|
||||
@@ -56,14 +78,12 @@ function openSave() {
|
||||
saveName.value = activeView.value?.name ?? '';
|
||||
saveShared.value = activeView.value?.isShared ?? false;
|
||||
saveOpen.value = true;
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function saveView() {
|
||||
const name = saveName.value.trim();
|
||||
if (name.length < 2 || busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body = {
|
||||
name,
|
||||
@@ -86,32 +106,42 @@ async function saveView() {
|
||||
activeId.value = data.id;
|
||||
}
|
||||
saveOpen.value = false;
|
||||
message.success('فیلتر ذخیره شد.');
|
||||
} catch {
|
||||
error.value = 'ذخیره فیلتر ناموفق بود.';
|
||||
message.error('ذخیره فیلتر ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeView() {
|
||||
function removeView() {
|
||||
if (!activeId.value || busy.value) return;
|
||||
if (!confirm('این فیلتر ذخیرهشده حذف شود؟')) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await api.delete(`/saved-views/${activeId.value}`);
|
||||
views.value = views.value.filter((v) => v.id !== activeId.value);
|
||||
activeId.value = '';
|
||||
} catch {
|
||||
error.value = 'حذف فیلتر ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: 'حذف فیلتر',
|
||||
content: 'این فیلتر ذخیرهشده حذف شود؟',
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await api.delete(`/saved-views/${activeId.value}`);
|
||||
views.value = views.value.filter((v) => v.id !== activeId.value);
|
||||
activeId.value = undefined;
|
||||
message.success('فیلتر حذف شد.');
|
||||
} catch {
|
||||
message.error('حذف فیلتر ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scope,
|
||||
() => {
|
||||
activeId.value = '';
|
||||
activeId.value = undefined;
|
||||
void loadViews();
|
||||
},
|
||||
);
|
||||
@@ -122,161 +152,53 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="saved-filters" dir="rtl">
|
||||
<label class="pick">
|
||||
<span class="lbl">فیلتر ذخیرهشده</span>
|
||||
<select
|
||||
:value="activeId"
|
||||
:disabled="loading"
|
||||
@change="onPick(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">— انتخاب —</option>
|
||||
<option v-for="v in views" :key="v.id" :value="v.id">
|
||||
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
|
||||
<UiIcon name="flag" :size="14" />
|
||||
<!-- یک خط فشرده: سلکت + ذخیره (+ حذف) -->
|
||||
<a-space-compact block dir="rtl" :style="{ width: '100%' }">
|
||||
<a-select
|
||||
:value="activeId"
|
||||
allow-clear
|
||||
:disabled="loading"
|
||||
:loading="loading"
|
||||
:options="selectOptions"
|
||||
:placeholder="compact ? 'فیلتر ذخیرهشده' : '— انتخاب —'"
|
||||
:style="{ flex: 1, minWidth: 0, width: '100%' }"
|
||||
@update:value="onPick"
|
||||
/>
|
||||
<a-button :disabled="loading" title="ذخیره فیلتر فعلی" @click="openSave">
|
||||
<template #icon><FlagOutlined /></template>
|
||||
ذخیره
|
||||
</button>
|
||||
<button
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="activeId"
|
||||
type="button"
|
||||
class="btn danger"
|
||||
title="حذف فیلتر"
|
||||
danger
|
||||
:disabled="busy"
|
||||
title="حذف فیلتر"
|
||||
@click="removeView"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-space-compact>
|
||||
|
||||
<div v-if="saveOpen" class="sheet">
|
||||
<div class="sheet-backdrop" aria-hidden="true" />
|
||||
<div class="sheet-panel" role="dialog" aria-modal="true">
|
||||
<header class="sheet-head">
|
||||
<h3>{{ activeId ? 'بهروزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
|
||||
<button type="button" class="x-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="saveOpen = false">
|
||||
<UiIcon name="x" :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<label>
|
||||
نام
|
||||
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخهای این هفته" />
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="saveShared" type="checkbox" />
|
||||
اشتراک با سازمان
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn primary" :disabled="busy" @click="saveView">
|
||||
{{ busy ? '…' : 'ذخیره' }}
|
||||
</button>
|
||||
<button type="button" class="btn" @click="saveOpen = false">لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-modal
|
||||
v-model:open="saveOpen"
|
||||
:title="activeId ? 'بهروزرسانی فیلتر' : 'ذخیره فیلتر'"
|
||||
:confirm-loading="busy"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="لغو"
|
||||
destroy-on-close
|
||||
@ok="saveView"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input
|
||||
v-model:value="saveName"
|
||||
:maxlength="120"
|
||||
placeholder="مثلاً سرنخهای این هفته"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item :style="{ marginBottom: 0 }">
|
||||
<a-checkbox v-model:checked="saveShared">اشتراک با سازمان</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.saved-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
.pick {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 11rem;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
select,
|
||||
input[type='text'] {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #fff;
|
||||
font-size: 0.85rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #0d9488;
|
||||
border-color: #0d9488;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.err {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
.sheet {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.sheet-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
.sheet-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(100%, 360px);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.sheet-panel h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||
.x-btn { width: 32px; height: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #64748b; display: grid; place-items: center; cursor: pointer; }
|
||||
.x-btn:hover { background: #f8fafc; color: #0f172a; }
|
||||
.sheet-panel label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
color: #334155;
|
||||
}
|
||||
.check {
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
Flex as AFlex,
|
||||
Popover as APopover,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { SettingOutlined } from '@ant-design/icons-vue';
|
||||
import { useTableColumns } from '@/lib/tableColumns';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tableKey: string;
|
||||
@@ -14,164 +23,49 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const open = ref(false);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const cols = useTableColumns(() => props.tableKey);
|
||||
|
||||
const columns = computed(() => cols.columns());
|
||||
const hiddenCount = computed(() => cols.hiddenCount());
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick));
|
||||
const placement = computed(() =>
|
||||
props.align === 'end' ? 'bottomLeft' : 'bottomRight',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="tc-pop">
|
||||
<button
|
||||
class="tc-tool"
|
||||
type="button"
|
||||
:class="{ active: hiddenCount > 0 }"
|
||||
:title="label"
|
||||
@click.stop="open = !open"
|
||||
>
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
<span class="tc-label">{{ label }}</span>
|
||||
<em v-if="hiddenCount" class="tc-badge">{{ toFaDigits(hiddenCount) }} مخفی</em>
|
||||
</button>
|
||||
<div v-if="open" class="tc-menu" :class="align" @click.stop>
|
||||
<p class="tc-head">نمایش ستونها</p>
|
||||
<p v-if="!columns.length" class="tc-empty">ستونی برای نمایش وجود ندارد.</p>
|
||||
<label v-for="col in columns" :key="col" class="tc-item">
|
||||
<input type="checkbox" :checked="cols.isVisible(col)" @change="cols.toggle(col)" />
|
||||
<span>{{ col }}</span>
|
||||
</label>
|
||||
<button v-if="hiddenCount" type="button" class="tc-reset" @click="cols.showAll()">
|
||||
نمایش همه ستونها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-popover v-model:open="open" trigger="click" :placement="placement">
|
||||
<template #content>
|
||||
<a-flex
|
||||
vertical
|
||||
:gap="8"
|
||||
style="min-width: 13rem; max-width: calc(100vw - 1.5rem)"
|
||||
>
|
||||
<a-typography-text type="secondary" strong>
|
||||
نمایش ستونها
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="!columns.length" type="secondary">
|
||||
ستونی برای نمایش وجود ندارد.
|
||||
</a-typography-text>
|
||||
<a-checkbox
|
||||
v-for="col in columns"
|
||||
:key="col"
|
||||
:checked="cols.isVisible(col)"
|
||||
@click.prevent="cols.toggle(col)"
|
||||
>
|
||||
{{ col }}
|
||||
</a-checkbox>
|
||||
<a-button v-if="hiddenCount" type="link" @click="cols.showAll()">
|
||||
نمایش همه ستونها
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
{{ label }}
|
||||
<template v-if="hiddenCount">
|
||||
· {{ toFaDigits(hiddenCount) }} مخفی
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tc-pop {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tc-tool {
|
||||
min-height: var(--control-h, 2.4rem);
|
||||
height: var(--control-h, 2.4rem);
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tc-tool:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.tc-tool.active {
|
||||
border-color: #5eead4;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.tc-badge {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
|
||||
.tc-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-start: 0;
|
||||
z-index: 20;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
padding: 0.45rem;
|
||||
min-width: 13rem;
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: min(24rem, 70vh);
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.tc-menu.end {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.tc-head {
|
||||
margin: 0 0 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
color: var(--ink-muted, #64748b);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tc-empty {
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-muted, #64748b);
|
||||
}
|
||||
|
||||
.tc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.42rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tc-item:hover {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.tc-item input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: #0d9488;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tc-reset {
|
||||
margin-top: 0.3rem;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: none;
|
||||
padding: 0.5rem;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: #0f766e;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tc-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import { computed } from 'vue';
|
||||
import { Select as ASelect } from 'ant-design-vue';
|
||||
|
||||
type PickUser = { id: string; fullName: string };
|
||||
|
||||
@@ -21,335 +19,35 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [ids: string[]];
|
||||
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const query = ref('');
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
let snapshot: string[] = [];
|
||||
|
||||
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
|
||||
const selected = computed(() =>
|
||||
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
|
||||
const options = computed(() =>
|
||||
props.users.map((u) => ({ value: u.id, label: u.fullName })),
|
||||
);
|
||||
const filtered = computed(() => {
|
||||
if (!query.value.trim()) return props.users;
|
||||
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
|
||||
});
|
||||
const canRemove = computed(() => props.modelValue.length > props.min);
|
||||
|
||||
function sameSet(a: string[], b: string[]) {
|
||||
return a.length === b.length && a.every((id) => b.includes(id));
|
||||
function onUpdate(value: unknown) {
|
||||
const ids = Array.isArray(value) ? value.map(String) : [];
|
||||
if (ids.length < props.min && props.modelValue.length >= props.min) {
|
||||
emit('update:modelValue', [...props.modelValue]);
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', ids);
|
||||
emit('change', ids);
|
||||
}
|
||||
|
||||
function openList() {
|
||||
if (props.disabled) return;
|
||||
snapshot = [...props.modelValue];
|
||||
query.value = '';
|
||||
open.value = true;
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
|
||||
function closeList() {
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
const has = props.modelValue.includes(id);
|
||||
if (has && !canRemove.value) return;
|
||||
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
|
||||
emit('update:modelValue', next);
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
if (props.disabled || !canRemove.value) return;
|
||||
const next = props.modelValue.filter((x) => x !== id);
|
||||
emit('update:modelValue', next);
|
||||
if (!open.value) emit('change', next);
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeList();
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onDocClick));
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
|
||||
<div class="chips">
|
||||
<span v-for="u in selected" :key="u.id" class="chip">
|
||||
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
|
||||
<span class="chip-name">{{ u.fullName }}</span>
|
||||
<button
|
||||
v-if="canRemove && !disabled"
|
||||
type="button"
|
||||
class="chip-x"
|
||||
:aria-label="`حذف ${u.fullName}`"
|
||||
@click.stop="remove(u.id)"
|
||||
>
|
||||
<UiIcon name="x" :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="add"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="open"
|
||||
@click.stop="open ? closeList() : openList()"
|
||||
>
|
||||
<UiIcon name="user-plus" :size="14" />
|
||||
{{ addLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="open" class="pop" @click.stop>
|
||||
<label class="search">
|
||||
<UiIcon name="search" :size="14" />
|
||||
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
|
||||
</label>
|
||||
<ul class="list">
|
||||
<li v-for="u in filtered" :key="u.id">
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
:class="{ on: modelValue.includes(u.id) }"
|
||||
@click="toggle(u.id)"
|
||||
>
|
||||
<span class="check" aria-hidden="true">
|
||||
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
|
||||
<span class="row-name">{{ u.fullName }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
|
||||
</ul>
|
||||
<div class="foot">
|
||||
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
|
||||
<button type="button" class="done" @click="closeList">تأیید</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-select
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:value="modelValue"
|
||||
:options="options"
|
||||
:disabled="disabled"
|
||||
:placeholder="emptyLabel"
|
||||
style="width: 100%"
|
||||
@update:value="onUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ums {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.18rem 0.25rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #f0fdfa;
|
||||
border: 1px solid #ccfbf1;
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chip-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chip-x {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chip-x:hover {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
height: 30px;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px dashed #5eead4;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add:hover:not(:disabled) {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pop {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
inset-inline-start: 0;
|
||||
z-index: 30;
|
||||
width: min(18rem, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
||||
border: 1px solid #f1f5f9;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.5rem;
|
||||
padding: 0 0.6rem;
|
||||
height: 2.2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.35rem 0.35rem;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.42rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 500;
|
||||
color: #0f172a;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.row.on {
|
||||
background: #f0fdfa;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row.on .check {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.done {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
padding: 0.35rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { Button as AButton, Typography } from 'ant-design-vue';
|
||||
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
type SpeechResult = {
|
||||
isFinal: boolean;
|
||||
@@ -186,102 +189,23 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vtt-wrap" :class="[variant, { live: recording }]">
|
||||
<button
|
||||
class="vtt"
|
||||
type="button"
|
||||
:class="{ on: recording }"
|
||||
<div>
|
||||
<a-button
|
||||
:type="recording ? 'primary' : 'default'"
|
||||
:danger="recording"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:title="ariaLabel"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="glyph" aria-hidden="true">
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
|
||||
</span>
|
||||
<span v-if="recording" class="timer">{{ timerLabel }}</span>
|
||||
</button>
|
||||
<p v-if="error && !hideError" class="err">{{ error }}</p>
|
||||
<template #icon>
|
||||
<PauseCircleOutlined v-if="recording" />
|
||||
<AudioOutlined v-else />
|
||||
</template>
|
||||
<span v-if="recording">{{ timerLabel }}</span>
|
||||
</a-button>
|
||||
<div v-if="error && !hideError">
|
||||
<a-typography-text type="danger">{{ error }}</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vtt-wrap {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.vtt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid #d7e3dc;
|
||||
background: #f4faf7;
|
||||
color: #0f766e;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtt:hover:not(:disabled) {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.vtt.on {
|
||||
border-color: #5eead4;
|
||||
background: #042f2e;
|
||||
color: #ecfdf5;
|
||||
padding-inline: 0.7rem 0.85rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 118, 110, 0.22);
|
||||
}
|
||||
|
||||
.vtt:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.4rem;
|
||||
}
|
||||
|
||||
.toolbar .vtt {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius);
|
||||
border-color: transparent;
|
||||
background: #f8fafc;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar.live .vtt {
|
||||
width: auto;
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
padding: 0 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
background: #042f2e;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { Button as AButton } from 'ant-design-vue';
|
||||
import type { PipelineAccent } from '@/lib/pipeline-board';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
|
||||
@@ -18,15 +18,14 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton
|
||||
variant="dashed"
|
||||
<a-button
|
||||
type="dashed"
|
||||
block
|
||||
class="add-deal"
|
||||
:style="{ color: PIPELINE_ACCENT[accent].text }"
|
||||
:aria-label="label"
|
||||
@click="emit('add')"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ label }}
|
||||
</UiButton>
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { userAvatarSrc } from '@/lib/userAvatars';
|
||||
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import {
|
||||
@@ -30,6 +45,9 @@ const emit = defineEmits<{
|
||||
bulkDelete: [];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const showAmount = computed(
|
||||
() => !props.copy.hideAmount && props.deal.amount != null,
|
||||
);
|
||||
@@ -47,306 +65,144 @@ const extraAssignees = computed(() =>
|
||||
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
|
||||
);
|
||||
const showTrash = computed(() => props.selectMode && props.selected);
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||
opacity: props.dragging ? 0.45 : 1,
|
||||
margin: 0,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="deal-card"
|
||||
:class="{
|
||||
dragging,
|
||||
'no-amount': !showAmount,
|
||||
'select-mode': selectMode,
|
||||
selected,
|
||||
}"
|
||||
<a-card
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="{ padding: '12px 14px', margin: 0 }"
|
||||
:style="cardStyle"
|
||||
:data-card-id="deal.id"
|
||||
:draggable="!selectMode"
|
||||
role="listitem"
|
||||
:aria-grabbed="dragging ? 'true' : 'false'"
|
||||
:style="{
|
||||
'--badge-bg': colors.soft,
|
||||
'--badge-fg': colors.text,
|
||||
}"
|
||||
@dragstart="!selectMode && emit('dragStart', deal, $event)"
|
||||
@dragend="emit('dragEnd')"
|
||||
@dblclick="!selectMode && emit('open', deal.id)"
|
||||
@click="selectMode && emit('toggleSelect', deal.id)"
|
||||
>
|
||||
<button
|
||||
v-if="selectMode"
|
||||
type="button"
|
||||
class="tick"
|
||||
:class="{ on: selected }"
|
||||
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="emit('toggleSelect', deal.id)"
|
||||
>
|
||||
<UiIcon v-if="selected" name="check" :size="14" />
|
||||
</button>
|
||||
<span v-else class="badge" aria-hidden="true">{{ deal.initials }}</span>
|
||||
<div class="titles">
|
||||
<h3 @click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)">
|
||||
{{ deal.title }}
|
||||
</h3>
|
||||
<p v-if="deal.description" class="desc">{{ deal.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="showTrash"
|
||||
type="button"
|
||||
class="menu trash"
|
||||
aria-label="حذف انتخابشدهها"
|
||||
@click.stop="emit('bulkDelete')"
|
||||
>
|
||||
<UiIcon name="trash" :size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu"
|
||||
:aria-label="copy.itemMenuAria"
|
||||
@click.stop="emit('menu', deal.id)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<img
|
||||
class="avatar"
|
||||
:src="avatarSrc"
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
:title="assigneeTitle"
|
||||
/>
|
||||
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle">
|
||||
+{{ extraAssignees }}
|
||||
</span>
|
||||
<span class="date">
|
||||
{{ deal.date }}
|
||||
<UiIcon name="calendar" :size="13" />
|
||||
</span>
|
||||
<div v-if="showAmount" class="amount">
|
||||
<strong>{{ money.number }}</strong>
|
||||
<span>{{ money.unit }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
|
||||
<a-flex :gap="10" align="flex-start" :style="{ minWidth: 0 }">
|
||||
<a-button
|
||||
v-if="selectMode"
|
||||
type="text"
|
||||
shape="circle"
|
||||
:aria-label="selected ? 'لغو انتخاب' : 'انتخاب معامله'"
|
||||
:aria-pressed="selected"
|
||||
:style="{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
minWidth: '28px',
|
||||
color: selected ? token.colorTextLightSolid : token.colorTextTertiary,
|
||||
border: `2px solid ${selected ? token.colorPrimary : token.colorBorder}`,
|
||||
background: selected ? token.colorPrimary : token.colorBgContainer,
|
||||
}"
|
||||
@click.stop="emit('toggleSelect', deal.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckOutlined v-if="selected" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-avatar
|
||||
v-else
|
||||
:style="{
|
||||
background: colors.soft,
|
||||
color: colors.text,
|
||||
flexShrink: 0,
|
||||
fontWeight: 700,
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
}"
|
||||
>
|
||||
{{ deal.initials }}
|
||||
</a-avatar>
|
||||
|
||||
<div :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text
|
||||
strong
|
||||
ellipsis
|
||||
:style="{ display: 'block', margin: 0, lineHeight: 1.35, cursor: 'pointer' }"
|
||||
:title="deal.title"
|
||||
@click.stop="selectMode ? emit('toggleSelect', deal.id) : emit('open', deal.id)"
|
||||
>
|
||||
{{ deal.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="deal.description"
|
||||
type="secondary"
|
||||
ellipsis
|
||||
:style="{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
marginTop: '2px',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
}"
|
||||
:title="deal.description"
|
||||
>
|
||||
{{ deal.description }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<a-button
|
||||
v-if="showTrash"
|
||||
type="text"
|
||||
danger
|
||||
aria-label="حذف انتخابشدهها"
|
||||
@click.stop="emit('bulkDelete')"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="text"
|
||||
:aria-label="copy.itemMenuAria"
|
||||
@click.stop="emit('menu', deal.id)"
|
||||
>
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
:gap="8"
|
||||
:style="{
|
||||
minWidth: 0,
|
||||
paddingTop: '8px',
|
||||
borderTop: `1px solid ${token.colorSplit}`,
|
||||
}"
|
||||
>
|
||||
<a-space :size="8" align="center" :style="{ minWidth: 0 }">
|
||||
<a-avatar :src="avatarSrc" :size="24" :title="assigneeTitle" />
|
||||
<a-tag v-if="extraAssignees > 0" :bordered="false" :title="assigneeTitle">
|
||||
+{{ extraAssignees }}
|
||||
</a-tag>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
<CalendarOutlined :style="{ marginInlineEnd: '4px' }" />
|
||||
{{ deal.date }}
|
||||
</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text
|
||||
v-if="showAmount"
|
||||
strong
|
||||
dir="ltr"
|
||||
:style="{ margin: 0, whiteSpace: 'nowrap', flexShrink: 0 }"
|
||||
>
|
||||
{{ money.number }}
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ money.unit }}
|
||||
</a-typography-text>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.deal-card {
|
||||
--face: 32px;
|
||||
display: grid;
|
||||
grid-template-columns: var(--face) minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.65rem;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 0.7rem 0.85rem 0.55rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
cursor: grab;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease,
|
||||
background 0.18s ease;
|
||||
text-align: right;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.deal-card.select-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deal-card.selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
}
|
||||
|
||||
.deal-card:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.deal-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.deal-card.select-mode:active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deal-card.dragging {
|
||||
opacity: 0.95;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
}
|
||||
|
||||
.deal-card.no-amount .date {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.deal-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.avatar,
|
||||
.tick {
|
||||
width: var(--face);
|
||||
height: var(--face);
|
||||
max-width: var(--face);
|
||||
border-radius: 999px;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-fg);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tick.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.more-assignees {
|
||||
width: auto;
|
||||
min-width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
padding: 0 0.22rem;
|
||||
margin-inline-start: -1.55rem;
|
||||
margin-top: 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.titles {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.titles h3 {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0.1rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu:hover,
|
||||
.menu:focus-visible {
|
||||
color: var(--ink);
|
||||
background: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu.trash {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.menu.trash:hover,
|
||||
.menu.trash:focus-visible {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amount {
|
||||
text-align: left;
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.02rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.amount strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
letter-spacing: -0.01em;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.amount span {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
Space as ASpace,
|
||||
} from 'ant-design-vue';
|
||||
import PipelineColumn from './PipelineColumn.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import { DEAL_BOARD_COPY } from '@/lib/pipeline-board';
|
||||
@@ -39,13 +46,40 @@ const emit = defineEmits<{
|
||||
|
||||
const adding = ref(false);
|
||||
const newName = ref('');
|
||||
const addInput = ref<HTMLInputElement | null>(null);
|
||||
const addInput = ref<{ focus: () => void } | null>(null);
|
||||
|
||||
const shellStyle = {
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
padding: 0,
|
||||
minWidth: 0,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
};
|
||||
|
||||
const trackStyle = {
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'stretch' as const,
|
||||
gap: '16px',
|
||||
width: '100%',
|
||||
overflowX: 'auto' as const,
|
||||
overflowY: 'hidden' as const,
|
||||
};
|
||||
|
||||
async function startAdd() {
|
||||
adding.value = true;
|
||||
newName.value = '';
|
||||
await nextTick();
|
||||
addInput.value?.focus();
|
||||
addInput.value?.focus?.();
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
@@ -63,193 +97,88 @@ function submitAdd() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board-shell" dir="rtl">
|
||||
<div class="board">
|
||||
<PipelineColumn
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
:stage="stage"
|
||||
:dragging-id="draggingId"
|
||||
:drop-active="dropStageId === stage.id"
|
||||
:can-manage="canManage"
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === stages.length - 1"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-over="(id, e) => emit('dragOver', id, e)"
|
||||
@drop="(id, e, index) => emit('drop', id, e, index)"
|
||||
@add-deal="emit('addDeal', $event)"
|
||||
@open-deal="emit('openDeal', $event)"
|
||||
@menu-deal="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename="(id, title) => emit('renameStage', id, title)"
|
||||
@delete="emit('deleteStage', $event)"
|
||||
@move="(id, dir) => emit('moveStage', id, dir)"
|
||||
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
|
||||
@column-drag-end="emit('columnDragEnd')"
|
||||
@load-more="emit('loadMore', $event)"
|
||||
/>
|
||||
<div class="pipeline-board-shell" dir="rtl" :style="shellStyle">
|
||||
<div class="pipeline-board-track" :style="trackStyle">
|
||||
<PipelineColumn
|
||||
v-for="(stage, index) in stages"
|
||||
:key="stage.id"
|
||||
:stage="stage"
|
||||
:dragging-id="draggingId"
|
||||
:drop-active="dropStageId === stage.id"
|
||||
:can-manage="canManage"
|
||||
:copy="copy"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
:is-first="index === 0"
|
||||
:is-last="index === stages.length - 1"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-over="(id, e) => emit('dragOver', id, e)"
|
||||
@drop="(id, e, index) => emit('drop', id, e, index)"
|
||||
@add-deal="emit('addDeal', $event)"
|
||||
@open-deal="emit('openDeal', $event)"
|
||||
@menu-deal="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename="(id, title) => emit('renameStage', id, title)"
|
||||
@delete="emit('deleteStage', $event)"
|
||||
@move="(id, dir) => emit('moveStage', id, dir)"
|
||||
@column-drag-start="(id, e) => emit('columnDragStart', id, e)"
|
||||
@column-drag-end="emit('columnDragEnd')"
|
||||
@load-more="emit('loadMore', $event)"
|
||||
/>
|
||||
|
||||
<section v-if="canManage" class="add-col">
|
||||
<form v-if="adding" class="add-form" @submit.prevent="submitAdd">
|
||||
<input
|
||||
ref="addInput"
|
||||
v-model="newName"
|
||||
maxlength="80"
|
||||
placeholder="نام ستون جدید…"
|
||||
aria-label="نام ستون جدید"
|
||||
@keydown.esc.prevent="cancelAdd"
|
||||
/>
|
||||
<div class="add-actions">
|
||||
<button class="ok" type="submit" :disabled="newName.trim().length < 1">
|
||||
افزودن
|
||||
</button>
|
||||
<button class="cancel" type="button" @click="cancelAdd">انصراف</button>
|
||||
</div>
|
||||
</form>
|
||||
<button v-else type="button" class="add-btn" @click="startAdd">
|
||||
<UiIcon name="plus" :size="18" />
|
||||
افزودن ستون
|
||||
</button>
|
||||
</section>
|
||||
<div
|
||||
v-if="canManage"
|
||||
:style="{
|
||||
flex: '0 0 280px',
|
||||
width: '280px',
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}"
|
||||
>
|
||||
<a-card v-if="adding" :style="{ width: '100%' }" :body-style="{ padding: '12px' }">
|
||||
<a-flex vertical :gap="12">
|
||||
<a-input
|
||||
ref="addInput"
|
||||
v-model:value="newName"
|
||||
:maxlength="80"
|
||||
placeholder="نام ستون جدید…"
|
||||
aria-label="نام ستون جدید"
|
||||
@pressEnter="submitAdd"
|
||||
@keydown.esc.prevent="cancelAdd"
|
||||
/>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="newName.trim().length < 1"
|
||||
@click="submitAdd"
|
||||
>
|
||||
افزودن
|
||||
</a-button>
|
||||
<a-button @click="cancelAdd">انصراف</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
<a-button v-else type="dashed" block :style="{ minHeight: '52px' }" @click="startAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن ستون
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board-shell {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 0.1rem 0.05rem 0.5rem;
|
||||
touch-action: pan-x pan-y;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
.pipeline-board-track {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent;
|
||||
}
|
||||
|
||||
.board::-webkit-scrollbar {
|
||||
.pipeline-board-track::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.board::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--muted) 38%, transparent);
|
||||
.pipeline-board-track::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.add-col {
|
||||
flex: 0 0 17.5rem;
|
||||
min-width: 17.5rem;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
min-height: 3.2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
border: 1.5px dashed var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-deep, #0f766e);
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
}
|
||||
|
||||
.add-form {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.add-form input {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.add-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.add-actions .ok,
|
||||
.add-actions .cancel {
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-actions .ok {
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.add-actions .ok:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.add-actions .cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@container pipeline (max-width: 720px) {
|
||||
.board {
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.add-col {
|
||||
flex: 0 0 min(20rem, 86cqi);
|
||||
width: min(20rem, 86cqi);
|
||||
min-width: min(20rem, 86cqi);
|
||||
min-height: 360px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
background: color-mix(in srgb, currentColor 28%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import {
|
||||
HolderOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Dropdown as ADropdown,
|
||||
Input as AInput,
|
||||
Menu as AMenu,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import AddDealButton from './AddDealButton.vue';
|
||||
import DealCard from './DealCard.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
@@ -39,11 +52,63 @@ const emit = defineEmits<{
|
||||
loadMore: [stageId: string];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const renaming = ref(false);
|
||||
const draft = ref('');
|
||||
const titleInput = ref<HTMLInputElement | null>(null);
|
||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||
const menuOpen = ref(false);
|
||||
const menuRoot = ref<HTMLElement | null>(null);
|
||||
|
||||
const accent = computed(() => PIPELINE_ACCENT[props.stage.color]);
|
||||
|
||||
const columnStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
flex: '0 0 280px',
|
||||
width: '280px',
|
||||
alignSelf: 'stretch' as const,
|
||||
minHeight: 0,
|
||||
padding: 0,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
border: `1px solid ${
|
||||
props.dropActive ? token.value.colorPrimary : token.value.colorBorderSecondary
|
||||
}`,
|
||||
background: props.dropActive
|
||||
? token.value.colorPrimaryBg
|
||||
: token.value.colorFillAlter,
|
||||
boxShadow: props.dropActive
|
||||
? `0 0 0 2px ${token.value.colorPrimaryBg}`
|
||||
: undefined,
|
||||
overflow: 'hidden' as const,
|
||||
}));
|
||||
|
||||
const headStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
background: token.value.colorBgContainer,
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
const bodyStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
@@ -62,7 +127,11 @@ function startRename() {
|
||||
draft.value = props.stage.title;
|
||||
renaming.value = true;
|
||||
menuOpen.value = false;
|
||||
void nextTick(() => titleInput.value?.select());
|
||||
void nextTick(() => {
|
||||
const el = titleInput.value as unknown as HTMLInputElement | null;
|
||||
el?.focus?.();
|
||||
el?.select?.();
|
||||
});
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
@@ -78,144 +147,133 @@ function cancelRename() {
|
||||
draft.value = props.stage.title;
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value;
|
||||
}
|
||||
|
||||
function onColumnDragStart(event: DragEvent) {
|
||||
event.dataTransfer?.setData('text/plain', `stage:${props.stage.id}`);
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
emit('columnDragStart', props.stage.id, event);
|
||||
}
|
||||
|
||||
function onDocPointerDown(event: PointerEvent) {
|
||||
const target = event.target as Node | null;
|
||||
const root = menuRoot.value;
|
||||
if (root && target && !root.contains(target)) menuOpen.value = false;
|
||||
const menuItems = computed(() => [
|
||||
{ key: 'rename', label: 'تغییر نام' },
|
||||
{ key: 'right', label: 'انتقال به راست', disabled: props.isFirst },
|
||||
{ key: 'left', label: 'انتقال به چپ', disabled: props.isLast },
|
||||
{ key: 'delete', label: 'حذف ستون', danger: true },
|
||||
]);
|
||||
|
||||
function onMenuClick({ key }: { key: string | number }) {
|
||||
menuOpen.value = false;
|
||||
if (key === 'rename') startRename();
|
||||
else if (key === 'right') emit('move', props.stage.id, -1);
|
||||
else if (key === 'left') emit('move', props.stage.id, 1);
|
||||
else if (key === 'delete') emit('delete', props.stage.id);
|
||||
}
|
||||
|
||||
function onMenuOpened() {
|
||||
document.addEventListener('pointerdown', onDocPointerDown);
|
||||
}
|
||||
|
||||
function onMenuClosed() {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
}
|
||||
|
||||
watch(menuOpen, (open) => {
|
||||
if (open) onMenuOpened();
|
||||
else onMenuClosed();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocPointerDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="column"
|
||||
:class="{ drop: dropActive }"
|
||||
:style="{
|
||||
'--stage-solid': PIPELINE_ACCENT[stage.color].solid,
|
||||
'--stage-soft': PIPELINE_ACCENT[stage.color].soft,
|
||||
'--stage-border': PIPELINE_ACCENT[stage.color].border,
|
||||
'--stage-text': PIPELINE_ACCENT[stage.color].text,
|
||||
}"
|
||||
:style="columnStyle"
|
||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="emit('drop', stage.id, $event)"
|
||||
>
|
||||
<header class="column-head">
|
||||
<div :style="headStyle">
|
||||
<span
|
||||
v-if="canManage"
|
||||
class="grip"
|
||||
draggable="true"
|
||||
title="کشیدن برای جابهجایی"
|
||||
aria-label="جابهجایی ستون"
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
flexShrink: 0,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
color: token.colorTextTertiary,
|
||||
cursor: 'grab',
|
||||
background: token.colorFillQuaternary,
|
||||
}"
|
||||
@dragstart="onColumnDragStart"
|
||||
@dragend="emit('columnDragEnd')"
|
||||
>
|
||||
<UiIcon name="grip" :size="14" />
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
<div class="title-wrap">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="titleInput"
|
||||
v-model="draft"
|
||||
class="title-input"
|
||||
maxlength="80"
|
||||
aria-label="نام ستون"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<h2
|
||||
v-else
|
||||
:class="{ editable: canManage }"
|
||||
:title="canManage ? 'برای تغییر نام کلیک کنید' : undefined"
|
||||
@click="startRename"
|
||||
>
|
||||
{{ stage.title }}
|
||||
</h2>
|
||||
<small v-if="stage.badge && !renaming" class="stage-badge">{{ stage.badge }}</small>
|
||||
</div>
|
||||
<span class="count">{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}</span>
|
||||
<div v-if="canManage" ref="menuRoot" class="more-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label="گزینههای ستون"
|
||||
:aria-expanded="menuOpen"
|
||||
@click.stop="toggleMenu"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="16" />
|
||||
</button>
|
||||
<div v-if="menuOpen" class="col-menu" role="menu">
|
||||
<button type="button" role="menuitem" @click="startRename">
|
||||
تغییر نام
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="isFirst"
|
||||
@click="emit('move', stage.id, -1); menuOpen = false"
|
||||
>
|
||||
انتقال به راست
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="isLast"
|
||||
@click="emit('move', stage.id, 1); menuOpen = false"
|
||||
>
|
||||
انتقال به چپ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="danger"
|
||||
@click="emit('delete', stage.id); menuOpen = false"
|
||||
>
|
||||
حذف ستون
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="plus"
|
||||
|
||||
<span
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: accent.solid,
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-if="renaming"
|
||||
ref="titleInput"
|
||||
v-model:value="draft"
|
||||
:maxlength="80"
|
||||
aria-label="نام ستون"
|
||||
:style="{ flex: 1, minWidth: 0 }"
|
||||
@pressEnter="commitRename"
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
@blur="commitRename"
|
||||
/>
|
||||
<a-typography-text
|
||||
v-else
|
||||
strong
|
||||
ellipsis
|
||||
:style="{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
margin: 0,
|
||||
cursor: canManage ? 'text' : 'default',
|
||||
}"
|
||||
:title="canManage ? 'برای تغییر نام کلیک کنید' : stage.title"
|
||||
@click="startRename"
|
||||
>
|
||||
{{ stage.title }}
|
||||
</a-typography-text>
|
||||
|
||||
<a-tag v-if="stage.badge && !renaming" :bordered="false">
|
||||
{{ stage.badge }}
|
||||
</a-tag>
|
||||
|
||||
<a-typography-text type="secondary" :style="{ whiteSpace: 'nowrap', flexShrink: 0 }">
|
||||
{{ toFaItemCount(stage.total ?? stage.deals.length, copy.itemNoun) }}
|
||||
</a-typography-text>
|
||||
|
||||
<a-dropdown
|
||||
v-if="canManage"
|
||||
v-model:open="menuOpen"
|
||||
:trigger="['click']"
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<a-button type="text" aria-label="گزینههای ستون" @click.stop>
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu :items="menuItems" @click="onMenuClick" />
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<a-button
|
||||
type="text"
|
||||
:aria-label="copy.addItemAria"
|
||||
:style="{ color: accent.solid }"
|
||||
@click="emit('addDeal', stage.id)"
|
||||
>
|
||||
<UiIcon name="plus" :size="16" />
|
||||
</button>
|
||||
</header>
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="cards" role="list" :aria-label="stage.title">
|
||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div
|
||||
v-for="(deal, index) in stage.deals"
|
||||
:key="deal.id"
|
||||
class="card-slot"
|
||||
@dragover="onCardDragOver(stage.id, $event)"
|
||||
@drop="onCardDrop(stage.id, index, $event)"
|
||||
>
|
||||
@@ -234,256 +292,26 @@ onBeforeUnmount(() => {
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="!stage.deals.length" class="empty">{{ copy.empty }}</p>
|
||||
<button
|
||||
<a-typography-text
|
||||
v-if="!stage.deals.length"
|
||||
type="secondary"
|
||||
:style="{ textAlign: 'center', display: 'block', margin: 'auto', padding: '16px' }"
|
||||
>
|
||||
{{ copy.empty }}
|
||||
</a-typography-text>
|
||||
<a-button
|
||||
v-if="stage.hasMore"
|
||||
type="button"
|
||||
class="load-more"
|
||||
:disabled="stage.loadingMore"
|
||||
type="dashed"
|
||||
block
|
||||
:loading="stage.loadingMore"
|
||||
@click="emit('loadMore', stage.id)"
|
||||
>
|
||||
{{ stage.loadingMore ? 'در حال بارگذاری…' : 'نمایش بیشتر' }}
|
||||
</button>
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
|
||||
<div :style="{ padding: '0 12px 12px', flexShrink: 0 }">
|
||||
<AddDealButton :accent="stage.color" :label="copy.addItem" @add="emit('addDeal', stage.id)" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
flex: 1 1 17.5rem;
|
||||
min-width: 17.5rem;
|
||||
min-height: 420px;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.column.drop {
|
||||
background: color-mix(in srgb, var(--stage-soft) 70%, transparent);
|
||||
border-color: color-mix(in srgb, var(--stage-border) 80%, transparent);
|
||||
}
|
||||
|
||||
.column-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0.1rem 0.15rem;
|
||||
}
|
||||
|
||||
.grip {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
color: var(--muted);
|
||||
cursor: grab;
|
||||
flex: 0 0 auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--stage-solid);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.column-head h2,
|
||||
.title-input {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.column-head h2 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column-head h2.editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
height: 1.7rem;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.icon-btn,
|
||||
.plus {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.icon-btn:hover,
|
||||
.icon-btn:focus-visible {
|
||||
background: var(--stage-soft);
|
||||
color: var(--stage-solid);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.plus {
|
||||
border: 1.5px solid var(--stage-solid);
|
||||
color: var(--stage-solid);
|
||||
}
|
||||
|
||||
.plus:hover,
|
||||
.plus:focus-visible {
|
||||
background: var(--stage-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.col-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
inset-inline-end: 0;
|
||||
z-index: 8;
|
||||
min-width: 10.5rem;
|
||||
padding: 0.3rem;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.col-menu button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.col-menu button:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.col-menu button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.col-menu .danger {
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.card-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stage-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--stage-soft);
|
||||
color: var(--stage-text);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
min-height: var(--control-h);
|
||||
border: 1px dashed var(--stage-border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: var(--stage-text);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more:hover:not(:disabled) {
|
||||
background: var(--stage-soft);
|
||||
}
|
||||
|
||||
.load-more:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@container pipeline (max-width: 720px) {
|
||||
.column {
|
||||
flex: 0 0 min(20rem, 86cqi);
|
||||
width: min(20rem, 86cqi);
|
||||
min-width: min(20rem, 86cqi);
|
||||
max-width: none;
|
||||
min-height: 360px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||
import type {
|
||||
PipelineAccent,
|
||||
PipelineDeal,
|
||||
PipelineStage,
|
||||
PipelineUiCopy,
|
||||
} from '@/lib/pipeline-board';
|
||||
import {
|
||||
DEAL_BOARD_COPY,
|
||||
formatCurrencyParts,
|
||||
@@ -26,14 +49,28 @@ const emit = defineEmits<{
|
||||
bulkDelete: [];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const listRoot = ref<HTMLElement | null>(null);
|
||||
/** فقط یکی از جدول/کارت در DOM باشد تا منوی انتخاب گم نشود */
|
||||
const useCards = ref(false);
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
|
||||
const rows = computed(() =>
|
||||
type Row = {
|
||||
key: string;
|
||||
deal: PipelineDeal;
|
||||
stageTitle: string;
|
||||
accent: PipelineAccent;
|
||||
};
|
||||
|
||||
function accentColors(accent: PipelineAccent) {
|
||||
return PIPELINE_ACCENT[accent];
|
||||
}
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
props.stages.flatMap((stage) =>
|
||||
stage.deals.map((deal) => ({
|
||||
key: deal.id,
|
||||
deal,
|
||||
stageTitle: stage.title,
|
||||
accent: stage.color,
|
||||
@@ -48,6 +85,33 @@ function money(deal: PipelineDeal) {
|
||||
const hideAmount = computed(() => Boolean(props.copy.hideAmount));
|
||||
const tableKey = computed(() => (hideAmount.value ? 'pipeline.leads' : 'pipeline.deals'));
|
||||
|
||||
const columns = computed<TableColumnsType<Row>>(() => {
|
||||
const cols: TableColumnsType<Row> = [];
|
||||
if (props.selectMode) {
|
||||
cols.push({ key: 'select', title: '', width: 56 });
|
||||
}
|
||||
cols.push(
|
||||
{ key: 'title', title: props.copy.listColTitle },
|
||||
{ key: 'stage', title: 'مرحله' },
|
||||
);
|
||||
if (!hideAmount.value) {
|
||||
cols.push({ key: 'amount', title: 'مبلغ' });
|
||||
}
|
||||
cols.push(
|
||||
{ key: 'date', title: 'تاریخ' },
|
||||
{ key: 'actions', title: 'عملیات', width: 72, align: 'center' },
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
|
||||
function customRow(record: Row) {
|
||||
return {
|
||||
onClick: () => {
|
||||
if (props.selectMode) emit('toggleSelect', record.deal.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = listRoot.value;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
@@ -66,445 +130,211 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRoot" class="list" dir="rtl">
|
||||
<p v-if="!rows.length" class="empty">{{ copy.empty }}</p>
|
||||
<div ref="listRoot" dir="rtl" :style="{ minWidth: 0 }">
|
||||
<a-empty v-if="!rows.length" :description="copy.empty" />
|
||||
|
||||
<div v-if="rows.length && !useCards" class="tc-row">
|
||||
<TableColumnsMenu :table-key="tableKey" align="end" />
|
||||
</div>
|
||||
<div v-if="rows.length && !useCards" class="table-wrap">
|
||||
<table v-table-cols="tableKey" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="selectMode" class="tick-col"></th>
|
||||
<th>{{ copy.listColTitle }}</th>
|
||||
<th>مرحله</th>
|
||||
<th v-if="!hideAmount">مبلغ</th>
|
||||
<th>تاریخ</th>
|
||||
<th class="menu-col">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.deal.id"
|
||||
:data-card-id="row.deal.id"
|
||||
:class="{ selected: selectedIds.has(row.deal.id) }"
|
||||
@click="selectMode && emit('toggleSelect', row.deal.id)"
|
||||
>
|
||||
<td v-if="selectMode" class="tick-col" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="tick"
|
||||
:class="{ on: selectedIds.has(row.deal.id) }"
|
||||
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
|
||||
:aria-pressed="selectedIds.has(row.deal.id)"
|
||||
@click="emit('toggleSelect', row.deal.id)"
|
||||
>
|
||||
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="identity"
|
||||
@click.stop="
|
||||
selectMode
|
||||
? emit('toggleSelect', row.deal.id)
|
||||
: emit('openDeal', row.deal.id)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="badge"
|
||||
:style="{
|
||||
background: PIPELINE_ACCENT[row.accent].soft,
|
||||
color: PIPELINE_ACCENT[row.accent].text,
|
||||
}"
|
||||
>{{ row.deal.initials }}</span>
|
||||
<span class="titles">
|
||||
<strong>{{ row.deal.title }}</strong>
|
||||
<em v-if="row.deal.description">{{ row.deal.description }}</em>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="stage"
|
||||
<template v-else-if="!useCards">
|
||||
<a-flex justify="flex-end" :style="{ marginBottom: '8px' }">
|
||||
<TableColumnsMenu :table-key="tableKey" align="end" />
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
:custom-row="customRow"
|
||||
:row-class-name="(record: Row) => (selectedIds.has(record.deal.id) ? 'pipeline-row-selected' : '')"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'select'">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
:aria-label="selectedIds.has(record.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
|
||||
@click.stop="emit('toggleSelect', record.deal.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckOutlined v-if="selectedIds.has(record.deal.id)" />
|
||||
</template>
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'title'">
|
||||
<a-flex
|
||||
:gap="10"
|
||||
align="center"
|
||||
:style="{ minWidth: 0, cursor: 'pointer' }"
|
||||
@click.stop="
|
||||
selectMode
|
||||
? emit('toggleSelect', record.deal.id)
|
||||
: emit('openDeal', record.deal.id)
|
||||
"
|
||||
>
|
||||
<a-avatar
|
||||
:style="{
|
||||
background: PIPELINE_ACCENT[row.accent].soft,
|
||||
color: PIPELINE_ACCENT[row.accent].text,
|
||||
background: accentColors(record.accent).soft,
|
||||
color: accentColors(record.accent).text,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
|
||||
{{ row.stageTitle }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-if="!hideAmount">
|
||||
<span class="amount" dir="ltr">
|
||||
<strong>{{ money(row.deal).number }}</strong>
|
||||
<small>{{ money(row.deal).unit }}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="date">
|
||||
<UiIcon name="calendar" :size="14" />
|
||||
{{ row.deal.date }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="menu-cell" @click.stop>
|
||||
<button
|
||||
v-if="selectMode && selectedIds.has(row.deal.id)"
|
||||
type="button"
|
||||
class="menu trash"
|
||||
aria-label="حذف انتخابشدهها"
|
||||
@click="emit('bulkDelete')"
|
||||
>
|
||||
<UiIcon name="trash" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu"
|
||||
:aria-label="copy.itemMenuAria"
|
||||
@click="emit('menuDeal', row.deal.id)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ record.deal.initials }}
|
||||
</a-avatar>
|
||||
<div :style="{ minWidth: 0 }">
|
||||
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
|
||||
{{ record.deal.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="record.deal.description"
|
||||
type="secondary"
|
||||
ellipsis
|
||||
:style="{ display: 'block', margin: 0, fontSize: `${token.fontSizeSM}px` }"
|
||||
>
|
||||
{{ record.deal.description }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stage'">
|
||||
<a-tag
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: accentColors(record.accent).soft,
|
||||
color: accentColors(record.accent).text,
|
||||
}"
|
||||
>
|
||||
{{ record.stageTitle }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'amount'">
|
||||
<span dir="ltr">
|
||||
<a-typography-text strong>{{ money(record.deal).number }}</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
|
||||
{{ money(record.deal).unit }}
|
||||
</a-typography-text>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'date'">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<span>{{ record.deal.date }}</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
v-if="selectMode && selectedIds.has(record.deal.id)"
|
||||
type="text"
|
||||
danger
|
||||
aria-label="حذف انتخابشدهها"
|
||||
@click.stop="emit('bulkDelete')"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
type="text"
|
||||
:aria-label="copy.itemMenuAria"
|
||||
@click.stop="emit('menuDeal', record.deal.id)"
|
||||
>
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<div v-if="rows.length && useCards" class="cards">
|
||||
<article
|
||||
<a-flex v-else vertical :gap="12">
|
||||
<a-card
|
||||
v-for="row in rows"
|
||||
:key="row.deal.id"
|
||||
class="card"
|
||||
:class="{ selected: selectedIds.has(row.deal.id) }"
|
||||
hoverable
|
||||
:data-card-id="row.deal.id"
|
||||
:body-style="{ padding: '14px 16px' }"
|
||||
:style="{
|
||||
background: selectedIds.has(row.deal.id)
|
||||
? token.colorPrimaryBg
|
||||
: token.colorBgContainer,
|
||||
borderColor: selectedIds.has(row.deal.id)
|
||||
? token.colorPrimary
|
||||
: token.colorBorderSecondary,
|
||||
}"
|
||||
@dblclick="!selectMode && emit('openDeal', row.deal.id)"
|
||||
@click="selectMode && emit('toggleSelect', row.deal.id)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<button
|
||||
v-if="selectMode"
|
||||
type="button"
|
||||
class="tick"
|
||||
:class="{ on: selectedIds.has(row.deal.id) }"
|
||||
:aria-label="selectedIds.has(row.deal.id) ? 'لغو انتخاب' : 'انتخاب معامله'"
|
||||
@click.stop="emit('toggleSelect', row.deal.id)"
|
||||
>
|
||||
<UiIcon v-if="selectedIds.has(row.deal.id)" name="check" :size="14" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="identity"
|
||||
@click.stop="
|
||||
selectMode
|
||||
? emit('toggleSelect', row.deal.id)
|
||||
: emit('openDeal', row.deal.id)
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="badge"
|
||||
<a-flex vertical :gap="12">
|
||||
<a-flex align="center" :gap="10" :style="{ minWidth: 0 }">
|
||||
<a-button
|
||||
v-if="selectMode"
|
||||
type="text"
|
||||
shape="circle"
|
||||
@click.stop="emit('toggleSelect', row.deal.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckOutlined v-if="selectedIds.has(row.deal.id)" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-avatar
|
||||
:style="{
|
||||
background: PIPELINE_ACCENT[row.accent].soft,
|
||||
color: PIPELINE_ACCENT[row.accent].text,
|
||||
background: accentColors(row.accent).soft,
|
||||
color: accentColors(row.accent).text,
|
||||
}"
|
||||
>{{ row.deal.initials }}</span>
|
||||
<span class="titles">
|
||||
<strong>{{ row.deal.title }}</strong>
|
||||
<em v-if="row.deal.description">{{ row.deal.description }}</em>
|
||||
>
|
||||
{{ row.deal.initials }}
|
||||
</a-avatar>
|
||||
<div
|
||||
:style="{ flex: 1, minWidth: 0, cursor: 'pointer' }"
|
||||
@click.stop="
|
||||
selectMode
|
||||
? emit('toggleSelect', row.deal.id)
|
||||
: emit('openDeal', row.deal.id)
|
||||
"
|
||||
>
|
||||
<a-typography-text strong ellipsis :style="{ display: 'block', margin: 0 }">
|
||||
{{ row.deal.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="row.deal.description"
|
||||
type="secondary"
|
||||
ellipsis
|
||||
:style="{ display: 'block', margin: 0 }"
|
||||
>
|
||||
{{ row.deal.description }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="selectMode && selectedIds.has(row.deal.id)"
|
||||
type="text"
|
||||
danger
|
||||
@click.stop="emit('bulkDelete')"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
<a-button v-else type="text" @click.stop="emit('menuDeal', row.deal.id)">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-tag
|
||||
:bordered="false"
|
||||
:style="{
|
||||
alignSelf: 'flex-start',
|
||||
background: accentColors(row.accent).soft,
|
||||
color: accentColors(row.accent).text,
|
||||
}"
|
||||
>
|
||||
{{ row.stageTitle }}
|
||||
</a-tag>
|
||||
<a-flex justify="space-between" align="center">
|
||||
<a-space :size="6">
|
||||
<CalendarOutlined />
|
||||
<a-typography-text type="secondary">{{ row.deal.date }}</a-typography-text>
|
||||
</a-space>
|
||||
<span v-if="!hideAmount" dir="ltr">
|
||||
<a-typography-text strong>{{ money(row.deal).number }}</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ marginInlineStart: '6px' }">
|
||||
{{ money(row.deal).unit }}
|
||||
</a-typography-text>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectMode && selectedIds.has(row.deal.id)"
|
||||
type="button"
|
||||
class="menu trash"
|
||||
aria-label="حذف انتخابشدهها"
|
||||
@click.stop="emit('bulkDelete')"
|
||||
>
|
||||
<UiIcon name="trash" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu"
|
||||
:aria-label="copy.itemMenuAria"
|
||||
@click.stop="emit('menuDeal', row.deal.id)"
|
||||
>
|
||||
<UiIcon name="dots-v" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="stage"
|
||||
:style="{
|
||||
background: PIPELINE_ACCENT[row.accent].soft,
|
||||
color: PIPELINE_ACCENT[row.accent].text,
|
||||
}"
|
||||
>
|
||||
<i :style="{ background: PIPELINE_ACCENT[row.accent].solid }" />
|
||||
{{ row.stageTitle }}
|
||||
</span>
|
||||
<div class="card-foot">
|
||||
<span class="date">
|
||||
<UiIcon name="calendar" :size="14" />
|
||||
{{ row.deal.date }}
|
||||
</span>
|
||||
<span v-if="!hideAmount" class="amount" dir="ltr">
|
||||
<strong>{{ money(row.deal).number }}</strong>
|
||||
<small>{{ money(row.deal).unit }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.data-table tbody tr.selected td {
|
||||
background: color-mix(in srgb, var(--accent) 7%, #fff);
|
||||
}
|
||||
|
||||
.tick-col {
|
||||
width: 2.6rem;
|
||||
padding-inline: 0.55rem !important;
|
||||
}
|
||||
|
||||
.tick {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #cbd5e1;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tick.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: 0 0 auto;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titles strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.titles em {
|
||||
font-style: normal;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage i {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
.amount strong {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.amount small {
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-col,
|
||||
.menu-cell {
|
||||
width: 3.2rem;
|
||||
min-width: 3.2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.menu {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border-radius: var(--radius);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu:hover,
|
||||
.menu:focus-visible {
|
||||
color: var(--ink);
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.menu.trash {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.menu.trash:hover,
|
||||
.menu.trash:focus-visible {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, #fff);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.95rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.card.selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 6%, #fff);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-top .identity {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import UiFilterBar from '@/components/ui/UiFilterBar.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiSegmented from '@/components/ui/UiSegmented.vue';
|
||||
import UiSelect from '@/components/ui/UiSelect.vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
FundOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
InputNumber as AInputNumber,
|
||||
InputSearch as AInputSearch,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
export type PipelineViewMode = 'kanban' | 'list';
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
pipelineName: string;
|
||||
pipelineOptions: Array<{ id: string; name: string }>;
|
||||
@@ -27,6 +38,9 @@ const emit = defineEmits<{
|
||||
create: [];
|
||||
}>();
|
||||
|
||||
const { Title: ATypographyTitle } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const viewMode = defineModel<PipelineViewMode>('viewMode', { required: true });
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
const selectedPipelineId = defineModel<string>('selectedPipelineId', { required: true });
|
||||
@@ -34,165 +48,112 @@ const amountMin = defineModel<string>('amountMin', { default: '' });
|
||||
const amountMax = defineModel<string>('amountMax', { default: '' });
|
||||
|
||||
const viewOptions = [
|
||||
{ value: 'kanban' as const, label: 'نمای ستونی', icon: 'grid' as const },
|
||||
{ value: 'list' as const, label: 'نمای لیست', icon: 'list' as const },
|
||||
{ value: 'kanban' as const, label: 'ستونی' },
|
||||
{ value: 'list' as const, label: 'لیست' },
|
||||
];
|
||||
|
||||
function onPick(value: string) {
|
||||
if (value === '__create__') {
|
||||
const pipelineSelectOptions = computed(() => {
|
||||
const list = props.pipelineOptions.map((o) => ({ value: o.id, label: o.name }));
|
||||
if (props.canCreate) {
|
||||
list.push({ value: '__create__', label: props.createOptionLabel });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function onPick(value: unknown) {
|
||||
const v = String(value ?? '');
|
||||
if (v === '__create__') {
|
||||
emit('create');
|
||||
return;
|
||||
}
|
||||
selectedPipelineId.value = value;
|
||||
selectedPipelineId.value = v;
|
||||
}
|
||||
|
||||
const amountMinNum = computed({
|
||||
get: () => (amountMin.value === '' ? undefined : Number(amountMin.value)),
|
||||
set: (v: number | string | null | undefined) => {
|
||||
if (v == null || v === '' || Number.isNaN(Number(v))) amountMin.value = '';
|
||||
else amountMin.value = String(v);
|
||||
},
|
||||
});
|
||||
|
||||
const amountMaxNum = computed({
|
||||
get: () => (amountMax.value === '' ? undefined : Number(amountMax.value)),
|
||||
set: (v: number | string | null | undefined) => {
|
||||
if (v == null || v === '' || Number.isNaN(Number(v))) amountMax.value = '';
|
||||
else amountMax.value = String(v);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="toolbar" dir="rtl">
|
||||
<div class="title-block">
|
||||
<span class="brand-ico" aria-hidden="true">
|
||||
<UiIcon :name="icon" :size="18" />
|
||||
</span>
|
||||
<h1>{{ pipelineName }}</h1>
|
||||
</div>
|
||||
<a-flex vertical :gap="12" dir="rtl">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<a-space :size="10" align="center">
|
||||
<span
|
||||
:style="{
|
||||
width: `${token.controlHeight}px`,
|
||||
height: `${token.controlHeight}px`,
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: token.colorPrimary,
|
||||
color: token.colorTextLightSolid,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppstoreOutlined v-if="icon === 'grid'" />
|
||||
<FundOutlined v-else />
|
||||
</span>
|
||||
<a-typography-title :level="4" :style="{ margin: 0 }">
|
||||
{{ pipelineName }}
|
||||
</a-typography-title>
|
||||
</a-space>
|
||||
|
||||
<UiSegmented v-model="viewMode" :options="viewOptions">
|
||||
<template #icon="{ option }">
|
||||
<UiIcon :name="option.icon ?? 'grid'" :size="16" />
|
||||
</template>
|
||||
</UiSegmented>
|
||||
<a-space wrap :size="8">
|
||||
<a-segmented v-model:value="viewMode" :options="viewOptions" />
|
||||
<a-select
|
||||
:value="selectedPipelineId"
|
||||
:options="pipelineSelectOptions"
|
||||
:style="{ width: '180px' }"
|
||||
@update:value="onPick"
|
||||
/>
|
||||
<slot name="excel" />
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<UiSelect
|
||||
class="pipeline-pick"
|
||||
:model-value="selectedPipelineId"
|
||||
@update:model-value="onPick"
|
||||
>
|
||||
<option
|
||||
v-for="opt in pipelineOptions"
|
||||
:key="opt.id"
|
||||
:value="opt.id"
|
||||
>
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
<option v-if="canCreate" value="__create__">{{ createOptionLabel }}</option>
|
||||
</UiSelect>
|
||||
|
||||
<div class="excel-actions">
|
||||
<slot name="excel" />
|
||||
</div>
|
||||
|
||||
<UiFilterBar
|
||||
v-model:search="search"
|
||||
class="filters"
|
||||
:show-filters="showAmountFilters"
|
||||
<!-- یک نوار فیلتر تکردیفه؛ بدون a-row و بدون فاصلهٔ ول -->
|
||||
<a-flex
|
||||
wrap="wrap"
|
||||
align="center"
|
||||
:gap="8"
|
||||
:style="{ width: '100%', minWidth: 0 }"
|
||||
>
|
||||
<a-input-search
|
||||
v-model:value="search"
|
||||
allow-clear
|
||||
placeholder="جستجو در معاملات…"
|
||||
:style="{ flex: '1 1 220px', minWidth: '180px', maxWidth: '100%' }"
|
||||
/>
|
||||
<template v-if="showAmountFilters">
|
||||
<label class="field">
|
||||
<span>حداقل مبلغ</span>
|
||||
<input v-model="amountMin" type="number" min="0" placeholder="۰" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>حداکثر مبلغ</span>
|
||||
<input v-model="amountMax" type="number" min="0" placeholder="—" />
|
||||
</label>
|
||||
<a-input-number
|
||||
v-model:value="amountMinNum"
|
||||
:min="0"
|
||||
placeholder="حداقل مبلغ"
|
||||
:style="{ width: '140px', flex: '0 0 auto' }"
|
||||
/>
|
||||
<a-input-number
|
||||
v-model:value="amountMaxNum"
|
||||
:min="0"
|
||||
placeholder="حداکثر مبلغ"
|
||||
:style="{ width: '140px', flex: '0 0 auto' }"
|
||||
/>
|
||||
</template>
|
||||
</UiFilterBar>
|
||||
</header>
|
||||
<div :style="{ flex: '1 1 240px', minWidth: '200px', maxWidth: '100%' }">
|
||||
<slot name="filters" />
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.85rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-ico {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-deep);
|
||||
color: #fff;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipeline-pick {
|
||||
flex: 0 1 13rem;
|
||||
min-width: 8.5rem;
|
||||
}
|
||||
|
||||
.excel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.excel-actions :deep(.btn-ghost-sm) {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
border-radius: 10px;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@container pipeline (max-width: 720px) {
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pipeline-pick,
|
||||
.excel-actions,
|
||||
.filters {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import type { CompanyDto, LeadDto, PaginatedResponse, PersonDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiInput, UiSelect } from '@/components/ui';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Input as AInput,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
export type PickedEntity = { type: 'lead' | 'person' | 'company'; id: string; label: string };
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -33,6 +43,10 @@ const typeOptions = computed(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
const selectOptions = computed(() =>
|
||||
typeOptions.value.map((t) => ({ value: t, label: ENTITY_TYPE_LABELS[t] })),
|
||||
);
|
||||
|
||||
const type = ref<PickedEntity['type']>(typeOptions.value[0] ?? 'person');
|
||||
const q = ref('');
|
||||
const results = ref<PickedEntity[]>([]);
|
||||
@@ -99,126 +113,55 @@ function pick(item: PickedEntity) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ent-picker">
|
||||
<div v-if="model" class="picked">
|
||||
<span class="picked-type">{{ ENTITY_TYPE_LABELS[model.type] }}</span>
|
||||
<strong>{{ model.label }}</strong>
|
||||
<button type="button" aria-label="حذف اتصال" @click="model = null">
|
||||
<UiIcon name="x" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<a-flex vertical :gap="8" style="width: 100%">
|
||||
<a-flex v-if="model" justify="space-between" align="center" :gap="8">
|
||||
<a-space :size="8">
|
||||
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[model.type] }}</a-tag>
|
||||
<a-typography-text strong>{{ model.label }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-button type="text" aria-label="حذف اتصال" @click="model = null">
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<template v-else-if="typeOptions.length">
|
||||
<div class="search-row">
|
||||
<UiSelect v-if="typeOptions.length > 1" v-model="type" class="type-sel">
|
||||
<option v-for="t in typeOptions" :key="t" :value="t">{{ ENTITY_TYPE_LABELS[t] }}</option>
|
||||
</UiSelect>
|
||||
<UiInput v-model="q" :placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`" />
|
||||
</div>
|
||||
<ul v-if="results.length || loading || q.trim().length >= 2" class="results">
|
||||
<li v-if="loading" class="muted">در حال جستجو…</li>
|
||||
<li v-else-if="!results.length" class="muted">موردی یافت نشد</li>
|
||||
<li v-for="r in results" :key="r.id">
|
||||
<button type="button" @click="pick(r)">{{ r.label }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<a-flex :gap="8" wrap="wrap">
|
||||
<a-select
|
||||
v-if="typeOptions.length > 1"
|
||||
v-model:value="type"
|
||||
:options="selectOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
:placeholder="`جستجوی ${ENTITY_TYPE_LABELS[type]}…`"
|
||||
style="flex: 1; min-width: 160px"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-flex
|
||||
v-if="results.length || loading || q.trim().length >= 2"
|
||||
vertical
|
||||
:gap="4"
|
||||
>
|
||||
<a-typography-text v-if="loading" type="secondary">در حال جستجو…</a-typography-text>
|
||||
<a-typography-text v-else-if="!results.length" type="secondary">
|
||||
موردی یافت نشد
|
||||
</a-typography-text>
|
||||
<a-button
|
||||
v-for="r in results"
|
||||
:key="r.id"
|
||||
type="text"
|
||||
block
|
||||
style="text-align: start"
|
||||
@click="pick(r)"
|
||||
>
|
||||
{{ r.label }}
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
<p v-else class="muted">ماژول لید/مشتری برای سازمان فعال نیست.</p>
|
||||
</div>
|
||||
|
||||
<a-typography-text v-else type="secondary">
|
||||
ماژول لید/مشتری برای سازمان فعال نیست.
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ent-picker {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.type-sel {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-row > :last-child {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.results li button {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.results li button:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.picked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
color: var(--accent-deep);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picked strong {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picked-type {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.picked button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.picked button:hover {
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,9 +8,22 @@ import type {
|
||||
RoutineTaskDto,
|
||||
RoutineTaskSource,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
|
||||
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
@@ -22,6 +35,8 @@ import {
|
||||
} from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
@@ -54,7 +69,23 @@ const form = reactive({
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
|
||||
const priorityOptions = computed(() =>
|
||||
(Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
),
|
||||
);
|
||||
|
||||
const categoryOptions = computed(() => [
|
||||
{ value: '', label: 'بدون دسته' },
|
||||
...(ctx.value?.categories ?? []).map((c) => ({ value: c.id, label: c.name })),
|
||||
]);
|
||||
|
||||
const assigneeOptions = computed(() =>
|
||||
assignees.value.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.fullName}${u.id === auth.user?.id ? ' (خودم)' : ''}`,
|
||||
})),
|
||||
);
|
||||
|
||||
async function ensureContext() {
|
||||
if (ctx.value) return;
|
||||
@@ -105,7 +136,7 @@ async function submit() {
|
||||
error.value = '';
|
||||
if (form.title.trim().length < 2) {
|
||||
error.value = 'عنوان کار را وارد کنید.';
|
||||
return;
|
||||
return Promise.reject();
|
||||
}
|
||||
const link = props.preset ?? form.entity;
|
||||
const body: CreateRoutineTaskRequest = {
|
||||
@@ -128,6 +159,7 @@ async function submit() {
|
||||
emit('close');
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت کار ممکن نشد.');
|
||||
return Promise.reject();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -135,119 +167,90 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="open" title="کار جدید" @close="emit('close')">
|
||||
<form class="qt-form" @submit.prevent="submit">
|
||||
<UiField label="عنوان" required>
|
||||
<UiInput v-model="form.title" placeholder="مثلاً: تماس با مشتری برای پیگیری پیشفاکتور" :maxlength="200" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="توضیحات">
|
||||
<UiTextarea v-model="form.description" :rows="2" :maxlength="2000" />
|
||||
</UiField>
|
||||
|
||||
<div class="grid-2">
|
||||
<UiField label="تاریخ" required>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="کار جدید"
|
||||
:confirm-loading="saving"
|
||||
ok-text="ثبت کار"
|
||||
cancel-text="انصراف"
|
||||
destroy-on-close
|
||||
@ok="submit"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="عنوان" required>
|
||||
<a-input
|
||||
v-model:value="form.title"
|
||||
placeholder="مثلاً: تماس با مشتری برای پیگیری پیشفاکتور"
|
||||
:maxlength="200"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="2" :maxlength="2000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ" required>
|
||||
<JalaliDateInput v-model="form.date" mode="date" :clearable="false" />
|
||||
</UiField>
|
||||
<UiField label="ساعت" hint="خالی = تا پایان روز">
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
|
||||
<JalaliDateInput v-model="form.time" mode="time" />
|
||||
</UiField>
|
||||
<UiField label="اولویت">
|
||||
<UiSelect v-model="form.priority">
|
||||
<option v-for="[k, label] in priorities" :key="k" :value="k">{{ label }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField label="دستهبندی">
|
||||
<UiSelect v-model="form.categoryId">
|
||||
<option value="">بدون دسته</option>
|
||||
<option v-for="c in ctx?.categories ?? []" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiField v-if="assignees.length > 1" label="مسئول" class="span-2">
|
||||
<UiSelect v-model="form.assigneeId">
|
||||
<option v-for="u in assignees" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}{{ u.id === auth.user?.id ? ' (خودم)' : '' }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="اولویت">
|
||||
<a-select
|
||||
v-model:value="form.priority"
|
||||
:options="priorityOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="دستهبندی">
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
:options="categoryOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="assignees.length > 1" :span="24">
|
||||
<a-form-item label="مسئول">
|
||||
<a-select
|
||||
v-model:value="form.assigneeId"
|
||||
:options="assigneeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="اتصال به پرونده">
|
||||
<a-space v-if="preset" :size="8">
|
||||
<a-tag :bordered="false">{{ ENTITY_TYPE_LABELS[preset.type] }}</a-tag>
|
||||
<a-typography-text strong>{{ preset.label }}</a-typography-text>
|
||||
</a-space>
|
||||
<EntityLinkPicker v-else v-model="form.entity" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-checkbox v-model:checked="form.isMandatory">
|
||||
کار اجباری (در امتیاز روزانه و Streak حساب میشود)
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<UiField label="اتصال به پرونده">
|
||||
<div v-if="preset" class="preset">
|
||||
<span>{{ ENTITY_TYPE_LABELS[preset.type] }}</span>
|
||||
<strong>{{ preset.label }}</strong>
|
||||
</div>
|
||||
<EntityLinkPicker v-else v-model="form.entity" />
|
||||
</UiField>
|
||||
|
||||
<label class="check">
|
||||
<input v-model="form.isMandatory" type="checkbox" />
|
||||
<span>کار اجباری (در امتیاز روزانه و Streak حساب میشود)</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<UiButton :loading="saving" @click="submit">ثبت کار</UiButton>
|
||||
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qt-form {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.preset span {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
import RoutineTaskModals from '@/components/routines/RoutineTaskModals.vue';
|
||||
import { UiButton } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, isOpenStatus } from '@/lib/routines';
|
||||
import { useRoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const { Text: ATypographyText, Title: ATypographyTitle } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entity: RoutineEntityRefDto;
|
||||
@@ -85,223 +97,90 @@ function onCreated(t: RoutineTaskDto) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="enabled" class="related">
|
||||
<header>
|
||||
<h3>
|
||||
<span class="mark"><UiIcon name="check" :size="15" /></span>
|
||||
{{ title }}
|
||||
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
|
||||
</h3>
|
||||
<UiButton size="sm" variant="outline" @click="quickOpen = true">
|
||||
<template #icon><UiIcon name="plus" :size="14" /></template>
|
||||
<a-card v-if="enabled" :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<a-space :size="8">
|
||||
<CheckOutlined />
|
||||
<a-typography-title :level="5" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-tag v-if="open.length" :bordered="false">{{ toFaDigits(open.length) }} باز</a-tag>
|
||||
</a-space>
|
||||
<a-button @click="quickOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ایجاد Task
|
||||
</UiButton>
|
||||
</header>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
|
||||
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
|
||||
<div v-else-if="!items.length" class="empty">
|
||||
<div class="empty-art" aria-hidden="true">
|
||||
<svg viewBox="0 0 72 72" width="72" height="72">
|
||||
<rect x="18" y="16" width="36" height="44" rx="8" fill="#f4fbf8" stroke="#b7e4d4" stroke-width="1.6" />
|
||||
<rect x="26" y="10" width="20" height="12" rx="4" fill="#e7f6f1" stroke="#7dcebb" stroke-width="1.6" />
|
||||
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="muted">{{ emptyTitle }}</p>
|
||||
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p>
|
||||
<button class="empty-cta" type="button" @click="quickOpen = true">
|
||||
ایجاد Task
|
||||
<UiIcon name="plus" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
<a-alert
|
||||
v-if="actions.error.value"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="actions.error.value"
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<div v-else class="rows">
|
||||
<RoutineTaskRow
|
||||
v-for="t in open"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
show-assignee
|
||||
show-delay
|
||||
@toggle="actions.toggle"
|
||||
@open="openTask"
|
||||
@action="onAction"
|
||||
/>
|
||||
<button v-if="closed.length" type="button" class="toggle-closed" @click="showClosed = !showClosed">
|
||||
{{ showClosed ? 'پنهان کردن' : 'نمایش' }} {{ toFaDigits(closed.length) }} کار انجامشده / بسته
|
||||
</button>
|
||||
<template v-if="showClosed">
|
||||
<a-spin :spinning="loading && !items.length">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
:description="emptyTitle"
|
||||
:style="{ margin: '16px 0' }"
|
||||
>
|
||||
<template v-if="emptyHint" #description>
|
||||
<div>
|
||||
<div>{{ emptyTitle }}</div>
|
||||
<a-typography-text type="secondary">{{ emptyHint }}</a-typography-text>
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="primary" @click="quickOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
ایجاد Task
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<a-flex v-else vertical :gap="8" :style="{ marginTop: '12px' }">
|
||||
<RoutineTaskRow
|
||||
v-for="t in closed"
|
||||
v-for="t in open"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
show-assignee
|
||||
show-delay
|
||||
@toggle="actions.toggle"
|
||||
@open="openTask"
|
||||
@action="onAction"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<a-button
|
||||
v-if="closed.length"
|
||||
type="link"
|
||||
@click="showClosed = !showClosed"
|
||||
>
|
||||
{{ showClosed ? 'پنهان کردن' : 'نمایش' }}
|
||||
{{ toFaDigits(closed.length) }} کار انجامشده / بسته
|
||||
</a-button>
|
||||
<template v-if="showClosed">
|
||||
<RoutineTaskRow
|
||||
v-for="t in closed"
|
||||
:key="t.id"
|
||||
:task="t"
|
||||
:busy="actions.busyId.value === t.id"
|
||||
show-date
|
||||
show-assignee
|
||||
@toggle="actions.toggle"
|
||||
@open="openTask"
|
||||
@action="onAction"
|
||||
/>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
|
||||
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
</section>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.related {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
h3 em {
|
||||
font-style: normal;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-medium);
|
||||
padding: 1px 8px;
|
||||
border-radius: 99px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.toggle-closed {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-deep);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.empty-art,
|
||||
.hint,
|
||||
.empty-cta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fancy {
|
||||
gap: 0.85rem;
|
||||
padding: 0.95rem 0.85rem 1rem;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.fancy .mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #e7f6f1;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.fancy header :deep(.ui-btn),
|
||||
.fancy header :deep(button) {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #e7f6f1;
|
||||
color: #0f766e;
|
||||
box-shadow: none;
|
||||
height: auto;
|
||||
min-height: 34px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-direction: row-reverse;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.fancy .empty {
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.5rem 0.2rem;
|
||||
}
|
||||
|
||||
.fancy .empty-art {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
margin-bottom: 0.2rem;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #f7fcfa, #eef8f4);
|
||||
}
|
||||
|
||||
.fancy .hint {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.fancy .empty-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #e7f6f1;
|
||||
color: #0f766e;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Col as ACol,
|
||||
FormItem as AFormItem,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { UiButton, UiField, UiModal, UiTextarea } from '@/components/ui';
|
||||
import type { RoutineTaskActions } from '@/lib/useRoutineTaskActions';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const props = defineProps<{ actions: RoutineTaskActions }>();
|
||||
|
||||
const modal = computed(() => props.actions.modal);
|
||||
@@ -57,81 +67,63 @@ const submitLabel = computed(() => {
|
||||
return 'ذخیره';
|
||||
}
|
||||
});
|
||||
|
||||
const okDanger = computed(() => modal.value.kind === 'cancel');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiModal :open="!!modal.kind" :title="title" nest @close="actions.close()">
|
||||
<form class="rt-modal" @submit.prevent="actions.submit()">
|
||||
<p v-if="modal.task" class="task-name">{{ modal.task.title }}</p>
|
||||
<a-modal
|
||||
:open="!!modal.kind"
|
||||
:title="title"
|
||||
:confirm-loading="modal.saving"
|
||||
:ok-text="submitLabel"
|
||||
:ok-button-props="{ danger: okDanger }"
|
||||
cancel-text="انصراف"
|
||||
destroy-on-close
|
||||
@ok="actions.submit()"
|
||||
@cancel="actions.close()"
|
||||
>
|
||||
<a-typography-text v-if="modal.task" strong>
|
||||
{{ modal.task.title }}
|
||||
</a-typography-text>
|
||||
|
||||
<template v-if="modal.kind === 'reschedule'">
|
||||
<div class="when">
|
||||
<UiField label="تاریخ" required>
|
||||
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
|
||||
</UiField>
|
||||
<UiField label="ساعت" hint="خالی = تا پایان روز">
|
||||
<JalaliDateInput v-model="modal.time" mode="time" />
|
||||
</UiField>
|
||||
</div>
|
||||
</template>
|
||||
<a-row v-if="modal.kind === 'reschedule'" :gutter="[16, 0]" :style="{ marginTop: '12px' }">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ" required>
|
||||
<JalaliDateInput v-model="modal.date" mode="date" required :clearable="false" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ساعت" extra="خالی = تا پایان روز">
|
||||
<JalaliDateInput v-model="modal.time" mode="time" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<UiField v-else :label="noteLabel" :required="noteRequired">
|
||||
<UiTextarea v-model="modal.note" :rows="4" :placeholder="placeholder" :maxlength="2000" />
|
||||
</UiField>
|
||||
<a-form-item
|
||||
v-else
|
||||
:label="noteLabel"
|
||||
:required="noteRequired"
|
||||
:style="{ marginTop: '12px' }"
|
||||
>
|
||||
<a-textarea
|
||||
v-model:value="modal.note"
|
||||
:rows="4"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="2000"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<p v-if="modal.kind === 'cancel'" class="hint">
|
||||
کار لغو شده در محاسبه درصد انجام حساب نمیشود.
|
||||
</p>
|
||||
<p v-if="modal.error" class="error">{{ modal.error }}</p>
|
||||
</form>
|
||||
<a-typography-text v-if="modal.kind === 'cancel'" type="secondary">
|
||||
کار لغو شده در محاسبه درصد انجام حساب نمیشود.
|
||||
</a-typography-text>
|
||||
|
||||
<template #footer>
|
||||
<UiButton
|
||||
:variant="modal.kind === 'cancel' ? 'danger' : modal.kind === 'complete' ? 'success' : 'primary'"
|
||||
:loading="modal.saving"
|
||||
@click="actions.submit()"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" @click="actions.close()">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-alert
|
||||
v-if="modal.error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="modal.error"
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rt-modal {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.task-name {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-subtle);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.when {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.when {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RoutineTaskDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
Dropdown as ADropdown,
|
||||
Flex as AFlex,
|
||||
Menu as AMenu,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
FlagOutlined,
|
||||
MoreOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const AMenuItem = AMenu.Item;
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { UiBadge, UiDropdownMenu } from '@/components/ui';
|
||||
import {
|
||||
ENTITY_TYPE_LABELS,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_VARIANTS,
|
||||
ROUTINE_TASK_SOURCE_LABELS,
|
||||
ROUTINE_TASK_STATUS_LABELS,
|
||||
STATUS_VARIANTS,
|
||||
entityRoute,
|
||||
formatDayNumeric,
|
||||
formatDelay,
|
||||
@@ -20,6 +36,9 @@ import {
|
||||
isOpenStatus,
|
||||
} from '@/lib/routines';
|
||||
|
||||
const { Text: ATypographyText, Title: ATypographyTitle, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
export type RoutineTaskRowAction =
|
||||
| 'start'
|
||||
| 'complete-note'
|
||||
@@ -45,396 +64,135 @@ const emit = defineEmits<{
|
||||
action: [kind: RoutineTaskRowAction, task: RoutineTaskDto];
|
||||
}>();
|
||||
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const done = computed(() => props.task.status === 'completed');
|
||||
const closed = computed(() => props.task.status === 'skipped' || props.task.status === 'cancelled');
|
||||
const open = computed(() => isOpenStatus(props.task.status));
|
||||
const showTime = computed(() => hasExplicitTime(props.task));
|
||||
const checkLabel = computed(() =>
|
||||
done.value ? `برگرداندن «${props.task.title}» به حالت انجامنشده` : `علامتزدن «${props.task.title}» بهعنوان انجامشده`,
|
||||
done.value
|
||||
? `برگرداندن «${props.task.title}» به حالت انجامنشده`
|
||||
: `علامتزدن «${props.task.title}» بهعنوان انجامشده`,
|
||||
);
|
||||
|
||||
function act(kind: RoutineTaskRowAction) {
|
||||
emit('action', kind, props.task);
|
||||
}
|
||||
|
||||
function onMenuClick({ key }: { key: string | number }) {
|
||||
const k = String(key);
|
||||
if (k === 'open') {
|
||||
emit('open', props.task);
|
||||
return;
|
||||
}
|
||||
act(k as RoutineTaskRowAction);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="rt-row"
|
||||
:class="[`st-${task.status}`, { done, closed, busy }]"
|
||||
<a-flex
|
||||
align="flex-start"
|
||||
:gap="12"
|
||||
:style="{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
opacity: closed ? 0.62 : 1,
|
||||
}"
|
||||
@click="emit('open', task)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rt-check"
|
||||
:class="{ on: done }"
|
||||
<a-checkbox
|
||||
:checked="done"
|
||||
:disabled="busy || closed"
|
||||
:aria-pressed="done"
|
||||
:aria-label="checkLabel"
|
||||
@click.stop="emit('toggle', task)"
|
||||
>
|
||||
<span class="box">
|
||||
<UiIcon v-if="done" name="check-plain" :size="16" />
|
||||
<span v-else-if="busy" class="spin" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
@click.stop
|
||||
@update:checked="emit('toggle', task)"
|
||||
/>
|
||||
|
||||
<div class="rt-main">
|
||||
<div class="rt-title-line">
|
||||
<h3 class="rt-title">{{ task.title }}</h3>
|
||||
<UiIcon v-if="task.isMandatory" class="mand" name="flag" :size="13" title="اجباری" />
|
||||
</div>
|
||||
<p v-if="task.description" class="rt-desc">{{ task.description }}</p>
|
||||
<a-flex vertical :gap="4" style="flex: 1; min-width: 0">
|
||||
<a-space :size="6">
|
||||
<a-typography-title
|
||||
:level="5"
|
||||
:style="{
|
||||
margin: 0,
|
||||
textDecoration: done ? 'line-through' : undefined,
|
||||
}"
|
||||
>
|
||||
{{ task.title }}
|
||||
</a-typography-title>
|
||||
<FlagOutlined v-if="task.isMandatory" />
|
||||
</a-space>
|
||||
|
||||
<div class="rt-meta">
|
||||
<span v-if="showDate" class="meta-item">
|
||||
<UiIcon name="calendar" :size="13" />
|
||||
{{ formatDayNumeric(task.scheduledFor) }}
|
||||
</span>
|
||||
<span v-if="showTime" class="meta-item">
|
||||
<UiIcon name="clock" :size="13" />
|
||||
{{ formatTime(task.dueAt) }}
|
||||
</span>
|
||||
<span v-if="showDelay && task.status === 'overdue'" class="meta-item late">
|
||||
<UiIcon name="warning" :size="13" />
|
||||
{{ formatDelay(task.dueAt) }} تأخیر
|
||||
</span>
|
||||
<span v-if="done && task.completedAt" class="meta-item ok">
|
||||
<UiIcon name="check" :size="13" />
|
||||
{{ formatTime(task.completedAt) }}
|
||||
</span>
|
||||
<span v-if="task.category" class="cat-chip">
|
||||
<i :style="{ background: task.category.color }" />
|
||||
{{ task.category.name }}
|
||||
</span>
|
||||
<UiBadge size="sm" :variant="PRIORITY_VARIANTS[task.priority]">
|
||||
{{ PRIORITY_LABELS[task.priority] }}
|
||||
</UiBadge>
|
||||
<UiBadge v-if="task.status !== 'todo'" size="sm" :variant="STATUS_VARIANTS[task.status]">
|
||||
<a-typography-paragraph
|
||||
v-if="task.description"
|
||||
type="secondary"
|
||||
:ellipsis="{ rows: 1 }"
|
||||
:style="{ marginBottom: 0 }"
|
||||
>
|
||||
{{ task.description }}
|
||||
</a-typography-paragraph>
|
||||
|
||||
<a-space wrap :size="[8, 4]">
|
||||
<a-typography-text v-if="showDate" type="secondary">
|
||||
<CalendarOutlined /> {{ formatDayNumeric(task.scheduledFor) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="showTime" type="secondary">
|
||||
<ClockCircleOutlined /> {{ formatTime(task.dueAt) }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="showDelay && task.status === 'overdue'"
|
||||
type="danger"
|
||||
>
|
||||
<WarningOutlined /> {{ formatDelay(task.dueAt) }} تأخیر
|
||||
</a-typography-text>
|
||||
<a-typography-text v-if="done && task.completedAt" type="success">
|
||||
<CheckOutlined /> {{ formatTime(task.completedAt) }}
|
||||
</a-typography-text>
|
||||
<a-tag v-if="task.category" :bordered="false">{{ task.category.name }}</a-tag>
|
||||
<a-tag :bordered="false">{{ PRIORITY_LABELS[task.priority] }}</a-tag>
|
||||
<a-tag v-if="task.status !== 'todo'" :bordered="false">
|
||||
{{ ROUTINE_TASK_STATUS_LABELS[task.status] }}
|
||||
</UiBadge>
|
||||
<span class="src-chip" :title="`منبع: ${ROUTINE_TASK_SOURCE_LABELS[task.source]}`">
|
||||
<UiIcon :name="task.source === 'routine' ? 'refresh' : 'pin'" :size="12" />
|
||||
{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}
|
||||
</span>
|
||||
</a-tag>
|
||||
<a-tag :bordered="false">{{ ROUTINE_TASK_SOURCE_LABELS[task.source] }}</a-tag>
|
||||
<RouterLink
|
||||
v-for="ent in task.entities"
|
||||
:key="`${ent.type}-${ent.id}`"
|
||||
class="ent-link"
|
||||
:to="entityRoute(ent)"
|
||||
@click.stop
|
||||
>
|
||||
<span class="ent-type">{{ ENTITY_TYPE_LABELS[ent.type] }}</span>
|
||||
{{ ent.label }}
|
||||
<a-tag color="processing" :bordered="false">
|
||||
{{ ENTITY_TYPE_LABELS[ent.type] }} · {{ ent.label }}
|
||||
</a-tag>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div class="rt-side" @click.stop>
|
||||
<span v-if="showAssignee && task.assignee" class="assignee" :title="task.assignee.fullName">
|
||||
<a-space :size="8" @click.stop>
|
||||
<a-space v-if="showAssignee && task.assignee" :size="6">
|
||||
<UserAvatar :name="task.assignee.fullName" :user-id="task.assignee.id" :size="26" />
|
||||
<span class="assignee-name">{{ task.assignee.fullName }}</span>
|
||||
</span>
|
||||
<UiDropdownMenu v-model:open="menuOpen" align="end">
|
||||
<template #trigger="{ toggle }">
|
||||
<button type="button" class="rt-more" aria-label="عملیات بیشتر" @click="toggle">
|
||||
<UiIcon name="dots" :size="18" />
|
||||
</button>
|
||||
<a-typography-text type="secondary">{{ task.assignee.fullName }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-dropdown>
|
||||
<a-button type="text" aria-label="عملیات بیشتر">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="onMenuClick">
|
||||
<a-menu-item key="open">جزئیات</a-menu-item>
|
||||
<a-menu-item v-if="task.status === 'todo'" key="start">شروع کار</a-menu-item>
|
||||
<a-menu-item v-if="open || done" key="complete-note">
|
||||
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="open" key="reschedule">تغییر زمان</a-menu-item>
|
||||
<a-menu-item v-if="task.status === 'overdue'" key="move-today">
|
||||
انتقال به امروز
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="open && task.rules.allowSkip" key="skip">رد کردن</a-menu-item>
|
||||
<a-menu-item v-if="open" key="cancel" danger>لغو کار</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<button type="button" @click="emit('open', task)">جزئیات</button>
|
||||
<button v-if="task.status === 'todo'" type="button" @click="act('start')">شروع کار</button>
|
||||
<button v-if="open || done" type="button" @click="act('complete-note')">
|
||||
{{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
|
||||
</button>
|
||||
<button v-if="open" type="button" @click="act('reschedule')">تغییر زمان</button>
|
||||
<button v-if="task.status === 'overdue'" type="button" @click="act('move-today')">انتقال به امروز</button>
|
||||
<button v-if="open && task.rules.allowSkip" type="button" @click="act('skip')">رد کردن</button>
|
||||
<button v-if="open" type="button" class="danger" @click="act('cancel')">لغو کار</button>
|
||||
</UiDropdownMenu>
|
||||
</div>
|
||||
</article>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rt-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.rt-row:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.rt-row.st-overdue {
|
||||
border-inline-start: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.rt-row.done {
|
||||
background: color-mix(in srgb, var(--success-soft) 28%, var(--surface));
|
||||
}
|
||||
|
||||
.rt-row.closed {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.rt-check {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: -6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.rt-check:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rt-check:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.rt-check .box {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
border: 2px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), transform 0.12s ease;
|
||||
}
|
||||
|
||||
.rt-check:not(:disabled):hover .box {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.rt-check:active .box {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.rt-check.on .box {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.st-overdue .rt-check .box {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, white);
|
||||
}
|
||||
|
||||
.spin {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: rt-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rt-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rt-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rt-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rt-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.done .rt-title {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: color-mix(in srgb, var(--muted) 60%, transparent);
|
||||
}
|
||||
|
||||
.mand {
|
||||
color: var(--warning-deep);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rt-desc {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rt-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
margin-top: 2px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-item.late {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.meta-item.ok {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.cat-chip,
|
||||
.src-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-chip i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.src-chip {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.ent-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 220px;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ent-link:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.ent-type {
|
||||
color: var(--muted);
|
||||
font-weight: var(--fw-normal);
|
||||
}
|
||||
|
||||
.rt-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.assignee {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--ink-secondary);
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.assignee-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rt-more {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rt-more:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
:global(.ui-dd-panel button.danger) {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rt-row {
|
||||
padding: var(--sp-3);
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.rt-check {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: -8px -6px;
|
||||
}
|
||||
|
||||
.rt-check .box {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rt-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.assignee-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1089,6 +1089,7 @@ onUnmounted(() => {
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
@@ -1909,10 +1910,10 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
/* همان فاصله بالای سایدبار شناور (14px) */
|
||||
padding: 14px 1.5rem 0.85rem;
|
||||
/* همتراز با پدینگ ۱۶px محتوای workspace */
|
||||
padding: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
@@ -2118,18 +2119,29 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ریشهٔ RouterView ارتفاع workspace را پر کند */
|
||||
.content > *:first-child {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) and (min-width: 901px) {
|
||||
.content {
|
||||
padding-inline: 1rem;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.desktop-topbar {
|
||||
padding-inline: 1rem;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2258,7 +2270,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
|
||||
padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
|
||||
}
|
||||
|
||||
.notif-layer {
|
||||
|
||||
@@ -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;
|
||||
+530
-1319
File diff suppressed because it is too large
Load Diff
+725
-1784
File diff suppressed because it is too large
Load Diff
+476
-583
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
HolderOutlined,
|
||||
PhoneOutlined,
|
||||
ReloadOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
|
||||
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
const router = useRouter();
|
||||
|
||||
const GAP = 16;
|
||||
const COL_WIDTH = 280;
|
||||
|
||||
const loading = ref(false);
|
||||
const moving = ref(false);
|
||||
const error = ref('');
|
||||
const columns = ref<LeadBoardColumnDto[]>([]);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const dropStageId = ref<string | null>(null);
|
||||
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
||||
let suppressCardClick = false;
|
||||
|
||||
/**
|
||||
* ارتفاع کامل زیر topbar شل.
|
||||
* height:0 + flex:1 روی فرزند، زنجیرهٔ کشش را اجباری میکند.
|
||||
*/
|
||||
const pageStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: `${GAP}px`,
|
||||
height: 'calc(100dvh - 120px)',
|
||||
maxHeight: 'calc(100dvh - 120px)',
|
||||
minHeight: '420px',
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
const boardShellStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
padding: `${GAP}px`,
|
||||
background: token.value.colorBgContainer,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const boardTrackStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'stretch',
|
||||
gap: `${GAP}px`,
|
||||
width: '100%',
|
||||
overflowX: 'auto' as const,
|
||||
overflowY: 'hidden' as const,
|
||||
paddingBottom: '4px',
|
||||
}));
|
||||
|
||||
function columnStyle(stageId: string) {
|
||||
const active = dropStageId.value === stageId;
|
||||
return {
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: `0 0 ${COL_WIDTH}px`,
|
||||
width: `${COL_WIDTH}px`,
|
||||
alignSelf: 'stretch',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
minHeight: 0,
|
||||
background: token.value.colorFillAlter,
|
||||
border: `1px solid ${
|
||||
active ? token.value.colorPrimary : token.value.colorBorderSecondary
|
||||
}`,
|
||||
borderRadius: `${token.value.borderRadiusLG}px`,
|
||||
boxShadow: active ? `0 0 0 2px ${token.value.colorPrimaryBg}` : undefined,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
}
|
||||
|
||||
const columnHeadStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: `${GAP}px`,
|
||||
padding: `${GAP}px`,
|
||||
borderBottom: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
background: token.value.colorBgContainer,
|
||||
}));
|
||||
|
||||
const columnBodyStyle = computed(() => ({
|
||||
boxSizing: 'border-box' as const,
|
||||
flex: '1 1 auto',
|
||||
height: 0,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
const cardBodyStyle = computed(() => ({
|
||||
padding: '12px 14px',
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function leadInitials(lead: LeadDto) {
|
||||
const raw = (lead.contactName || lead.title || '?').trim();
|
||||
const parts = raw.split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '؟';
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 1);
|
||||
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
|
||||
}
|
||||
|
||||
function leadSubtitle(lead: LeadDto) {
|
||||
return [lead.contactName, lead.city].filter(Boolean).join(' · ') || '';
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '';
|
||||
@@ -39,7 +174,13 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function openLead(lead: LeadDto) {
|
||||
if (suppressCardClick || draggingId.value || moving.value) return;
|
||||
void router.push({ name: 'lead-detail', params: { id: lead.id } });
|
||||
}
|
||||
|
||||
function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
suppressCardClick = true;
|
||||
draggingId.value = lead.id;
|
||||
event.dataTransfer?.setData('text/plain', lead.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
@@ -48,6 +189,9 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
window.setTimeout(() => {
|
||||
suppressCardClick = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
@@ -106,183 +250,278 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="مسیر لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
|
||||
<template #actions>
|
||||
<RouterLink :to="{ name: 'leads' }" class="link-btn">
|
||||
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
|
||||
<div class="lead-board-page" :style="pageStyle">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="GAP">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
مسیر لیدها
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
کشیدن و رها کردن برای تغییر وضعیت
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space :size="GAP">
|
||||
<RouterLink :to="{ name: 'leads' }" custom v-slot="{ navigate }">
|
||||
<a-button @click="navigate">
|
||||
<template #icon><UnorderedListOutlined /></template>
|
||||
فهرست لیدها
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
|
||||
<a-button
|
||||
:disabled="loading || moving"
|
||||
:loading="loading"
|
||||
@click="load"
|
||||
>
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<div v-else-if="columns.length" class="board">
|
||||
<section
|
||||
v-for="col in columns"
|
||||
:key="col.stage.id"
|
||||
class="column"
|
||||
:class="{ drop: dropStageId === col.stage.id }"
|
||||
@dragover="onDragOver(col.stage.id, $event)"
|
||||
@drop="onDrop(col.stage.id, $event)"
|
||||
<div :style="boardShellStyle">
|
||||
<div
|
||||
v-if="loading || moving"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: token.colorBgMask,
|
||||
}"
|
||||
>
|
||||
<header class="col-head">
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: col.stage.color || 'var(--accent)' }"
|
||||
/>
|
||||
<h2>{{ col.stage.name }}</h2>
|
||||
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
|
||||
</header>
|
||||
<a-spin />
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<article
|
||||
v-for="lead in col.leads"
|
||||
:key="lead.id"
|
||||
class="card"
|
||||
:class="{ dragging: draggingId === lead.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(lead, $event)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
|
||||
{{ lead.title }}
|
||||
</RouterLink>
|
||||
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
|
||||
<p v-if="lead.city" class="line">{{ lead.city }}</p>
|
||||
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
|
||||
<p v-if="lead.nextFollowUpAt" class="line muted">
|
||||
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
|
||||
</p>
|
||||
</article>
|
||||
<p v-if="!col.leads.length" class="empty-col">خالی</p>
|
||||
<a-empty
|
||||
v-if="!loading && !columns.length"
|
||||
description="مرحلهای برای نمایش در مسیر لیدها نیست. در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
|
||||
:style="{ margin: 'auto' }"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="columns.length"
|
||||
class="lead-board-track"
|
||||
:style="boardTrackStyle"
|
||||
>
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.stage.id"
|
||||
:style="columnStyle(col.stage.id)"
|
||||
@dragover="onDragOver(col.stage.id, $event)"
|
||||
@drop="onDrop(col.stage.id, $event)"
|
||||
>
|
||||
<div :style="columnHeadStyle">
|
||||
<a-space :size="8">
|
||||
<span
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: col.stage.color || token.colorPrimary,
|
||||
display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}"
|
||||
/>
|
||||
<a-typography-text strong>{{ col.stage.name }}</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(col.leads.length) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<div :style="columnBodyStyle">
|
||||
<a-card
|
||||
v-for="lead in col.leads"
|
||||
:key="lead.id"
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="cardBodyStyle"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
opacity: draggingId === lead.id ? 0.45 : 1,
|
||||
flexShrink: 0,
|
||||
margin: 0,
|
||||
background: token.colorBgContainer,
|
||||
boxShadow: token.boxShadow,
|
||||
}"
|
||||
@click="openLead(lead)"
|
||||
>
|
||||
<a-flex vertical :gap="10" :style="{ width: '100%', minWidth: 0 }">
|
||||
<a-flex :gap="10" align="center" :style="{ minWidth: 0 }">
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="جابهجایی لید"
|
||||
title="برای جابهجایی بکشید"
|
||||
draggable="true"
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
flexShrink: 0,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
color: token.colorTextTertiary,
|
||||
cursor: 'grab',
|
||||
background: token.colorFillQuaternary,
|
||||
}"
|
||||
@click.stop
|
||||
@dragstart.stop="onDragStart(lead, $event)"
|
||||
@dragend.stop="onDragEnd"
|
||||
>
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
<a-avatar
|
||||
:style="{
|
||||
background: token.colorPrimaryBg,
|
||||
color: token.colorPrimary,
|
||||
flexShrink: 0,
|
||||
fontWeight: 700,
|
||||
}"
|
||||
>
|
||||
{{ leadInitials(lead) }}
|
||||
</a-avatar>
|
||||
<div :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text
|
||||
strong
|
||||
ellipsis
|
||||
:style="{ display: 'block', margin: 0, lineHeight: 1.35 }"
|
||||
:title="lead.title"
|
||||
>
|
||||
{{ lead.title }}
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
v-if="leadSubtitle(lead)"
|
||||
type="secondary"
|
||||
ellipsis
|
||||
:style="{
|
||||
display: 'block',
|
||||
margin: 0,
|
||||
marginTop: '2px',
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
lineHeight: 1.35,
|
||||
}"
|
||||
:title="leadSubtitle(lead)"
|
||||
>
|
||||
{{ leadSubtitle(lead) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<a-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
:gap="8"
|
||||
:style="{ minWidth: 0 }"
|
||||
>
|
||||
<a-flex
|
||||
v-if="lead.mobile"
|
||||
align="center"
|
||||
:gap="6"
|
||||
dir="ltr"
|
||||
:style="{ minWidth: 0 }"
|
||||
>
|
||||
<PhoneOutlined
|
||||
:style="{
|
||||
color: token.colorPrimary,
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
}"
|
||||
/>
|
||||
<a-typography-text
|
||||
:style="{
|
||||
margin: 0,
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: token.colorText,
|
||||
}"
|
||||
>
|
||||
{{ lead.mobile }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-tag
|
||||
v-if="lead.assigneeName"
|
||||
:bordered="false"
|
||||
:style="{
|
||||
margin: 0,
|
||||
maxWidth: '46%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
background: token.colorFillSecondary,
|
||||
}"
|
||||
:title="lead.assigneeName"
|
||||
>
|
||||
{{ lead.assigneeName }}
|
||||
</a-tag>
|
||||
</a-flex>
|
||||
|
||||
<a-flex
|
||||
v-if="lead.nextFollowUpAt"
|
||||
align="center"
|
||||
:gap="6"
|
||||
:style="{
|
||||
paddingTop: '8px',
|
||||
borderTop: `1px solid ${token.colorSplit}`,
|
||||
}"
|
||||
>
|
||||
<CalendarOutlined
|
||||
:style="{
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
}"
|
||||
/>
|
||||
<a-typography-text
|
||||
type="secondary"
|
||||
:style="{
|
||||
margin: 0,
|
||||
fontSize: `${token.fontSizeSM}px`,
|
||||
}"
|
||||
>
|
||||
پیگیری {{ formatDate(lead.nextFollowUpAt) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-typography-text
|
||||
v-if="!col.leads.length"
|
||||
type="secondary"
|
||||
:style="{
|
||||
textAlign: 'center',
|
||||
display: 'block',
|
||||
margin: 'auto',
|
||||
padding: `${GAP}px`,
|
||||
}"
|
||||
>
|
||||
خالی
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="مرحلهای برای نمایش در مسیر لیدها نیست."
|
||||
description="در تنظیمات مراحل لید، «نمایش روی مسیر» را فعال کنید."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
/* فقط ارتفاع/اسکرول ساختاری — بدون رنگ/ریاسکین Ant */
|
||||
.lead-board-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
text-decoration: none;
|
||||
.lead-board-track {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
min-height: 24rem;
|
||||
.lead-board-track::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 16.5rem;
|
||||
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
max-height: calc(100vh - 12rem);
|
||||
}
|
||||
|
||||
.column.drop {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.col-head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-head em {
|
||||
font-style: normal;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cards {
|
||||
padding: 0.55rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
padding: 0.65rem 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.empty-col {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-col {
|
||||
margin: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
.lead-board-track::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 28%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
+674
-1009
File diff suppressed because it is too large
Load Diff
+827
-2145
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,37 @@
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
Upload as AUpload,
|
||||
} from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
|
||||
const ASpaceCompact = Space.Compact;
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
|
||||
|
||||
const contactPickerSupported = isContactPickerSupported();
|
||||
const pickingContact = ref(false);
|
||||
const contactFileInput = ref<HTMLInputElement | null>(null);
|
||||
const sourceOpen = ref(false);
|
||||
const pickOpen = ref(false);
|
||||
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
||||
const pickQuery = ref('');
|
||||
const vcfInputKey = ref(0);
|
||||
|
||||
const visiblePickDrafts = computed(() =>
|
||||
filterPhoneContacts(pickDrafts.value, pickQuery.value),
|
||||
);
|
||||
|
||||
const companyOptions = computed(() => [
|
||||
{ value: '', label: 'بدون شرکت' },
|
||||
...companies.value.map((c) => ({ value: c.id, label: c.name })),
|
||||
]);
|
||||
|
||||
const backTo = computed(() =>
|
||||
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
|
||||
);
|
||||
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
|
||||
sourceOpen.value = true;
|
||||
return;
|
||||
}
|
||||
contactFileInput.value?.click();
|
||||
vcfInputKey.value += 1;
|
||||
void nextTick(() => triggerVcfPick());
|
||||
}
|
||||
|
||||
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
|
||||
|
||||
function triggerVcfPick() {
|
||||
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
|
||||
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
input?.click();
|
||||
}
|
||||
|
||||
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
void (async () => {
|
||||
pickingContact.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const rows = await parseVCardFile(file as File);
|
||||
if (!rows.length) {
|
||||
error.value = 'مخاطبی در فایل پیدا نشد.';
|
||||
return;
|
||||
}
|
||||
await openPickList(rows);
|
||||
} catch {
|
||||
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
|
||||
} finally {
|
||||
pickingContact.value = false;
|
||||
}
|
||||
})();
|
||||
return false;
|
||||
};
|
||||
|
||||
async function pickFromDevice() {
|
||||
if (pickingContact.value || saving.value) return;
|
||||
pickingContact.value = true;
|
||||
@@ -167,7 +227,7 @@ async function pickFromDevice() {
|
||||
const message = e instanceof Error ? e.message : '';
|
||||
if (name === 'AbortError') return;
|
||||
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
|
||||
contactFileInput.value?.click();
|
||||
triggerVcfPick();
|
||||
return;
|
||||
}
|
||||
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
|
||||
@@ -178,28 +238,7 @@ async function pickFromDevice() {
|
||||
|
||||
function pickFromVcf() {
|
||||
sourceOpen.value = false;
|
||||
contactFileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onContactVcfFile(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const file = el.files?.[0];
|
||||
el.value = '';
|
||||
if (!file) return;
|
||||
pickingContact.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const rows = await parseVCardFile(file);
|
||||
if (!rows.length) {
|
||||
error.value = 'مخاطبی در فایل پیدا نشد.';
|
||||
return;
|
||||
}
|
||||
await openPickList(rows);
|
||||
} catch {
|
||||
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
|
||||
} finally {
|
||||
pickingContact.value = false;
|
||||
}
|
||||
triggerVcfPick();
|
||||
}
|
||||
|
||||
function fill(p: PersonDto) {
|
||||
@@ -334,14 +373,24 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!id.value || !confirm('این شخص حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/people/${id.value}`);
|
||||
await router.push({ name: 'contacts' });
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
}
|
||||
function remove() {
|
||||
if (!id.value) return;
|
||||
Modal.confirm({
|
||||
title: 'حذف شخص',
|
||||
content: 'این شخص حذف شود؟',
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
try {
|
||||
await api.delete(`/people/${id.value}`);
|
||||
await router.push({ name: 'contacts' });
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -356,498 +405,299 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
|
||||
<!-- Desktop Header -->
|
||||
<UiPageHeader
|
||||
<div>
|
||||
<a-flex
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
:back-to="backTo"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
:gap="12"
|
||||
:style="{ marginBottom: '16px' }"
|
||||
>
|
||||
<template #lead>
|
||||
<span class="crumb">
|
||||
<div>
|
||||
<a-typography-text type="secondary">
|
||||
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
|
||||
<span>›</span>
|
||||
<strong>{{ pageTitle }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
|
||||
· {{ pageTitle }}
|
||||
</a-typography-text>
|
||||
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button @click="cancel">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
انصراف
|
||||
</a-button>
|
||||
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="submit">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ذخیره
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<form v-else class="crm-form" @submit.prevent="submit">
|
||||
<UiCard>
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام" for-id="firstName">
|
||||
<UiInput id="firstName" v-model="firstName" />
|
||||
</UiField>
|
||||
<a-spin :spinning="loading">
|
||||
<a-form v-if="!loading" layout="vertical" @finish="submit">
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">اطلاعات اصلی</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام">
|
||||
<a-input v-model:value="firstName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام خانوادگی">
|
||||
<a-input v-model:value="lastName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="موبایل">
|
||||
<a-space-compact block style="width: 100%">
|
||||
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
|
||||
<a-button
|
||||
:disabled="pickingContact || saving"
|
||||
@click="fillFromPhoneContact"
|
||||
>
|
||||
<template #icon><PhoneOutlined /></template>
|
||||
{{ pickingContact ? '…' : 'از مخاطبین' }}
|
||||
</a-button>
|
||||
</a-space-compact>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تلفن">
|
||||
<a-input v-model:value="phone" type="tel" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ایمیل">
|
||||
<a-input v-model:value="email" type="email" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سمت">
|
||||
<a-input v-model:value="position" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="آدرس وبسایت">
|
||||
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="پیج اینستاگرام">
|
||||
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ تولد">
|
||||
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="معرف">
|
||||
<a-input v-model:value="referrer" placeholder="نام معرف" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<UiField label="نام خانوادگی" for-id="lastName">
|
||||
<UiInput id="lastName" v-model="lastName" />
|
||||
</UiField>
|
||||
<a-divider orientation="right">آدرسها</a-divider>
|
||||
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
|
||||
<a-flex justify="flex-end">
|
||||
<a-button type="link" @click="addAddress">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن آدرس
|
||||
</a-button>
|
||||
</a-flex>
|
||||
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
|
||||
<a-col :flex="'auto'">
|
||||
<a-input
|
||||
v-model:value="addresses[idx]"
|
||||
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col>
|
||||
<a-button danger @click="removeAddress(idx)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
|
||||
<UiField label="موبایل" for-id="mobile">
|
||||
<template #label-extra>
|
||||
<button
|
||||
type="button"
|
||||
class="from-contact"
|
||||
:disabled="pickingContact || saving"
|
||||
@click="fillFromPhoneContact"
|
||||
>
|
||||
<UiIcon name="phone" :size="14" />
|
||||
{{ pickingContact ? '…' : 'از مخاطبین' }}
|
||||
</button>
|
||||
</template>
|
||||
<UiInput id="mobile" v-model="mobile" type="tel" />
|
||||
</UiField>
|
||||
|
||||
<input
|
||||
ref="contactFileInput"
|
||||
class="hidden-file"
|
||||
type="file"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
@change="onContactVcfFile"
|
||||
/>
|
||||
|
||||
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
|
||||
<p class="pick-lead">
|
||||
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
|
||||
</p>
|
||||
<div class="pick-choose">
|
||||
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
|
||||
<UiIcon name="phone" :size="16" />
|
||||
از دفترچه تلفن
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
|
||||
از فایل .vcf
|
||||
</UiButton>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
|
||||
<p class="pick-lead">
|
||||
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
||||
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
|
||||
</p>
|
||||
<div class="pick-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
v-model="pickQuery"
|
||||
type="search"
|
||||
placeholder="جستجو نام یا موبایل…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<ul v-if="visiblePickDrafts.length" class="pick-list">
|
||||
<li v-for="row in visiblePickDrafts" :key="row.key">
|
||||
<button type="button" class="pick-row" @click="choosePick(row)">
|
||||
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
|
||||
<span class="pick-meta">
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ draftLine(row) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
|
||||
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiField label="تلفن" for-id="phone">
|
||||
<UiInput id="phone" v-model="phone" type="tel" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="ایمیل" for-id="email">
|
||||
<UiInput id="email" v-model="email" type="email" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="سمت" for-id="position">
|
||||
<UiInput id="position" v-model="position" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="آدرس وبسایت" for-id="website">
|
||||
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="پیج اینستاگرام" for-id="instagram">
|
||||
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="تاریخ تولد" for-id="birth-date">
|
||||
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="معرف" for-id="referrer">
|
||||
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
|
||||
</UiField>
|
||||
|
||||
<div class="crm-col-span-full addresses">
|
||||
<div class="addresses-head">
|
||||
<span>آدرسها</span>
|
||||
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
|
||||
</div>
|
||||
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
|
||||
<UiInput
|
||||
:id="`address-${idx}`"
|
||||
v-model="addresses[idx]"
|
||||
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
|
||||
/>
|
||||
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="وضعیت مشتری">
|
||||
<div class="moods">
|
||||
<button
|
||||
v-for="item in PERSON_MOODS"
|
||||
:key="item"
|
||||
class="mood-btn"
|
||||
type="button"
|
||||
:class="{ on: mood === item }"
|
||||
<a-divider orientation="right">وضعیت و شرکت</a-divider>
|
||||
<a-form-item label="وضعیت مشتری">
|
||||
<a-row :gutter="[8, 8]">
|
||||
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
|
||||
<a-button
|
||||
block
|
||||
:type="mood === item ? 'primary' : 'default'"
|
||||
:ghost="mood === item"
|
||||
:style="{ height: 'auto', paddingBlock: '12px' }"
|
||||
@click="mood = item"
|
||||
>
|
||||
<CustomerMoodFace :mood="item" :size="40" />
|
||||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||||
</button>
|
||||
<a-flex vertical align="center" :gap="6">
|
||||
<CustomerMoodFace :mood="item" :size="40" />
|
||||
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
|
||||
</a-flex>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شرکت">
|
||||
<a-select
|
||||
v-model:value="companyId"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="companyOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="برچسبها">
|
||||
<a-input
|
||||
v-model:value="tagsRaw"
|
||||
placeholder="VIP، معرفی (با کاما جدا کنید)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea v-model:value="notes" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card
|
||||
v-if="!isNew"
|
||||
:bordered="true"
|
||||
:style="{ marginTop: '8px' }"
|
||||
type="inner"
|
||||
>
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
|
||||
<div>
|
||||
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text strong>
|
||||
{{ toFaDigits(loyaltyPoints) }} امتیاز
|
||||
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</UiField>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'loyalty' }">
|
||||
<a-button type="link">مدیریت باشگاه</a-button>
|
||||
</RouterLink>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="شرکت" for-id="companyId">
|
||||
<UiSelect id="companyId" v-model="companyId">
|
||||
<option value="">بدون شرکت</option>
|
||||
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
</div>
|
||||
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||||
</a-card>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="برچسبها" for-id="tags">
|
||||
<UiInput
|
||||
id="tags"
|
||||
v-model="tagsRaw"
|
||||
placeholder="VIP، معرفی (با کاما جدا کنید)"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="یادداشت" for-id="notes">
|
||||
<UiTextarea id="notes" v-model="notes" :rows="3" />
|
||||
</UiField>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
|
||||
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
|
||||
<div class="loyalty-info">
|
||||
<span class="muted">باشگاه مشتریان</span>
|
||||
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
|
||||
</div>
|
||||
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
<a-space
|
||||
v-if="embedded"
|
||||
wrap
|
||||
:size="8"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
|
||||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||||
</a-space>
|
||||
|
||||
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||||
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
|
||||
<a-space
|
||||
v-else
|
||||
wrap
|
||||
:size="8"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
|
||||
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<p v-if="error" class="crm-field-error">{{ error }}</p>
|
||||
<a-upload
|
||||
ref="vcfUploadRef"
|
||||
:key="vcfInputKey"
|
||||
:show-upload-list="false"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
:before-upload="onVcfBeforeUpload"
|
||||
:style="{ display: 'none' }"
|
||||
>
|
||||
<span />
|
||||
</a-upload>
|
||||
|
||||
<div class="mobile-actions mob-only">
|
||||
<UiButton variant="primary" block type="submit" :disabled="saving">
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
<a-modal
|
||||
:open="sourceOpen"
|
||||
title="از مخاطبین"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
@cancel="closeSource"
|
||||
>
|
||||
<a-typography-paragraph type="secondary">
|
||||
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
|
||||
</a-typography-paragraph>
|
||||
<a-flex vertical :gap="8">
|
||||
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
|
||||
<template #icon><PhoneOutlined /></template>
|
||||
از دفترچه تلفن
|
||||
</a-button>
|
||||
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
|
||||
<a-button type="link" @click="closeSource">انصراف</a-button>
|
||||
</a-flex>
|
||||
</a-modal>
|
||||
|
||||
<!-- Embedded Modal Actions (Fixed at bottom) -->
|
||||
<div v-if="embedded" class="embed-actions">
|
||||
<UiButton variant="primary" :disabled="saving" @click="submit">
|
||||
<UiIcon name="check" :size="16" />
|
||||
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
|
||||
</div>
|
||||
</section>
|
||||
<a-modal
|
||||
:open="pickOpen"
|
||||
title="انتخاب مخاطب"
|
||||
destroy-on-close
|
||||
@cancel="closePick"
|
||||
>
|
||||
<a-typography-paragraph type="secondary">
|
||||
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
||||
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
|
||||
</a-typography-paragraph>
|
||||
<a-input
|
||||
v-model:value="pickQuery"
|
||||
allow-clear
|
||||
placeholder="جستجو نام یا موبایل…"
|
||||
:style="{ marginBottom: '12px' }"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-list
|
||||
v-if="visiblePickDrafts.length"
|
||||
:data-source="visiblePickDrafts"
|
||||
:style="{ maxHeight: '360px', overflow: 'auto' }"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
|
||||
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
|
||||
<template #footer>
|
||||
<a-button @click="closePick">انصراف</a-button>
|
||||
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.crumb a {
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.addresses {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.addresses-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.add-addr {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-deep);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-bold);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.addr-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rm-addr {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rm-addr:hover {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.moods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.mood-btn {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mood-btn.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.loyalty-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.loyalty-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.loyalty-link {
|
||||
color: var(--accent-deep);
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.mobile-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.from-contact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent-deep);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-bold);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.from-contact:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hidden-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pick-lead {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.pick-choose {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.pick-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.65rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.pick-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pick-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: min(48vh, 360px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pick-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pick-row:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.pick-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: var(--fw-bold);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pick-meta {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pick-meta strong {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.pick-meta small {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pick-empty {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
.mob-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,12 +17,28 @@ import {
|
||||
updateDealStage,
|
||||
} from '@/lib/pipeline-board';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
|
||||
import UiButton from '@/components/ui/UiButton.vue';
|
||||
import UiInput from '@/components/ui/UiInput.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
import DealFormView from '@/views/DealFormView.vue';
|
||||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
Modal,
|
||||
Modal as AModal,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
|
||||
import {
|
||||
DEAL_EXCEL_ALIASES,
|
||||
@@ -30,6 +46,9 @@ import {
|
||||
EXCEL_CUSTOM_FIELD_PREFIX,
|
||||
brand,
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
|
||||
|
||||
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
|
||||
key: c.key,
|
||||
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
|
||||
rawValues: true,
|
||||
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
|
||||
};
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const GAP = 16;
|
||||
|
||||
/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
|
||||
const pageStyle = {
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: `${GAP}px`,
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -62,7 +96,6 @@ const amountMax = ref('');
|
||||
const showCreate = ref(false);
|
||||
const newName = ref('');
|
||||
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
|
||||
const deleteStageId = ref<string | null>(null);
|
||||
const stageBusy = ref(false);
|
||||
const busy = ref(false);
|
||||
const importMsg = ref('');
|
||||
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
|
||||
}
|
||||
|
||||
function onDeleteStage(stageId: string) {
|
||||
deleteStageId.value = stageId;
|
||||
const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
|
||||
Modal.confirm({
|
||||
title: 'حذف ستون',
|
||||
content: `ستون «${title}» حذف شود؟ اگر معاملهای داخل آن باشد باید اول آنها را جابهجا کنید.`,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
if (!boardPipelineId.value || stageBusy.value) return;
|
||||
stageBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
|
||||
throw e;
|
||||
} finally {
|
||||
stageBusy.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deleteStageTitle = computed(() => {
|
||||
const id = deleteStageId.value;
|
||||
return stages.value.find((s) => s.id === id)?.title ?? '';
|
||||
});
|
||||
|
||||
async function confirmDeleteStage() {
|
||||
const stageId = deleteStageId.value;
|
||||
if (!stageId || !boardPipelineId.value || stageBusy.value) return;
|
||||
stageBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
|
||||
deleteStageId.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
|
||||
} finally {
|
||||
stageBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDealIds = ref<string[]>([]);
|
||||
const dealDeleting = ref(false);
|
||||
const selectMode = ref(false);
|
||||
const selectedIds = ref<Set<string>>(new Set());
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size);
|
||||
|
||||
const deleteDealTitle = computed(() => {
|
||||
const ids = deleteDealIds.value;
|
||||
if (ids.length !== 1) return '';
|
||||
function dealTitleById(dealId: string) {
|
||||
for (const s of stages.value) {
|
||||
const d = s.deals.find((x) => x.id === ids[0]);
|
||||
const d = s.deals.find((x) => x.id === dealId);
|
||||
if (d) return d.title;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const deleteConfirmCopy = computed(() => {
|
||||
const n = deleteDealIds.value.length;
|
||||
if (n <= 0) return '';
|
||||
if (n === 1) {
|
||||
return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
|
||||
}
|
||||
return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
|
||||
});
|
||||
}
|
||||
|
||||
function askDeleteDeal(dealId: string) {
|
||||
menuOpenId.value = null;
|
||||
deleteDealIds.value = [dealId];
|
||||
confirmDeleteDeals([dealId]);
|
||||
}
|
||||
|
||||
function startSelect(dealId: string) {
|
||||
@@ -460,31 +481,43 @@ function clearSelect() {
|
||||
function askBulkDelete() {
|
||||
if (!selectedIds.value.size) return;
|
||||
menuOpenId.value = null;
|
||||
deleteDealIds.value = [...selectedIds.value];
|
||||
confirmDeleteDeals([...selectedIds.value]);
|
||||
}
|
||||
|
||||
async function confirmDeleteDeal() {
|
||||
const ids = deleteDealIds.value;
|
||||
function confirmDeleteDeals(ids: string[]) {
|
||||
if (!ids.length || dealDeleting.value) return;
|
||||
dealDeleting.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
deleteDealIds.value = [];
|
||||
clearSelect();
|
||||
await load();
|
||||
if (failed) {
|
||||
error.value =
|
||||
failed === ids.length
|
||||
? 'حذف معاملهها ناموفق بود.'
|
||||
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
|
||||
} finally {
|
||||
dealDeleting.value = false;
|
||||
}
|
||||
const copy =
|
||||
ids.length === 1
|
||||
? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
|
||||
: `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
|
||||
Modal.confirm({
|
||||
title: ids.length > 1 ? 'حذف معاملهها' : 'حذف معامله',
|
||||
content: copy,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
async onOk() {
|
||||
dealDeleting.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
clearSelect();
|
||||
await load();
|
||||
if (failed) {
|
||||
error.value =
|
||||
failed === ids.length
|
||||
? 'حذف معاملهها ناموفق بود.'
|
||||
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
|
||||
throw e;
|
||||
} finally {
|
||||
dealDeleting.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onDealSaved() {
|
||||
@@ -593,6 +626,19 @@ function onMenuDeal(dealId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function onDealMenuClick({ key }: { key: string | number }) {
|
||||
const id = menuOpenId.value;
|
||||
if (!id) return;
|
||||
if (key === 'select') startSelect(id);
|
||||
else if (key === 'view') onOpenDeal(id);
|
||||
else if (key === 'edit') {
|
||||
dealModal.value = { dealId: id };
|
||||
menuOpenId.value = null;
|
||||
} else if (key === 'promote') void promotePriorDeal(id);
|
||||
else if (key === 'delete') askDeleteDeal(id);
|
||||
else menuOpenId.value = null;
|
||||
}
|
||||
|
||||
const unbindMenuDismiss = bindMenuDismiss(
|
||||
() => menuOpenId.value != null,
|
||||
() => (menuOpenId.value = null),
|
||||
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" dir="rtl">
|
||||
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
|
||||
<PipelineToolbar
|
||||
pipeline-name="مسیر فروش"
|
||||
:pipeline-options="pipelineOptions"
|
||||
v-model:selected-pipeline-id="selectedPipelineId"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:search="search"
|
||||
v-model:amount-min="amountMin"
|
||||
v-model:amount-max="amountMax"
|
||||
:can-create="canCreatePipeline"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
<template #excel>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
||||
خروجی اکسل
|
||||
</button>
|
||||
<ExcelImportFlow
|
||||
preview-path="/deals/import/preview"
|
||||
import-path="/deals/import"
|
||||
rows-import-path="/deals/import/rows"
|
||||
:client-aliases="DEAL_EXCEL_ALIASES"
|
||||
:client-fields="dealExcelFields"
|
||||
:client-read-options="dealClientReadOptions"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
|
||||
ورود از اکسل
|
||||
</button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
<SavedFiltersBar
|
||||
scope="deals"
|
||||
:current-filters="dealsSavedFilters"
|
||||
@apply="applyDealsSavedFilters"
|
||||
/>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
||||
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری مسیر فروش…</p>
|
||||
|
||||
<div v-if="selectMode" class="select-bar">
|
||||
<span>{{ toFaDigits(selectedCount) }} معامله انتخاب شده</span>
|
||||
<button
|
||||
v-if="selectedCount"
|
||||
type="button"
|
||||
class="select-del"
|
||||
@click="askBulkDelete"
|
||||
<div class="pipeline-page" dir="rtl" :style="pageStyle">
|
||||
<a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
|
||||
<PipelineToolbar
|
||||
pipeline-name="مسیر فروش"
|
||||
:pipeline-options="pipelineOptions"
|
||||
v-model:selected-pipeline-id="selectedPipelineId"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:search="search"
|
||||
v-model:amount-min="amountMin"
|
||||
v-model:amount-max="amountMax"
|
||||
:can-create="canCreatePipeline"
|
||||
@create="showCreate = true"
|
||||
>
|
||||
حذف انتخابشدهها
|
||||
</button>
|
||||
<button type="button" class="select-cancel" @click="clearSelect">انصراف از انتخاب</button>
|
||||
<template #excel>
|
||||
<a-button :disabled="loading || busy" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
<ExcelImportFlow
|
||||
preview-path="/deals/import/preview"
|
||||
import-path="/deals/import"
|
||||
rows-import-path="/deals/import/rows"
|
||||
:client-aliases="DEAL_EXCEL_ALIASES"
|
||||
:client-fields="dealExcelFields"
|
||||
:client-read-options="dealClientReadOptions"
|
||||
:disabled="loading || busy"
|
||||
@start="onImportStart"
|
||||
@done="onImportDone"
|
||||
@fail="onImportFail"
|
||||
@settled="onImportSettled"
|
||||
>
|
||||
<a-button :disabled="loading || busy">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
ورود از اکسل
|
||||
</a-button>
|
||||
</ExcelImportFlow>
|
||||
</template>
|
||||
<template #filters>
|
||||
<SavedFiltersBar
|
||||
compact
|
||||
scope="deals"
|
||||
:current-filters="dealsSavedFilters"
|
||||
@apply="applyDealsSavedFilters"
|
||||
/>
|
||||
</template>
|
||||
</PipelineToolbar>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
|
||||
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
|
||||
|
||||
<a-alert
|
||||
v-if="selectMode"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="`${toFaDigits(selectedCount)} معامله انتخاب شده`"
|
||||
>
|
||||
<template #action>
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="selectedCount"
|
||||
type="primary"
|
||||
danger
|
||||
@click="askBulkDelete"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف انتخابشدهها
|
||||
</a-button>
|
||||
<a-button @click="clearSelect">انصراف از انتخاب</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-alert>
|
||||
</a-flex>
|
||||
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="pageBusy"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: token.colorBgMask,
|
||||
}"
|
||||
>
|
||||
<a-spin :tip="busyMessage" />
|
||||
</div>
|
||||
|
||||
<PipelineList
|
||||
v-if="viewMode === 'list' && (!loading || stages.length)"
|
||||
:stages="visibleStages"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@open-deal="onOpenDeal"
|
||||
@menu-deal="onMenuDeal"
|
||||
@toggle-select="toggleSelect"
|
||||
@bulk-delete="askBulkDelete"
|
||||
/>
|
||||
|
||||
<PipelineBoard
|
||||
v-else-if="viewMode === 'kanban' && stages.length"
|
||||
:stages="visibleStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canCreatePipeline"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="onAddDeal"
|
||||
@open-deal="onOpenDeal"
|
||||
@menu-deal="onMenuDeal"
|
||||
@toggle-select="toggleSelect"
|
||||
@bulk-delete="askBulkDelete"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="onColumnDragStart"
|
||||
@column-drag-end="onColumnDragEnd"
|
||||
@add-stage="onAddStage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PipelineList
|
||||
v-if="viewMode === 'list' && (!loading || stages.length)"
|
||||
:stages="visibleStages"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@open-deal="onOpenDeal"
|
||||
@menu-deal="onMenuDeal"
|
||||
@toggle-select="toggleSelect"
|
||||
@bulk-delete="askBulkDelete"
|
||||
/>
|
||||
|
||||
<PipelineBoard
|
||||
v-else-if="viewMode === 'kanban' && stages.length"
|
||||
:stages="visibleStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canCreatePipeline"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="onAddDeal"
|
||||
@open-deal="onOpenDeal"
|
||||
@menu-deal="onMenuDeal"
|
||||
@toggle-select="toggleSelect"
|
||||
@bulk-delete="askBulkDelete"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="onColumnDragStart"
|
||||
@column-drag-end="onColumnDragEnd"
|
||||
@add-stage="onAddStage"
|
||||
/>
|
||||
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
@@ -734,109 +819,62 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="menuOpenId" class="menu-pop" role="menu" :style="menuStyle ?? undefined">
|
||||
<button type="button" role="menuitem" @click="startSelect(menuOpenId)">
|
||||
انتخاب
|
||||
</button>
|
||||
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
|
||||
مشاهده معامله
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null"
|
||||
<div
|
||||
v-if="menuOpenId"
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
zIndex: 1080,
|
||||
...(menuStyle ?? {}),
|
||||
}"
|
||||
>
|
||||
<a-menu
|
||||
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
|
||||
@click="onDealMenuClick"
|
||||
>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
v-if="canPromotePrior(findDeal(menuOpenId))"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="promotePriorDeal(menuOpenId)"
|
||||
>
|
||||
انتقال به کار جاری
|
||||
</button>
|
||||
<button type="button" role="menuitem" class="danger" @click="askDeleteDeal(menuOpenId)">
|
||||
حذف
|
||||
</button>
|
||||
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
|
||||
<a-menu-item key="select">انتخاب</a-menu-item>
|
||||
<a-menu-item key="view">مشاهده معامله</a-menu-item>
|
||||
<a-menu-item key="edit">ویرایش</a-menu-item>
|
||||
<a-menu-item
|
||||
v-if="canPromotePrior(findDeal(menuOpenId))"
|
||||
key="promote"
|
||||
>
|
||||
انتقال به کار جاری
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" danger>حذف</a-menu-item>
|
||||
<a-menu-item key="close">بستن</a-menu-item>
|
||||
</a-menu>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<UiModal
|
||||
<a-modal
|
||||
:open="showCreate && canCreatePipeline"
|
||||
title="مسیر فروش جدید"
|
||||
@close="showCreate = false"
|
||||
:confirm-loading="saving"
|
||||
ok-text="ساخت"
|
||||
cancel-text="انصراف"
|
||||
:ok-button-props="{ disabled: newName.trim().length < 2 }"
|
||||
destroy-on-close
|
||||
@ok="createPipeline"
|
||||
@cancel="showCreate = false"
|
||||
>
|
||||
<form class="create-form" @submit.prevent="createPipeline">
|
||||
<label class="field">
|
||||
<span>نام مسیر فروش</span>
|
||||
<UiInput
|
||||
v-model="newName"
|
||||
<a-form layout="vertical" @finish="createPipeline">
|
||||
<a-form-item label="نام مسیر فروش" required>
|
||||
<a-input
|
||||
v-model:value="newName"
|
||||
placeholder="نام مسیر فروش جدید…"
|
||||
:maxlength="120"
|
||||
required
|
||||
:minlength="2"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
type="button"
|
||||
:disabled="saving || newName.trim().length < 2"
|
||||
@click="createPipeline"
|
||||
>
|
||||
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" @click="showCreate = false">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<UiModal
|
||||
:open="Boolean(deleteStageId)"
|
||||
title="حذف ستون"
|
||||
@close="deleteStageId = null"
|
||||
>
|
||||
<p class="delete-copy">
|
||||
ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معاملهای داخل آن باشد باید
|
||||
اول آنها را جابهجا کنید.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton
|
||||
type="button"
|
||||
:disabled="stageBusy"
|
||||
@click="confirmDeleteStage"
|
||||
>
|
||||
{{ stageBusy ? 'در حال حذف…' : 'حذف' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" @click="deleteStageId = null">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal
|
||||
:open="deleteDealIds.length > 0"
|
||||
:title="deleteDealIds.length > 1 ? 'حذف معاملهها' : 'حذف معامله'"
|
||||
@close="deleteDealIds = []"
|
||||
>
|
||||
<p class="delete-copy">{{ deleteConfirmCopy }}</p>
|
||||
<template #footer>
|
||||
<UiButton type="button" :disabled="dealDeleting" @click="confirmDeleteDeal">
|
||||
{{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" @click="deleteDealIds = []">
|
||||
انصراف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal
|
||||
<a-modal
|
||||
:open="Boolean(dealModal)"
|
||||
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
|
||||
@close="dealModal = null"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
width="720px"
|
||||
@cancel="dealModal = null"
|
||||
>
|
||||
<DealFormView
|
||||
v-if="dealModal"
|
||||
@@ -847,117 +885,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
@saved="onDealSaved"
|
||||
@cancel="dealModal = null"
|
||||
/>
|
||||
</UiModal>
|
||||
</section>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
container-type: inline-size;
|
||||
container-name: pipeline;
|
||||
}
|
||||
|
||||
.create-form .field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.create-form .field span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-copy {
|
||||
/* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
|
||||
.pipeline-page {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem 1rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--accent) 10%, #fff);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line));
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-bar span {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.select-bar button {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-bar .select-del {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.select-bar .select-cancel {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.menu-pop {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.35rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.menu-pop button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0.45rem 0.85rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.menu-pop button:hover {
|
||||
background: var(--surface-2, #f1f5f9);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
.menu-pop button.danger {
|
||||
color: var(--danger);
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Statistic as AStatistic,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialAccountType } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
@@ -116,110 +139,176 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="صندوق و حسابها" description="موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<div class="stats">
|
||||
<article>
|
||||
<span>مجموع موجودی حسابها</span>
|
||||
<strong>{{ toman(total) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>ورودی این فهرست</span>
|
||||
<strong class="in">{{ toman(periodIn) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>خروجی این فهرست</span>
|
||||
<strong class="out">{{ toman(periodOut) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>حسابهای فعال</span>
|
||||
<strong>{{ active.length }} حساب</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="account in accounts" :key="account.id" class="account">
|
||||
<div>
|
||||
<strong>{{ account.name }}</strong>
|
||||
<small>{{ typeLabel[account.type] }}{{ account.isActive ? '' : ' · غیرفعال' }}</small>
|
||||
<p>مشاهده گردش حساب و جزئیات</p>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(account.balance) }}</b>
|
||||
<RouterLink class="link" :to="`/finance/accounts/${account.id}`">جزئیات حساب</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<UiButton @click="openForm = !openForm">افزودن حساب جدید</UiButton>
|
||||
<UiButton variant="secondary" @click="transferOpen = !transferOpen">انتقال وجه</UiButton>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="openForm" title="حساب جدید">
|
||||
<div class="grid">
|
||||
<UiField label="نام حساب"><UiInput v-model="form.name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.type" class="select">
|
||||
<option value="cash">صندوق نقدی</option>
|
||||
<option value="bank">حساب بانکی</option>
|
||||
<option value="petty">تنخواه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField v-if="form.type === 'bank'" label="نام بانک"><UiInput v-model="form.bankName" /></UiField>
|
||||
<UiField label="شماره یا شناسه"><UiInput v-model="form.accountRef" /></UiField>
|
||||
<UiField label="مسئول"><UiInput v-model="form.responsibleName" /></UiField>
|
||||
<UiField label="موجودی اولیه"><MoneyInput v-model="form.openingBalance" :decimals="0" /></UiField>
|
||||
<UiField label="تاریخ شروع"><UiInput v-model="form.openingDate" type="date" /></UiField>
|
||||
<UiField label="توضیحات"><UiInput v-model="form.notes" /></UiField>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">صندوق و حسابها</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
موجودی هر حساب از مانده افتتاحیه و گردش قطعی محاسبه میشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createAccount">ثبت حساب</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="transferOpen" title="انتقال بین حسابها">
|
||||
<div class="grid">
|
||||
<UiField label="از حساب">
|
||||
<select v-model="transfer.fromAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="به حساب">
|
||||
<select v-model="transfer.toAccountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in active" :key="`to-${account.id}`" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="transfer.amount" :decimals="0" /></UiField>
|
||||
<UiField label="شرح"><UiInput v-model="transfer.memo" /></UiField>
|
||||
</div>
|
||||
<p class="muted">این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.</p>
|
||||
<UiButton :loading="saving" @click="sendTransfer">تأیید انتقال</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="مجموع موجودی حسابها" :value="toman(total)" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic
|
||||
title="ورودی این فهرست"
|
||||
:value="toman(periodIn)"
|
||||
:value-style="{ color: 'var(--ant-color-success)' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic
|
||||
title="خروجی این فهرست"
|
||||
:value="toman(periodOut)"
|
||||
:value-style="{ color: 'var(--ant-color-error)' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic title="حسابهای فعال" :value="`${active.length} حساب`" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openForm = !openForm">افزودن حساب جدید</a-button>
|
||||
<a-button @click="transferOpen = !transferOpen">انتقال وجه</a-button>
|
||||
</a-space>
|
||||
|
||||
<template v-if="openForm">
|
||||
<a-divider orientation="right">حساب جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createAccount">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام حساب" name="name">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="type">
|
||||
<a-select v-model:value="form.type" style="width: 100%">
|
||||
<a-select-option value="cash">صندوق نقدی</a-select-option>
|
||||
<a-select-option value="bank">حساب بانکی</a-select-option>
|
||||
<a-select-option value="petty">تنخواه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="form.type === 'bank'" :xs="24" :md="8">
|
||||
<a-form-item label="نام بانک" name="bankName">
|
||||
<a-input v-model:value="form.bankName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شماره یا شناسه" name="accountRef">
|
||||
<a-input v-model:value="form.accountRef" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مسئول" name="responsibleName">
|
||||
<a-input v-model:value="form.responsibleName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="موجودی اولیه" name="openingBalance">
|
||||
<a-input v-model:value="form.openingBalance" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ شروع" name="openingDate">
|
||||
<a-input v-model:value="form.openingDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت حساب</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<template v-if="transferOpen">
|
||||
<a-divider orientation="right">انتقال بین حسابها</a-divider>
|
||||
<a-form layout="vertical" @finish="sendTransfer">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="از حساب" name="fromAccountId">
|
||||
<a-select v-model:value="transfer.fromAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in active" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="به حساب" name="toAccountId">
|
||||
<a-select v-model:value="transfer.toAccountId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in active" :key="`to-${account.id}`" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="transfer.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شرح" name="memo">
|
||||
<a-input v-model:value="transfer.memo" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-typography-text type="secondary">
|
||||
این انتقال درآمد یا هزینه نیست. اگر موجودی مبدأ کافی نباشد ثبت نمیشود.
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">تأیید انتقال</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">فهرست حسابها</a-divider>
|
||||
<a-empty v-if="!accounts.length" description="حسابی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="accounts">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.balance) }}</a-typography-text>
|
||||
<RouterLink :to="`/finance/accounts/${item.id}`">جزئیات حساب</RouterLink>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ item.name }}
|
||||
<a-tag v-if="!item.isActive">غیرفعال</a-tag>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ typeLabel[item.type] }} · مشاهده گردش حساب و جزئیات
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats, .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
|
||||
.stats article, .account {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.stats span, .account small, .account p, .muted { color: #64748b; margin: 0.15rem 0 0; }
|
||||
.in { color: #15803d; }
|
||||
.out { color: #b91c1c; }
|
||||
.list { display: grid; gap: 0.65rem; }
|
||||
.account { display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
|
||||
.side { display: grid; justify-items: end; gap: 0.4rem; }
|
||||
.link { color: #0f766e; font-weight: 700; text-decoration: none; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialCategoryDto } from '@mento/shared';
|
||||
import { UiButton, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const rows = ref<FinancialCategoryDto[]>([]);
|
||||
const name = ref('');
|
||||
const kind = ref<'income' | 'expense'>('expense');
|
||||
@@ -35,36 +56,61 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="دستهبندیهای مالی" description="دستههای پیشفرض با اولین بازدید ساخته میشوند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div class="add">
|
||||
<UiField label="نام"><UiInput v-model="name" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="kind" class="select">
|
||||
<option value="income">درآمد</option>
|
||||
<option value="expense">هزینه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiButton @click="add">افزودن</UiButton>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="row in rows" :key="row.id">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">دستهبندیهای مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دستههای پیشفرض با اولین بازدید ساخته میشوند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.add { display: grid; grid-template-columns: 1fr 160px auto; gap: 0.7rem; align-items: end; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
li span { color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
@media (max-width: 700px) { .add { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">افزودن دسته</a-divider>
|
||||
<a-form layout="vertical" @finish="add">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام" name="name">
|
||||
<a-input v-model:value="name" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-select v-model:value="kind" style="width: 100%">
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="4">
|
||||
<a-form-item label=" ">
|
||||
<a-button type="primary" html-type="submit" block>افزودن</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="دستهای ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag :color="item.kind === 'income' ? 'success' : 'default'">
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialCategoryDto, FinancialDocumentDto } from '@mento/shared';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
const kind = 'expense' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const form = ref({
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const title = 'پرداختها و هزینهها';
|
||||
const settleLabel = 'مبلغ پرداختشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'پرداختنشده',
|
||||
partial: 'جزئی',
|
||||
settled: 'پرداختشده',
|
||||
void: 'باطلشده',
|
||||
};
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
||||
{ value: 'pos', label: 'کارتخوان' },
|
||||
{ value: 'transfer', label: 'حواله' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
];
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
});
|
||||
form.value = {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: '',
|
||||
partyName: '',
|
||||
accountId: '',
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
};
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreasuryDocsView kind="expense" />
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ title }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">سند جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createDoc">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select v-model:value="form.categoryId" allow-clear style="width: 100%" placeholder="بدون دسته">
|
||||
<a-select-option value="">بدون دسته</a-select-option>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب حساب">
|
||||
<a-select-option value="">انتخاب حساب</a-select-option>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option v-for="opt in methodOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'void'"
|
||||
type="link"
|
||||
danger
|
||||
@click="voidDoc(item.id)"
|
||||
>
|
||||
ابطال
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Statistic as AStatistic,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TreasuryDashboardDto } from '@mento/shared';
|
||||
import { UiCard, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const data = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
|
||||
@@ -31,82 +46,117 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="مدیریت مالی"
|
||||
description="دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<div v-if="data" class="stats">
|
||||
<article v-for="card in cards" :key="card.key">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ toman(data[card.key]) }}</strong>
|
||||
<small>{{ card.hint }}</small>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="data" class="split">
|
||||
<UiCard title="اقساط نزدیک یا معوق">
|
||||
<p v-if="!data.upcomingInstallments.length" class="muted">قسط بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingInstallments" :key="row.id">
|
||||
<div>
|
||||
<strong>قسط {{ row.position }}</strong>
|
||||
<small>{{ row.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
<UiCard title="پرداختهای آینده">
|
||||
<p v-if="!data.upcomingPayables.length" class="muted">تعهد پرداخت بازی نیست.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.upcomingPayables" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
<UiCard v-if="data" title="آخرین اسناد">
|
||||
<p v-if="!data.recent.length" class="muted">هنوز سندی ثبت نشده است.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in data.recent" :key="row.id">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.status }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مدیریت مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دفتر داخلی درآمد، هزینه و موجودی حساب. این بخش جایگزین فاکتور فروش یا حسابداری رسمی نیست.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.stats article {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 16px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.stats span, .stats small, .muted { color: #64748b; }
|
||||
.stats strong { font-size: 1.05rem; }
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
|
||||
@media (max-width: 800px) { .split { grid-template-columns: 1fr; } }
|
||||
.list li { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.list small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<template v-if="data">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:xl="6"
|
||||
>
|
||||
<a-card :bordered="false">
|
||||
<a-statistic :title="card.label" :value="toman(data[card.key])" />
|
||||
<a-typography-text type="secondary">{{ card.hint }}</a-typography-text>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">اقساط نزدیک یا معوق</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingInstallments.length"
|
||||
description="قسط بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingInstallments"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>قسط {{ item.position }}</template>
|
||||
<template #description>
|
||||
{{ item.status === 'overdue' ? 'معوق' : 'باز' }} · مانده {{ toman(item.remaining) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-divider orientation="right">پرداختهای آینده</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.upcomingPayables.length"
|
||||
description="تعهد پرداخت بازی نیست."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.upcomingPayables"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>مانده {{ toman(item.remaining) }}</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="right">آخرین اسناد</a-divider>
|
||||
<a-empty
|
||||
v-if="!data.recent.length"
|
||||
description="هنوز سندی ثبت نشده است."
|
||||
/>
|
||||
<a-list
|
||||
v-else
|
||||
item-layout="horizontal"
|
||||
:data-source="data.recent"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,765 @@
|
||||
<script setup lang="ts">
|
||||
import TreasuryDocsView from './TreasuryDocsView.vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Grid,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowLeftOutlined,
|
||||
BankOutlined,
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
CreditCardOutlined,
|
||||
FileTextOutlined,
|
||||
InfoCircleOutlined,
|
||||
NumberOutlined,
|
||||
PlusOutlined,
|
||||
TagOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatLocalDateInput } from '@/lib/jalali';
|
||||
import type {
|
||||
FinancialAccountDto,
|
||||
FinancialCategoryDto,
|
||||
FinancialDocumentDto,
|
||||
TreasuryDashboardDto,
|
||||
} from '@mento/shared';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = computed(() => !screens.value.md);
|
||||
|
||||
const kind = 'income' as const;
|
||||
const rows = ref<FinancialDocumentDto[]>([]);
|
||||
const categories = ref<FinancialCategoryDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const dashboard = ref<TreasuryDashboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const ok = ref('');
|
||||
const saving = ref(false);
|
||||
const q = ref('');
|
||||
const formTab = ref<'main' | 'extra'>('main');
|
||||
const categoryModalOpen = ref(false);
|
||||
const newCategoryName = ref('');
|
||||
const categorySaving = ref(false);
|
||||
const mobileFormRef = ref<FormInstance>();
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
amount: '',
|
||||
categoryId: undefined as string | undefined,
|
||||
partyName: '',
|
||||
accountId: undefined as string | undefined,
|
||||
settleAmount: '',
|
||||
method: 'transfer',
|
||||
reference: '',
|
||||
notes: '',
|
||||
bookedAt: formatLocalDateInput(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const pageTitle = 'دریافتها و درآمدها';
|
||||
const settleLabel = 'مبلغ دریافتشده';
|
||||
const statusLabel: Record<string, string> = {
|
||||
open: 'دریافتنشده',
|
||||
partial: 'جزئی',
|
||||
settled: 'دریافتشده',
|
||||
void: 'باطلشده',
|
||||
};
|
||||
|
||||
const methodOptions = [
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
{ value: 'card_to_card', label: 'کارتبهکارت' },
|
||||
{ value: 'pos', label: 'کارتخوان' },
|
||||
{ value: 'transfer', label: 'حواله' },
|
||||
{ value: 'other', label: 'سایر' },
|
||||
];
|
||||
|
||||
const formTabOptions = [
|
||||
{
|
||||
value: 'main',
|
||||
payload: 'main',
|
||||
label: h('span', {}, [h(InfoCircleOutlined), ' اطلاعات اصلی']),
|
||||
},
|
||||
{
|
||||
value: 'extra',
|
||||
payload: 'extra',
|
||||
label: h('span', {}, [h(FileTextOutlined), ' جزئیات تکمیلی']),
|
||||
},
|
||||
];
|
||||
|
||||
const remainingAmount = computed(() => {
|
||||
const total = Number(form.value.amount) || 0;
|
||||
const settled = Number(form.value.settleAmount) || 0;
|
||||
return Math.max(0, total - settled);
|
||||
});
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'ذخیره ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق بود.';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [docs, cats, acc, dash] = await Promise.all([
|
||||
api.get<FinancialDocumentDto[]>('/treasury/documents', { params: { kind, q: q.value || undefined } }),
|
||||
api.get<FinancialCategoryDto[]>('/treasury/categories', { params: { kind } }),
|
||||
api.get<FinancialAccountDto[]>('/treasury/accounts'),
|
||||
api.get<TreasuryDashboardDto>('/treasury/dashboard').catch(() => null),
|
||||
]);
|
||||
rows.value = docs.data;
|
||||
categories.value = cats.data.filter((row) => row.isActive);
|
||||
accounts.value = acc.data.filter((row) => row.isActive);
|
||||
if (dash) dashboard.value = dash.data;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = emptyForm();
|
||||
formTab.value = 'main';
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
}
|
||||
|
||||
async function createDoc() {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.post('/treasury/documents', {
|
||||
kind,
|
||||
title: form.value.title,
|
||||
amount: form.value.amount,
|
||||
categoryId: form.value.categoryId || null,
|
||||
partyName: form.value.partyName || null,
|
||||
accountId: form.value.accountId || null,
|
||||
settleAmount: form.value.settleAmount || 0,
|
||||
method: form.value.method,
|
||||
reference: form.value.reference || null,
|
||||
notes: form.value.notes || null,
|
||||
bookedAt: form.value.bookedAt || undefined,
|
||||
});
|
||||
resetForm();
|
||||
ok.value = 'سند ثبت شد. اگر مبلغ دریافت/پرداخت صفر باشد موجودی حساب تغییر نمیکند.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMobileForm() {
|
||||
try {
|
||||
await mobileFormRef.value?.validate();
|
||||
} catch {
|
||||
formTab.value = 'main';
|
||||
return;
|
||||
}
|
||||
await createDoc();
|
||||
}
|
||||
|
||||
async function voidDoc(id: string) {
|
||||
if (!window.confirm('این سند و گردش متصل به آن باطل شود؟')) return;
|
||||
try {
|
||||
await api.post(`/treasury/documents/${id}/void`, { reason: 'ابطال توسط مدیر' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategoryQuick() {
|
||||
const name = newCategoryName.value.trim();
|
||||
if (name.length < 2) {
|
||||
error.value = 'نام دستهبندی کوتاه است.';
|
||||
return;
|
||||
}
|
||||
categorySaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<FinancialCategoryDto>('/treasury/categories', {
|
||||
name,
|
||||
kind,
|
||||
});
|
||||
await load();
|
||||
form.value.categoryId = data.id;
|
||||
newCategoryName.value = '';
|
||||
categoryModalOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e);
|
||||
} finally {
|
||||
categorySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری ناموفق بود.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TreasuryDocsView kind="income" />
|
||||
<div
|
||||
:style="
|
||||
isMobile
|
||||
? { paddingBottom: 'calc(96px + var(--mobile-tabbar-h, 3.65rem))' }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<!-- ─── Mobile app experience ─── -->
|
||||
<template v-if="isMobile">
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<div>
|
||||
<a-flex align="center" :gap="token.marginXS">
|
||||
<a-typography-title :level="3" :style="{ margin: 0, flex: 1 }">
|
||||
ثبت دریافت جدید
|
||||
</a-typography-title>
|
||||
<ArrowLeftOutlined :style="{ color: token.colorTextSecondary, fontSize: '18px' }" />
|
||||
</a-flex>
|
||||
<a-typography-text type="secondary">اطلاعات دریافت را وارد کنید</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :span="12">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:body-style="{ padding: `${token.paddingSM}px` }"
|
||||
:style="{
|
||||
background: token.colorPrimaryBg,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: token.boxShadow,
|
||||
}"
|
||||
>
|
||||
<a-flex align="center" justify="space-between" :gap="8">
|
||||
<div :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
دریافتهای این ماه
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="{ fontSize: '15px', color: token.colorPrimary }"
|
||||
>
|
||||
{{ toman(dashboard?.actualReceipts) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<a-avatar
|
||||
:size="36"
|
||||
:style="{
|
||||
background: token.colorPrimary,
|
||||
color: token.colorTextLightSolid,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<template #icon><ArrowDownOutlined /></template>
|
||||
</a-avatar>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:body-style="{ padding: `${token.paddingSM}px` }"
|
||||
:style="{
|
||||
background: token.colorPrimaryBg,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: token.boxShadow,
|
||||
}"
|
||||
>
|
||||
<a-flex align="center" justify="space-between" :gap="8">
|
||||
<div :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
در انتظار دریافت
|
||||
</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text
|
||||
strong
|
||||
:style="{ fontSize: '15px', color: token.colorPrimary }"
|
||||
>
|
||||
{{ toman(dashboard?.receivables) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<a-avatar
|
||||
:size="36"
|
||||
:style="{
|
||||
background: token.colorPrimary,
|
||||
color: token.colorTextLightSolid,
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<template #icon><ClockCircleOutlined /></template>
|
||||
</a-avatar>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<a-segmented
|
||||
v-model:value="formTab"
|
||||
block
|
||||
:options="formTabOptions"
|
||||
:style="{ background: token.colorFillTertiary }"
|
||||
/>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-form ref="mobileFormRef" layout="vertical" :model="form" @finish="createDoc">
|
||||
<div v-show="formTab === 'main'">
|
||||
<a-form-item
|
||||
label="عنوان درآمد"
|
||||
name="title"
|
||||
:rules="[{ required: true, message: 'عنوان را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.title"
|
||||
placeholder="مثلاً: دریافت قسط پروژه الف"
|
||||
>
|
||||
<template #prefix><FileTextOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="مشتری / طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" placeholder="نام طرف حساب">
|
||||
<template #prefix><UserOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item name="method">
|
||||
<template #label>
|
||||
<a-space :size="6">
|
||||
<CreditCardOutlined />
|
||||
<span>روش دریافت</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option
|
||||
v-for="opt in methodOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item name="categoryId">
|
||||
<template #label>
|
||||
<a-space :size="6">
|
||||
<TagOutlined />
|
||||
<span>دستهبندی درآمد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-flex :gap="8">
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
placeholder="انتخاب دسته"
|
||||
style="flex: 1; min-width: 0"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" ghost @click="categoryModalOpen = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
label="مبلغ کل (تومان)"
|
||||
name="amount"
|
||||
:rules="[{ required: true, message: 'مبلغ کل را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.amount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item
|
||||
:label="`${settleLabel} (تومان)`"
|
||||
name="settleAmount"
|
||||
:rules="[{ required: true, message: 'مبلغ دریافتشده را وارد کنید' }]"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.settleAmount"
|
||||
inputmode="decimal"
|
||||
placeholder="۰"
|
||||
dir="ltr"
|
||||
>
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:style="{
|
||||
background: token.colorSuccessBg,
|
||||
borderRadius: `${token.borderRadiusLG * 2}px`,
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
marginBottom: `${token.margin}px`,
|
||||
}"
|
||||
>
|
||||
<a-space :size="8">
|
||||
<span
|
||||
:style="{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: token.colorSuccess,
|
||||
display: 'inline-block',
|
||||
}"
|
||||
/>
|
||||
<a-typography-text type="secondary">مانده قابل دریافت</a-typography-text>
|
||||
</a-space>
|
||||
<a-typography-text strong :style="{ color: token.colorSuccess }">
|
||||
{{ toman(remainingAmount) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="تاریخ دریافت" name="bookedAt">
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item name="accountId">
|
||||
<template #label>
|
||||
<a-space :size="6">
|
||||
<BankOutlined />
|
||||
<span>حساب مقصد</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-select
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
placeholder="انتخاب حساب"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<div v-show="formTab === 'extra'">
|
||||
<a-form-item label="شماره پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" placeholder="شماره پیگیری بانکی یا مرجع">
|
||||
<template #prefix><NumberOutlined /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-textarea
|
||||
v-model:value="form.notes"
|
||||
:rows="4"
|
||||
placeholder="توضیحات اختیاری…"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'void'"
|
||||
type="link"
|
||||
danger
|
||||
@click="voidDoc(item.id)"
|
||||
>
|
||||
ابطال
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
|
||||
<div
|
||||
:style="{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 'var(--mobile-tabbar-h, 3.65rem)',
|
||||
zIndex: 90,
|
||||
background: token.colorBgElevated,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
}"
|
||||
>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="8">
|
||||
<a-button block @click="resetForm">انصراف</a-button>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-button type="primary" block :loading="saving" @click="submitMobileForm">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ثبت دریافت
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── Desktop (unchanged interaction model) ─── -->
|
||||
<template v-else>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">{{ pageTitle }}</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت سند، موجودی را عوض نمیکند مگر مبلغ دریافت یا پرداخت واقعی وارد شود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">سند جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createDoc">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item :label="settleLabel" name="settleAmount">
|
||||
<a-input v-model:value="form.settleAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دستهبندی" name="categoryId">
|
||||
<a-select
|
||||
v-model:value="form.categoryId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="بدون دسته"
|
||||
>
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select
|
||||
v-model:value="form.accountId"
|
||||
allow-clear
|
||||
style="width: 100%"
|
||||
placeholder="انتخاب حساب"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="account in accounts"
|
||||
:key="account.id"
|
||||
:value="account.id"
|
||||
>
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="روش" name="method">
|
||||
<a-select v-model:value="form.method" style="width: 100%">
|
||||
<a-select-option
|
||||
v-for="opt in methodOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تاریخ" name="bookedAt">
|
||||
<JalaliDateInput v-model="form.bookedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیگیری" name="reference">
|
||||
<a-input v-model:value="form.reference" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="توضیحات" name="notes">
|
||||
<a-input v-model:value="form.notes" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست اسناد</a-divider>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="جستجو">
|
||||
<a-input
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو…"
|
||||
@press-enter="load"
|
||||
@change="load"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-empty v-if="!rows.length" description="سندی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'void'"
|
||||
type="link"
|
||||
danger
|
||||
@click="voidDoc(item.id)"
|
||||
>
|
||||
ابطال
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ statusLabel[item.status] }}
|
||||
· مانده {{ toman(item.remaining) }}
|
||||
<template v-if="item.categoryName"> · {{ item.categoryName }}</template>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
v-model:open="categoryModalOpen"
|
||||
title="دسته جدید"
|
||||
ok-text="افزودن"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="categorySaving"
|
||||
@ok="createCategoryQuick"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="نام دسته" required>
|
||||
<a-input
|
||||
v-model:value="newCategoryName"
|
||||
placeholder="مثلاً: فروش خدمات"
|
||||
@press-enter="createCategoryQuick"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, OrgUserDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type PayrollRow = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
@@ -69,6 +88,10 @@ async function payRow() {
|
||||
}
|
||||
}
|
||||
|
||||
function startPay(row: PayrollRow) {
|
||||
pay.value = { id: row.id, amount: row.remaining, accountId: accounts.value[0]?.id || '' };
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری حقوق ناموفق بود.';
|
||||
@@ -77,59 +100,109 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="حقوق و دستمزد" description="فقط مالک و مدیر این صفحه را میبینند." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="ثبت دوره">
|
||||
<div class="grid">
|
||||
<UiField label="پرسنل">
|
||||
<select v-model="form.userId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.fullName }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="دوره"><UiInput v-model="form.period" placeholder="۱۴۰۵-۰۷" /></UiField>
|
||||
<UiField label="پایه"><MoneyInput v-model="form.baseAmount" :decimals="0" /></UiField>
|
||||
<UiField label="مزایا"><MoneyInput v-model="form.benefits" :decimals="0" /></UiField>
|
||||
<UiField label="پاداش"><MoneyInput v-model="form.bonus" :decimals="0" /></UiField>
|
||||
<UiField label="کسورات"><MoneyInput v-model="form.deductions" :decimals="0" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createRow">ثبت حقوق</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ row.period }} · مانده {{ toman(row.remaining) }} · {{ row.status }}</small>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">حقوق و دستمزد</a-typography-title>
|
||||
<a-typography-text type="secondary">فقط مالک و مدیر این صفحه را میبینند.</a-typography-text>
|
||||
</div>
|
||||
<div class="side">
|
||||
<b>{{ toman(row.payable) }}</b>
|
||||
<button v-if="row.status !== 'settled' && row.status !== 'void'" type="button" @click="pay = { id: row.id, amount: row.remaining, accountId: accounts[0]?.id || '' }">پرداخت</button>
|
||||
</div>
|
||||
</article>
|
||||
<UiCard v-if="pay.id" title="پرداخت حقوق">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><MoneyInput v-model="pay.amount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب">
|
||||
<select v-model="pay.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton @click="payRow">ثبت پرداخت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.75rem; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.side { display: grid; justify-items: end; }
|
||||
.side button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">ثبت دوره</a-divider>
|
||||
<a-form layout="vertical" @finish="createRow">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="پرسنل" name="userId">
|
||||
<a-select v-model:value="form.userId" style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.fullName }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="period">
|
||||
<a-input v-model:value="form.period" placeholder="۱۴۰۵-۰۷" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پایه" name="baseAmount">
|
||||
<a-input v-model:value="form.baseAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مزایا" name="benefits">
|
||||
<a-input v-model:value="form.benefits" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پاداش" name="bonus">
|
||||
<a-input v-model:value="form.bonus" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="کسورات" name="deductions">
|
||||
<a-input v-model:value="form.deductions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت حقوق</a-button>
|
||||
</a-form>
|
||||
|
||||
<template v-if="pay.id">
|
||||
<a-divider orientation="right">پرداخت حقوق</a-divider>
|
||||
<a-form layout="vertical" @finish="payRow">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="pay.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب" name="accountId">
|
||||
<a-select v-model:value="pay.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت پرداخت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">فهرست</a-divider>
|
||||
<a-empty v-if="!rows.length" description="رکورد حقوقی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.payable) }}</a-typography-text>
|
||||
<a-button
|
||||
v-if="item.status !== 'settled' && item.status !== 'void'"
|
||||
type="link"
|
||||
@click="startPay(item)"
|
||||
>
|
||||
پرداخت
|
||||
</a-button>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.fullName }}</template>
|
||||
<template #description>
|
||||
{{ item.period }} · مانده {{ toman(item.remaining) }} · {{ item.status }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import type { FinancialAccountDto, FinancialReceivableDto } from '@mento/shared';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type InstallmentRow = FinancialReceivableDto['installments'][number];
|
||||
|
||||
const rows = ref<FinancialReceivableDto[]>([]);
|
||||
const accounts = ref<FinancialAccountDto[]>([]);
|
||||
const error = ref('');
|
||||
@@ -36,6 +59,31 @@ const statusLabel: Record<string, string> = {
|
||||
void: 'باطل',
|
||||
};
|
||||
|
||||
const installmentColumns: TableColumnsType<InstallmentRow> = [
|
||||
{ title: 'قسط', dataIndex: 'position', key: 'position' },
|
||||
{
|
||||
title: 'سررسید',
|
||||
key: 'dueDate',
|
||||
customRender: ({ record }) => formatJalaliNumeric(new Date(record.dueDate)),
|
||||
},
|
||||
{
|
||||
title: 'مبلغ',
|
||||
key: 'amount',
|
||||
customRender: ({ record }) => toman(record.amount),
|
||||
},
|
||||
{
|
||||
title: 'مانده',
|
||||
key: 'remaining',
|
||||
customRender: ({ record }) => toman(record.remaining),
|
||||
},
|
||||
{
|
||||
title: 'وضعیت',
|
||||
key: 'status',
|
||||
customRender: ({ record }) => statusLabel[record.status] ?? record.status,
|
||||
},
|
||||
{ title: '', key: 'actions' },
|
||||
];
|
||||
|
||||
function apiMessage(e: unknown) {
|
||||
if (!axios.isAxiosError(e)) return 'عملیات ناموفق بود.';
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -112,6 +160,15 @@ async function collectInstallment() {
|
||||
}
|
||||
}
|
||||
|
||||
function startCollect(row: FinancialReceivableDto, item: InstallmentRow) {
|
||||
collect.value = {
|
||||
installmentId: item.id,
|
||||
receivableId: row.id,
|
||||
amount: item.remaining,
|
||||
accountId: accounts.value[0]?.id || '',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load().catch(() => {
|
||||
error.value = 'بارگذاری مطالبات ناموفق بود.';
|
||||
@@ -120,113 +177,166 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="اقساط و مطالبات"
|
||||
description="دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند."
|
||||
/>
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="قرارداد جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="طرف حساب"><UiInput v-model="form.partyName" /></UiField>
|
||||
<UiField label="مبلغ کل"><MoneyInput v-model="form.totalAmount" :decimals="0" /></UiField>
|
||||
<UiField label="پیشپرداخت"><MoneyInput v-model="form.prepaidAmount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب پیشپرداخت">
|
||||
<select v-model="form.accountId" class="select">
|
||||
<option value="">انتخاب</option>
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تعداد قسط"><UiInput v-model="form.count" :disabled="custom" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">اقساط و مطالبات</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
دریافت هر قسط فقط یک بار وارد حساب میشود و مانده همان قسط را کم میکند.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<label class="check"><input v-model="custom" type="checkbox" /> اقساط نامساوی</label>
|
||||
<div v-if="custom" class="custom">
|
||||
<div v-for="(line, index) in customLines" :key="index" class="grid">
|
||||
<UiField label="مبلغ قسط"><MoneyInput v-model="line.amount" :decimals="0" /></UiField>
|
||||
<UiField label="سررسید"><UiInput v-model="line.dueDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton variant="secondary" @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</UiButton>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="createContract">ساخت اقساط</UiButton>
|
||||
<UiButton variant="secondary" @click="downloadExcel">خروجی اکسل</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<article v-for="row in rows" :key="row.id" class="contract">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}</small>
|
||||
</div>
|
||||
<b>{{ toman(row.totalAmount) }}</b>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>قسط</th>
|
||||
<th>سررسید</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مانده</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in row.installments" :key="item.id">
|
||||
<td>{{ item.position }}</td>
|
||||
<td>{{ formatJalaliNumeric(new Date(item.dueDate)) }}</td>
|
||||
<td>{{ toman(item.amount) }}</td>
|
||||
<td>{{ toman(item.remaining) }}</td>
|
||||
<td>{{ statusLabel[item.status] }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="item.status !== 'settled'"
|
||||
type="button"
|
||||
@click="collect = { installmentId: item.id, receivableId: row.id, amount: item.remaining, accountId: accounts[0]?.id || '' }"
|
||||
>
|
||||
دریافت
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<TreasuryNav />
|
||||
|
||||
<UiCard v-if="collect.installmentId" title="دریافت قسط">
|
||||
<div class="grid">
|
||||
<UiField label="مبلغ"><MoneyInput v-model="collect.amount" :decimals="0" /></UiField>
|
||||
<UiField label="حساب مقصد">
|
||||
<select v-model="collect.accountId" class="select">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
|
||||
</select>
|
||||
</UiField>
|
||||
</div>
|
||||
<UiButton :loading="saving" @click="collectInstallment">ثبت دریافت</UiButton>
|
||||
</UiCard>
|
||||
</section>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">قرارداد جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createContract">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="طرف حساب" name="partyName">
|
||||
<a-input v-model:value="form.partyName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ کل" name="totalAmount">
|
||||
<a-input v-model:value="form.totalAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="پیشپرداخت" name="prepaidAmount">
|
||||
<a-input v-model:value="form.prepaidAmount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب پیشپرداخت" name="accountId">
|
||||
<a-select v-model:value="form.accountId" allow-clear style="width: 100%" placeholder="انتخاب">
|
||||
<a-select-option value="">انتخاب</a-select-option>
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="تعداد قسط" name="count">
|
||||
<a-input v-model:value="form.count" :disabled="custom" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-select v-model:value="form.interval" style="width: 100%" :disabled="custom">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" :disabled="custom" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24">
|
||||
<a-form-item>
|
||||
<a-checkbox v-model:checked="custom">اقساط نامساوی</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<template v-if="custom">
|
||||
<a-row v-for="(line, index) in customLines" :key="index" :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item :label="`مبلغ قسط ${index + 1}`">
|
||||
<a-input v-model:value="line.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سررسید">
|
||||
<a-input v-model:value="line.dueDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item>
|
||||
<a-button @click="customLines.push({ amount: '', dueDate: '' })">قسط دیگر</a-button>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ساخت اقساط</a-button>
|
||||
<a-button @click="downloadExcel">خروجی اکسل</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
|
||||
<template v-if="collect.installmentId">
|
||||
<a-divider orientation="right">دریافت قسط</a-divider>
|
||||
<a-form layout="vertical" @finish="collectInstallment">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="collect.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="حساب مقصد" name="accountId">
|
||||
<a-select v-model:value="collect.accountId" style="width: 100%">
|
||||
<a-select-option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">ثبت دریافت</a-button>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<a-divider orientation="right">قراردادها</a-divider>
|
||||
<a-empty v-if="!rows.length" description="قراردادی ثبت نشده است." />
|
||||
<template v-else>
|
||||
<div v-for="row in rows" :key="row.id">
|
||||
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="8">
|
||||
<div>
|
||||
<a-typography-text strong>{{ row.title }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ row.partyName || 'بدون طرف حساب' }} · مانده {{ toman(row.remaining) }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-typography-text strong>{{ toman(row.totalAmount) }}</a-typography-text>
|
||||
</a-flex>
|
||||
<a-table
|
||||
:columns="installmentColumns"
|
||||
:data-source="row.installments"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: true }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag>{{ statusLabel[record.status] ?? record.status }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-button
|
||||
v-if="record.status !== 'settled'"
|
||||
type="link"
|
||||
@click="startCollect(row, record as InstallmentRow)"
|
||||
>
|
||||
دریافت
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-divider />
|
||||
</div>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.contract { background: #fff; border: 1px solid #e7eef2; border-radius: 16px; padding: 0.8rem 1rem; overflow: auto; }
|
||||
.contract header, .contract header div { display: flex; justify-content: space-between; gap: 0.75rem; }
|
||||
.contract small { display: block; color: #64748b; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.6rem; }
|
||||
th, td { text-align: right; padding: 0.45rem; border-top: 1px solid #f1f5f9; white-space: nowrap; }
|
||||
button { border: 0; background: transparent; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
.check { display: flex; gap: 0.4rem; align-items: center; margin: 0.4rem 0 0.8rem; font-weight: 700; }
|
||||
.custom { display: grid; gap: 0.5rem; margin-bottom: 0.8rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiCard, UiField, UiInput, UiPageHeader } from '@/components/ui';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
import { formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
const ASelectOption = ASelect.Option;
|
||||
|
||||
type Row = { id: string; title: string; kind: string; amount: string; interval: string; nextDue: string; isActive: boolean };
|
||||
const rows = ref<Row[]>([]);
|
||||
const error = ref('');
|
||||
@@ -38,48 +57,80 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="درآمد و هزینه تکرارشونده" description="تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<p v-if="ok" class="ok">{{ ok }}</p>
|
||||
<UiCard title="الگوی جدید">
|
||||
<div class="grid">
|
||||
<UiField label="عنوان"><UiInput v-model="form.title" /></UiField>
|
||||
<UiField label="نوع">
|
||||
<select v-model="form.kind" class="select">
|
||||
<option value="expense">هزینه</option>
|
||||
<option value="income">درآمد</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="مبلغ"><MoneyInput v-model="form.amount" :decimals="0" /></UiField>
|
||||
<UiField label="دوره">
|
||||
<select v-model="form.interval" class="select">
|
||||
<option value="monthly">ماهانه</option>
|
||||
<option value="weekly">هفتگی</option>
|
||||
<option value="yearly">سالانه</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="شروع"><UiInput v-model="form.startDate" type="date" /></UiField>
|
||||
</div>
|
||||
<UiButton @click="createItem">ثبت الگو</UiButton>
|
||||
</UiCard>
|
||||
<article v-for="row in rows" :key="row.id" class="row">
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<strong>{{ row.title }}</strong>
|
||||
<small>{{ row.kind === 'income' ? 'درآمد' : 'هزینه' }} · {{ row.interval }} · بعدی {{ formatJalaliNumeric(new Date(row.nextDue)) }}</small>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">درآمد و هزینه تکرارشونده</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تا تأیید دریافت یا پرداخت، موجودی حساب عوض نمیشود.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<b>{{ toman(row.amount) }}</b>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.7rem; margin-bottom: 0.8rem; }
|
||||
.select { width: 100%; min-height: var(--control-h); border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
.row { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 14px; padding: 0.8rem 1rem; }
|
||||
.row small { display: block; color: #64748b; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
.ok { color: #0f766e; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">الگوی جدید</a-divider>
|
||||
<a-form layout="vertical" @finish="createItem">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="عنوان" name="title">
|
||||
<a-input v-model:value="form.title" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="نوع" name="kind">
|
||||
<a-select v-model:value="form.kind" style="width: 100%">
|
||||
<a-select-option value="expense">هزینه</a-select-option>
|
||||
<a-select-option value="income">درآمد</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="مبلغ" name="amount">
|
||||
<a-input v-model:value="form.amount" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="دوره" name="interval">
|
||||
<a-select v-model:value="form.interval" style="width: 100%">
|
||||
<a-select-option value="monthly">ماهانه</a-select-option>
|
||||
<a-select-option value="weekly">هفتگی</a-select-option>
|
||||
<a-select-option value="yearly">سالانه</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="شروع" name="startDate">
|
||||
<a-input v-model:value="form.startDate" type="date" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" html-type="submit">ثبت الگو</a-button>
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">فهرست الگوها</a-divider>
|
||||
<a-empty v-if="!rows.length" description="الگویی ثبت نشده است." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="rows">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.title }}</template>
|
||||
<template #description>
|
||||
{{ item.kind === 'income' ? 'درآمد' : 'هزینه' }}
|
||||
· {{ item.interval }}
|
||||
· بعدی {{ formatJalaliNumeric(new Date(item.nextDue)) }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import TreasuryNav from '@/components/treasury/TreasuryNav.vue';
|
||||
import { toman } from './treasury-format';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
type Period = {
|
||||
bookedIncome: string;
|
||||
bookedExpense: string;
|
||||
@@ -14,6 +30,14 @@ type Period = {
|
||||
cashNet: string;
|
||||
expenseCategories: Array<{ name: string; amount: string; share: string }>;
|
||||
};
|
||||
|
||||
type MetricRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
current: string;
|
||||
previous: string;
|
||||
};
|
||||
|
||||
const current = ref<Period | null>(null);
|
||||
const previous = ref<Period | null>(null);
|
||||
const payablesByMonth = ref<Array<{ month: string; amount: string }>>([]);
|
||||
@@ -29,6 +53,22 @@ const lines = [
|
||||
['خالص جریان نقدی', 'cashNet'],
|
||||
] as const;
|
||||
|
||||
const metricColumns: TableColumnsType<MetricRow> = [
|
||||
{ title: 'شاخص', dataIndex: 'label', key: 'label' },
|
||||
{ title: 'این دوره', dataIndex: 'current', key: 'current' },
|
||||
{ title: 'دوره قبل', dataIndex: 'previous', key: 'previous' },
|
||||
];
|
||||
|
||||
const metricRows = computed<MetricRow[]>(() => {
|
||||
if (!current.value || !previous.value) return [];
|
||||
return lines.map(([label, key]) => ({
|
||||
key,
|
||||
label,
|
||||
current: toman(current.value![key]),
|
||||
previous: toman(previous.value![key]),
|
||||
}));
|
||||
});
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get<{
|
||||
current: Period;
|
||||
@@ -60,49 +100,85 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="گزارش مالی" description="سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه." />
|
||||
<TreasuryNav />
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<UiButton variant="secondary" @click="download">خروجی اکسل</UiButton>
|
||||
<table v-if="current && previous">
|
||||
<thead>
|
||||
<tr><th>شاخص</th><th>این دوره</th><th>دوره قبل</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="line in lines" :key="line[1]">
|
||||
<td>{{ line[0] }}</td>
|
||||
<td>{{ toman(current[line[1]]) }}</td>
|
||||
<td>{{ toman(previous[line[1]]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>مطالبات به تفکیک طرف حساب</h2>
|
||||
<ul>
|
||||
<li v-for="row in receivablesByParty" :key="row.name"><span>{{ row.name }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!receivablesByParty.length">مورد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2>تعهدات به تفکیک ماه</h2>
|
||||
<ul>
|
||||
<li v-for="row in payablesByMonth" :key="row.month"><span>{{ row.month }}</span><b>{{ toman(row.amount) }}</b></li>
|
||||
<li v-if="!payablesByMonth.length">تعهد بازی نیست.</li>
|
||||
</ul>
|
||||
<h2 v-if="current">سهم هزینهها</h2>
|
||||
<ul v-if="current">
|
||||
<li v-for="row in current.expenseCategories" :key="row.name">
|
||||
<span>{{ row.name }}</span>
|
||||
<b>{{ toman(row.amount) }} · {{ row.share }}٪</b>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
<div>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">گزارش مالی</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
سود عملیاتی از درآمد و هزینه ثبتشده است، نه از خالص وجه.
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button @click="download">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<style scoped>
|
||||
.page { display: grid; gap: 1rem; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 16px; overflow: hidden; }
|
||||
th, td { text-align: right; padding: 0.7rem; border-bottom: 1px solid #f1f5f9; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
||||
li { display: flex; justify-content: space-between; background: #fff; border: 1px solid #e7eef2; border-radius: 12px; padding: 0.7rem 0.9rem; }
|
||||
h2 { margin: 0; font-size: 1rem; }
|
||||
.err { color: #b91c1c; font-weight: 700; }
|
||||
</style>
|
||||
<TreasuryNav />
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">مقایسه دورهها</a-divider>
|
||||
<a-table
|
||||
v-if="metricRows.length"
|
||||
:columns="metricColumns"
|
||||
:data-source="metricRows"
|
||||
:pagination="false"
|
||||
row-key="key"
|
||||
/>
|
||||
<a-empty v-else description="دادهای برای نمایش نیست." />
|
||||
|
||||
<a-divider orientation="right">مطالبات به تفکیک طرف حساب</a-divider>
|
||||
<a-empty v-if="!receivablesByParty.length" description="مورد بازی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="receivablesByParty">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<a-divider orientation="right">تعهدات به تفکیک ماه</a-divider>
|
||||
<a-empty v-if="!payablesByMonth.length" description="تعهد بازی نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="payablesByMonth">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>{{ toman(item.amount) }}</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.month }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
|
||||
<template v-if="current">
|
||||
<a-divider orientation="right">سهم هزینهها</a-divider>
|
||||
<a-empty v-if="!current.expenseCategories.length" description="دستهای نیست." />
|
||||
<a-list v-else item-layout="horizontal" :data-source="current.expenseCategories">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-typography-text strong>
|
||||
{{ toman(item.amount) }} · {{ item.share }}٪
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ item.name }}</template>
|
||||
</a-list-item-meta>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 بصری، اولویتهای ۱–۴ بالا ضروریاند.
|
||||
)
|
||||
Generated
+247
-3
@@ -60,7 +60,9 @@
|
||||
"name": "@mento/web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@mento/shared": "*",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.8.4",
|
||||
"echarts": "^6.1.0",
|
||||
"pinia": "^3.0.1",
|
||||
@@ -184,6 +186,34 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/colors": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
|
||||
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ctrl/tinycolor": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ant-design/icons-svg": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.6.0.tgz",
|
||||
"integrity": "sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ant-design/icons-vue": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
|
||||
"integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^6.0.0",
|
||||
"@ant-design/icons-svg": "^4.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"dev": true,
|
||||
@@ -224,6 +254,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.8",
|
||||
"license": "MIT",
|
||||
@@ -252,6 +291,27 @@
|
||||
"node": ">=0.1.90"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
|
||||
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
|
||||
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"cpu": [
|
||||
@@ -263,7 +323,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -279,7 +338,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1070,6 +1128,16 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simonwep/pickr": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
|
||||
"integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.15.1",
|
||||
"nanopop": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"devOptional": true,
|
||||
@@ -1739,6 +1807,46 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/ant-design-vue": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
|
||||
"integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "^6.0.0",
|
||||
"@ant-design/icons-vue": "^7.0.0",
|
||||
"@babel/runtime": "^7.10.5",
|
||||
"@ctrl/tinycolor": "^3.5.0",
|
||||
"@emotion/hash": "^0.9.0",
|
||||
"@emotion/unitless": "^0.8.0",
|
||||
"@simonwep/pickr": "~1.8.0",
|
||||
"array-tree-filter": "^2.1.0",
|
||||
"async-validator": "^4.0.0",
|
||||
"csstype": "^3.1.1",
|
||||
"dayjs": "^1.10.5",
|
||||
"dom-align": "^1.12.1",
|
||||
"dom-scroll-into-view": "^2.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.15",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"scroll-into-view-if-needed": "^2.2.25",
|
||||
"shallow-equal": "^1.0.0",
|
||||
"stylis": "^4.1.3",
|
||||
"throttle-debounce": "^5.0.0",
|
||||
"vue-types": "^3.0.0",
|
||||
"warning": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ant-design-vue"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
@@ -1830,10 +1938,22 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-tree-filter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
|
||||
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"license": "MIT"
|
||||
@@ -2355,6 +2475,12 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "1.0.20",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"license": "MIT"
|
||||
@@ -2427,6 +2553,20 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"license": "MIT"
|
||||
@@ -2586,6 +2726,18 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-align": {
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
|
||||
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dom-scroll-into-view": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
|
||||
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.1",
|
||||
"license": "BSD-2-Clause",
|
||||
@@ -3530,6 +3682,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
|
||||
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT"
|
||||
@@ -3593,7 +3754,6 @@
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3812,6 +3972,12 @@
|
||||
"version": "4.18.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"license": "MIT"
|
||||
@@ -3899,6 +4065,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"dev": true,
|
||||
@@ -4119,6 +4297,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanopop": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
|
||||
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -4685,6 +4869,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -4896,6 +5086,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
"version": "2.2.31",
|
||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"compute-scroll-into-view": "^1.0.20"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"license": "ISC",
|
||||
@@ -4955,6 +5154,12 @@
|
||||
"version": "1.2.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shallow-equal": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
|
||||
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
@@ -5147,6 +5352,12 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
|
||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.6",
|
||||
"license": "MIT",
|
||||
@@ -5317,6 +5528,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/throttle-debounce": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.22"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"devOptional": true,
|
||||
@@ -5820,6 +6040,30 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
|
||||
"integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-object": "3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/warning": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.2",
|
||||
"dev": true,
|
||||
|
||||
Reference in New Issue
Block a user