53 Commits
Author SHA1 Message Date
MahdiandCursor ae16e43013 Clarify activity card due tones: overdue as عقب افتاده, done as انجام شده, and today in warning yellow.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:52:56 +03:30
MahdiandCursor 7a55b84b81 Add org-scoped activity priority labels/colors and apply them across forms, boards, and routines.
Also fix activity leave-without-save confirm, kanban due-date labels with tone-colored peek chevron, and rename the types manager button.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 21:39:15 +03:30
MahdiandCursor a1ac677b3a Combine data-access and contact list facets with OR so each selected dimension grants visibility.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 20:15:21 +03:30
MahdiandCursor b00465439e Drop groupIds from customer data-access filters; keep visibility via group assignees only.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:50:02 +03:30
MahdiandCursor 9af0e9e94e Fix customer groups modal scroll nesting and make assignees removable via chips.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 19:07:36 +03:30
MahdiandCursor b745b6d460 Tighten team users table with a compact row-number column and fixed layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 18:30:49 +03:30
MahdiandCursor fec5ece255 Improve deal board activity cues with relative dates and a quick activity list popup.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 16:27:32 +03:30
MahdiandCursor f4f2311ae4 Improve team settings responsive layout, narrower role panel, and mobile pagination.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 15:31:58 +03:30
MahdiandCursor 57e0fdd600 Improve team settings search, user sorting, and role badge layout.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 14:01:08 +03:30
MahdiandCursor b583858088 feat(customers): add optional group assignees with OR visibility for people, companies, and birthdays
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 12:59:49 +03:30
MahdiandCursor 453ff1a5f6 fix(activities): allow assignees to attach files when commenting under creator-only edit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:49:22 +03:30
MahdiandCursor 68fcece743 fix(activities): keep comment and postpone open for assignees under creator-only edit
Allow dueAt/description patches for collaborators when editPolicy is creator-only, and show the no-edit warning only when they try to change locked fields—not as a permanent banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-12 09:23:10 +03:30
MahdiandCursor de77066cb0 feat(tahesab): add manual sync to refresh linked customer profiles
Expose sync/customers-now and a settings button so existing linked people get national id, address, city, and rasteh without waiting for the schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:54:59 +03:30
MahdiandCursor d23dd20237 feat(tahesab): sync full customer profile fields into CRM people
Pull national id, address, city, and rasteh on import/refresh so person records match Tahesab, and surface them on the person form and profile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 18:31:47 +03:30
MahdiandCursor 8a4bd70ad4 fix(boards): keep kanban card drag from locking after a few moves
Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:58:19 +03:30
MahdiandCursor d87321c7bf fix(activities): type ActivityRow with editPolicy for API builds
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:01:16 +03:30
MahdiandCursor 1a05eee46d feat(activities): let creators lock edit and delete to themselves
Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:56:36 +03:30
MahdiandCursor ec9a7cf9c9 feat(deals): plan activities on the deal and create them from the board
Show related activities on deal detail, prefill dealId on activity create, and add a side-panel quick create from pipeline cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:06:20 +03:30
MahdiandCursor 4f08ffae36 fix(web): use nowrap for StagePathStrip flex wrap prop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:59:06 +03:30
MahdiandCursor d7cb92bf81 feat(web): add stage path strip with Didar-style move confirm
Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:55:43 +03:30
MahdiandCursor 871d7bba1a fix(activities): keep board filters and make card drops reliable
Return to the filtered desk after marking an activity done, widen the detail title, and stop a stuck column-drag id from blocking card moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 13:05:26 +03:30
MahdiandCursor f13a1249af feat(activities): edit detail in place and tighten the board peek
Let people change activity fields and their own notes with photos from the detail page, open full detail from the card title, and toggle the side peek with a chevron.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 21:31:21 +03:30
MahdiandCursor fdf1fbe4aa feat(activities): peek at a card from the board
Let people read notes, postpone, and mark an activity done beside the card instead of opening the full page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 20:10:16 +03:30
MahdiandCursor 6df198321a feat(activities): remove an assignee from the share list
A team, role, or person can be taken off the activity from the progress chips, and ungrouped people use the same chip as grouped members.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 18:43:02 +03:30
MahdiandCursor 4c8604d346 feat(activities): pick assignees by team or role
A team or role adds every member in one step, while each person's completion share stays unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:39:51 +03:30
MahdiandCursor 25a24772fc feat(access): keep a single owner per organization
The person who registered stays the only owner. Full day-to-day access is granted through the manager role instead of a second owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:59:32 +03:30
MahdiandCursor 932dc3f1c1 feat(billing): start demos on the core plan and apply tariff levels
New signups get the core module set with the 7-day demo. Superadmin can apply core, team, organization, or dedicated and the owner's environments take that level's access.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 14:08:14 +03:30
MahdiandCursor 5b26b9156d fix(treasury): restore the money parser import on expenses
The phone layout used the amount parser without importing it, so the web type check blocked the push.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:33:17 +03:30
MahdiandCursor d12d7b91cb feat(treasury): separate receipts from income and fit finance on a phone
A receipt or payment only reduces the party balance, while an income or expense keeps both amounts. Finance screens stack into cards on a phone so the forms and lists stay readable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 12:28:27 +03:30
MahdiandCursor 49459707cb fix(treasury): show a party's net balance as debtor or creditor
Income and expense forms now use the same receivable-minus-payable figure beside the party.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 11:09:51 +03:30
MahdiandCursor ba7f942059 fix(treasury): apply a full receipt or payment to the party's open balance
A settled document of the same amount no longer leaves an existing remaining unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 10:45:59 +03:30
MahdiandCursor 96105468d4 fix(activities): keep each assignee status attached to that person
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:45:32 +03:30
MahdiandCursor 7a8007fff0 feat(activities): keep multi-assignee completion readable for large teams
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 21:30:05 +03:30
MahdiandCursor da851bd9e5 feat(activities): let every assignee finish their own share
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 20:48:43 +03:30
MahdiandCursor dae3ae3f7e fix(notes): keep card titles visible when the square card is crowded
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 18:34:23 +03:30
MahdiandCursor b03d97dd9e fix(activities): show each note as its own dated message
The colon inside the clock was splitting the stamp from the comment, so date, author, text, and attachments now stay together.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:39:06 +03:30
MahdiandCursor 8b8d01976d fix(pipeline): hide prior-only sales paths from current work
The board lists a pipeline in current work only when it has a deal in that period, and keeps archive-only paths for the prior-data view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 15:09:49 +03:30
MahdiandCursor 4764562bd4 fix(tahesab): stop treating shared phone numbers as new customers
A second Tahesab code with the same phone stays linked to the existing person, so the import list no longer counts it as unimported and sync no longer overwrites that person's name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:42:46 +03:30
MahdiandCursor 0e1bc4a1fe fix(access): honor finance sections and team-member data scope
Role treasury keys now open the first allowed finance page, and team-member ownership limits lists, dashboard, and reports instead of the whole organization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:20:20 +03:30
MahdiandCursor 3c9b906bd6 fix(leads): keep the filter panel from shifting the page
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:58:18 +03:30
MahdiandCursor ef58922e13 feat(leads): gather filters in one popover like activities
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 20:24:45 +03:30
MahdiandCursor 06036b00d3 feat(finance): open payroll details from mobile cards
Show each payroll as a card on a phone and slide its details up from the bottom so a short sheet leaves the top of the screen clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 18:13:38 +03:30
MahdiandCursor d33d88e76d feat(finance): show payroll details and search payment periods
Keep long reasons out of the report table, open them with payments in a details view, and let a payment period be found by name or month.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 17:34:12 +03:30
MahdiandCursor 5ef693990c feat(finance): edit payroll with month ranges, reasons, and toman
Let salary and payments be corrected after they are saved, pick a Jalali period as a month range, and show toman on finance amounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 16:53:45 +03:30
MahdiandCursor 6ebd45f2cb feat(finance): show invoice balances in treasury and split payroll
Issued invoices feed debtors, creditors, and period profit without a second income document. Payroll is split into setup, staged payment, and a filtered report.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:20:00 +03:30
MahdiandCursor 33f33f9521 fix(notes): keep the card menu above the grid
The menu was clipped by the card, so the lower actions disappeared under the next row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 14:19:35 +03:30
MahdiandCursor 97f00d7e6d fix(products): open one row menu so edit and other actions run
Desktop and mobile dropdowns shared an id, so both opened and the extra menu closed the click before the action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-08 11:54:38 +03:30
m.lotfi b8c49293f7 Update .gitea/workflows/build.yml
Fix build.yml for push to registery
2026-11-08 06:37:08 +00:00
MahdiandCursor f36711174e feat(voip): make Daftaresoma setup inbound-first and filter calls by line
Drop required from_number for webhook logging, persist CalleeExtension as lineExtension, and add direction/status/line filters on the calls list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 13:58:42 +03:30
MahdiandCursor abb1a3a18e feat(voip): add Daftaresoma provider tab with webhook and click-to-call
Map portal call events into CRM logs, store the API token for OutgoingCall, and surface a dedicated settings tab for wiring the webhook URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 12:31:59 +03:30
MahdiandCursor 21d2c69227 feat(leads): expand filters and tidy the list chrome on mobile
Owners can filter by assignee and follow-up status, with extra filters in a drawer, while mobile keeps a compact search-and-filter bar without the stage chip grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-07 11:07:34 +03:30
mahmood 0ca9540524 Fix change build level for test docker build
Build & Push Docker Image / build (push) Successful in 6m11s
2026-11-05 19:53:52 +03:30
mahmood dc691c5192 Preparation for MONSYS
Build & Push Docker Image / build (push) Failing after 7s
2026-11-05 19:48:41 +03:30
272 changed files with 52078 additions and 35301 deletions
+3 -11
View File
@@ -41,19 +41,11 @@ Legacy CRM UI (`components/ui/*`, `.crm-*`, `.btn` / `.field` in `styles/`) rema
6. **24-column grid only** 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. 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 + StyleProvider** 7. **RTL + locale**
App root in `App.vue` **must** wrap ConfigProvider with `<a-style-provider hash-priority="high">` so legacy `main.css` cannot override Ant controls via higher specificity: 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).
```vue
<a-style-provider hash-priority="high">
<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">
<RouterView />
</a-config-provider>
</a-style-provider>
```
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** 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). Prefer **explicit** `import { Button as AButton, … } from 'ant-design-vue'` in migrated files when the Vite resolver is missing or unreliable. `<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** 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. Visual tokens (primary, radius, `controlHeight`, padding/margin scale, shadows, Form/Card overrides) live only in `antdTheme.ts`. Do not fork theme objects inside views.
-7
View File
@@ -1,7 +0,0 @@
{
"plugins": {
"hex": {
"enabled": true
}
}
}
@@ -38,13 +38,9 @@ Also read and obey [`.cursor/rules/antd-strict-governance.mdc`](../../rules/antd
| Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` | | Dependency | `ant-design-vue` ^4, `@ant-design/icons-vue`, `unplugin-vue-components` in `@mento/web` |
| Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` | | Vite plugin | `apps/web/vite.config.ts` — `Components` + `AntDesignVueResolver` |
| Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) | | Theme | `apps/web/src/theme/antdTheme.ts` (`controlHeight: 36`) |
| RTL + locale + theme + StyleProvider | `apps/web/src/App.vue` — `<a-style-provider hash-priority="high">` wrapping `<a-config-provider direction="rtl" :locale="faIR" :theme="antdTheme">` (mandatory so legacy `main.css` cannot override Ant) | | 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 | | Legacy kit (do not touch) | `apps/web/src/components/ui/*`, `styles/main.css` utilities |
### Mandatory child-control conversion (same pass)
هنگام بازنویسی یک صفحه (View)، ایجنت موظف است علاوه بر خود فایل View، تمام کامپوننت‌های فرزندی را که در آن View ایمپورت شده‌اند و حاوی اینپوت/سلکت/دکمه هستند نیز بازبینی کرده و درون آن‌ها را به کامپوننت‌های رسمی `ant-design-vue` تبدیل کند. هیچ تگ `<input>`, `<select>`, `<textarea>`, `<button>` خامی نباید در صفحه یا کامپوننت‌های فرزندِ مصرفی آن باقی بماند.
## Migration workflow ## Migration workflow
1. Confirm the **single** route/view the user named. 1. Confirm the **single** route/view the user named.
+15
View File
@@ -0,0 +1,15 @@
.git
node_modules
**/dist
**/.env
**/.env.*
apps/stt/.venv
uploads
docs
e2e
test-results
playwright-report
graphify-out
.worktrees
*.md
crm-node_modules.tgz
+129
View File
@@ -0,0 +1,129 @@
name: Build & Push Docker Image
on:
push:
tags:
- "*"
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Start time
run: echo "BUILD_STARTED=$(date +%s)" >> "$GITHUB_ENV"
- name: Free disk space
id: prune
run: |
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
docker builder prune -af || true
docker image prune -af || true
docker system prune -af --volumes || true
df -h / /workspace /var/lib/containerd 2>/dev/null || df -h /
- name: Checkout code
id: checkout
uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
TAG="${GITHUB_REF_NAME}"
else
TAG="$(git rev-parse --short HEAD)"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "registry=dock.monsys.ir" >> "$GITHUB_OUTPUT"
- name: Build Docker images
id: build
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
docker build -f Dockerfile.api -t "$reg/montact-api:$tag" -t "$reg/montact-api:latest" .
docker build -f Dockerfile.web -t "$reg/montact-web:$tag" -t "$reg/montact-web:latest" .
docker build -f Dockerfile.stt -t "$reg/montact-stt:$tag" -t "$reg/montact-stt:latest" .
# Registry login is unsolved. These two steps stay off so a tag run
# only proves the three images build. Remove `if: false` on both when
# dock.monsys.ir accepts the runner.
- name: Login to registry
id: login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
USER_NAME="${REGISTRY_USERNAME:-admin}"
printf '%s\n' "$REGISTRY_PASSWORD" | docker login "dock.monsys.ir" -u "$USER_NAME" --password-stdin
- name: Push Docker images
id: push
run: |
set -eu
tag="${{ steps.meta.outputs.tag }}"
reg="${{ steps.meta.outputs.registry }}"
for name in montact-api montact-web montact-stt; do
docker push "$reg/$name:$tag"
docker push "$reg/$name:latest"
done
- name: Notify Bale
if: always()
continue-on-error: true
env:
BALE_TOKEN: ${{ secrets.BALE_BOT_TOKEN || vars.BALE_BOT_TOKEN }}
BALE_CHAT_ID: ${{ vars.BALE_CHAT_ID }}
TAG: ${{ steps.meta.outputs.tag }}
REG: ${{ steps.meta.outputs.registry }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}
STEP_OUTCOMES: |
Checkout code=${{ steps.checkout.outcome }}
Resolve image tag=${{ steps.meta.outcome }}
Build Docker images=${{ steps.build.outcome }}
Login to registry=${{ steps.login.outcome }}
Push Docker images=${{ steps.push.outcome }}
run: |
if [ -z "$BALE_TOKEN" ] || [ -z "$BALE_CHAT_ID" ]; then
echo "BALE_BOT_TOKEN / BALE_CHAT_ID not set in Gitea — skipping notification"
exit 0
fi
failed_step=$(printf '%s\n' "$STEP_OUTCOMES" | grep -E '=(failure|cancelled)$' | head -n1 | cut -d= -f1)
if [ -n "$failed_step" ]; then
title="❌ بیلد ناموفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Push Docker images=success$'; then
title="✅ بیلد موفق"
elif printf '%s\n' "$STEP_OUTCOMES" | grep -q '^Build Docker images=success$'; then
title="✅ بیلد موفق؛ پوش رجیستری هنوز خاموش است"
else
title="⚠️ بیلد کامل نشد"
fi
commit=$(git log -1 --pretty='%h — %s' 2>/dev/null || echo "${GITHUB_SHA:0:7}")
elapsed=$(( $(date +%s) - ${BUILD_STARTED:-$(date +%s)} ))
text="${title}
پروژه: ${GITHUB_REPOSITORY}
تگ: ${TAG:-${GITHUB_REF_NAME}}"
if [ -z "$failed_step" ] && [ -n "$TAG" ]; then
text="${text}
image: ${REG}/montact-api:${TAG}
image: ${REG}/montact-web:${TAG}
image: ${REG}/montact-stt:${TAG}"
fi
[ -n "$failed_step" ] && text="${text}
مرحلهٔ خطا: ${failed_step}"
text="${text}
کامیت: ${commit}
توسط: ${GITHUB_ACTOR}
مدت: $((elapsed / 60))m$((elapsed % 60))s
لاگ: ${RUN_URL}"
msg_file=$(mktemp)
printf '%s\n' "$text" | sed 's/^[[:space:]]*//' > "$msg_file"
curl -sS -m 20 -X POST "https://tapi.bale.ai/bot${BALE_TOKEN}/sendMessage" \
--data-urlencode "chat_id=${BALE_CHAT_ID}" \
--data-urlencode "text@${msg_file}"
-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
</g>
<g>
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<g>
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+29
View File
@@ -0,0 +1,29 @@
# NestJS API + Prisma. Build context is the monorepo root.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/api apps/api
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared \
&& npm run prisma:generate -w @mento/api \
&& npm run build -w @mento/api
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/shared ./packages/shared
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/prisma ./apps/api/prisma
WORKDIR /app/apps/api
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/src/main.js"]
+15
View File
@@ -0,0 +1,15 @@
# Local Faster-Whisper. Not published on Ingress.
FROM python:3.11-slim-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY apps/stt/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY apps/stt/main.py ./main.py
ENV STT_MODEL=small \
STT_DEVICE=cpu \
STT_COMPUTE_TYPE=int8 \
HF_HOME=/models
EXPOSE 3040
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3040"]
+16
View File
@@ -0,0 +1,16 @@
# Vue panel. The built app calls /api on the same host; Traefik sends that to the API.
FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY apps/web apps/web
COPY packages/shared packages/shared
RUN npm run build -w @mento/shared && npm run build -w @mento/web
FROM nginx:1.27-alpine
COPY docker/web.nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
-65
View File
@@ -1,65 +0,0 @@
---
description: حاکمیت مطلق معماری رابط کاربری بر پایه Ant Design Vue v4، قفل مهاجرت صفحه به صفحه، ممنوعیت کامل CSS دستی و استاندارد ریاضی چیدمان
globs: apps/web/src/**/*.{vue,ts,tsx,css}
alwaysApply: true
---
# قرارداد حاکمیتی و غیرقابل‌مذاکرهٔ رابط کاربری (Ant Design Vue v4 Strict Contract)
تنها منبع مجاز برای ساخت و بازنویسی عناصر رابط کاربری در این پروژه `ant-design-vue` (نسخه ۴) است. هرگونه تخطی از قوانین زیر خطای معماری محسوب می‌شود:
## ۱. قفل ایزولاسیون و مهاجرت صفحه به صفحه (Single-Page Blast Radius Lock)
1. **ممنوعیت ویرایش دسته‌جمعی:** تو فقط و فقط مجاز به ویرایش صفحه (View) یا کامپوننتی هستی که کاربر صراحتاً در همان نوبت نام برده است. دست زدن به سایر صفحات پروژه مطلقاً ممنوع است.
2. **حفظ لایهٔ قدیمی برای صفحات مهاجرت‌نکرده:** فایل‌های داخل `apps/web/src/components/ui/Ui*.vue` و استایل‌های سراسری `main.css` / `tokens.css` نباید حذف یا ویرایش شوند تا صفحات قدیمی پروژه بدون خطا به کار خود ادامه دهند.
3. **قطع کامل وابستگی در صفحهٔ هدف:** در صفحه‌ای که کاربر دستور بازنویسی آن را می‌دهد، تمام ایمپورت‌های `@/components/ui` و کلاس‌های قدیمی باید از آن فایل حذف شوند و ۱۰۰٪ با کامپوننت‌های رسمی `<a-...>` جایگزین گردند.
4. **کامپوننت‌های تخصصی تاریخ شمسی و مبالغ:** در صفحات بازنویسی‌شده، اگر از `JalaliDateInput` یا `MoneyInput` استفاده شده است، منطق آن‌ها را حفظ کن اما مطمئن شو ظاهر ورودی آن‌ها با ارتفاع `40px`، ردیوس `10px` و عرض `style="width: 100%"` کاملاً هم‌تراز با فیلدهای Ant Design نمایش داده می‌شود.
## ۲. لیست سیاه سینتکس و تگ‌های ممنوعه در صفحات بازنویسی‌شده (Banned Syntax)
در هر فایلی که بازنویسی یا ایجاد می‌کنی، وجود موارد زیر خطای بحرانی است و باید فوراً حذف شوند:
- **تگ‌های خام HTML برای فرم و جدول:** `<button>`, `<input>`, `<select>`, `<textarea>`, `<label>`, `<form>`, `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>`.
- **کلاس‌های لگسی پروژه:** `.btn`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.btn-save`, `.field`, `.crm-page`, `.crm-page-header`, `.crm-card`, `.crm-table`, `.crm-empty-state`.
- **کلاس‌های Tailwind (پروژه فاقد Tailwind است):** استفاده از کلاس‌های ناموجود مانند `w-full`, `flex`, `gap-4`, `p-4`, `mb-4` مطلقاً ممنوع است. برای عرض کامل کنترل‌ها فقط از `style="width: 100%"` یا پراپ `block` استفاده کن.
## ۳. قوانین ریاضی چیدمان، گرید و ریتم فضایی (Spatial & Layout Mathematics)
1. **ممنوعیت تکه‌تکه کردن فرم به کارت‌های متعدد (Anti-Card-Fragmentation):**
- خرد کردن یک فرم به چندین باکس یا کارت مجزا زیر هم ممنوع است.
- کل فیلدهای یک فرم اصلی باید در **حداکثر ۱ `<a-card :bordered="false">`** تجمیع شوند.
- برای تفکیک بخش‌های منطقی در داخل آن کارت واحد، فقط از `<a-divider orientation="right">عنوان بخش</a-divider>` استفاده کن.
2. **چیدمان استاندارد دو ستونه (فرم اصلی + پنل خلاصه):**
- ردیف اصلی صفحه: `<a-row :gutter="[24, 24]">`
- ستون اصلی (فرم/محتوا): `<a-col :xs="24" :lg="17" :xl="18">`
- ستون کناری (خلاصه وضعیت): `<a-col :xs="24" :lg="7" :xl="6">` همراه با `<a-affix :offset-top="24">`.
- محتوای پنل خلاصه باید الزاماً با `<a-descriptions :column="1" size="small" bordered>` داخل `<a-card title="..." :bordered="false">` پیاده‌سازی شود، نه با `div` و `span` دستی.
3. **قانون عرض فیلدها در فرم (Form Grid Allocation):**
- تمام فرم‌ها باید `<a-form layout="vertical" :model="...">` باشند و تمام فیلدها بدون استثنا داخل `<a-form-item label="..." name="...">` قرار گیرند.
- رها کردن فیلدهای کوتاه در عرض ۱۰۰٪ (`span="24"`) ممنوع است. چیدمان درون فرم باید با `<a-row :gutter="[16, 0]">` به شکل زیر باشد:
- **فیلدهای کوتاه** (تاریخ، ساعت، مدت زمان، مبلغ، وضعیت، نوع، کد): `<a-col :xs="24" :sm="12" :md="8">` (۳ ستونه).
- **فیلدهای متوسط** (انتخاب مشتری، مسئول انجام، فرصت مرتبط، دسته‌بندی، عنوان): `<a-col :xs="24" :md="12">` (۲ ستونه).
- **فیلدهای تمام‌عرض** (توضیحات، یادداشت، چک‌لیست، برچسب‌ها): `<a-col :span="24">`.
- **سوییچ‌ها و چک‌باکس‌های هم‌خانواده:** قرارگیری در یک ردیف با `<a-space :size="24" wrap>`.
4. **مدیریت Margin و Padding بدون CSS دستی:**
- فاصلهٔ عمودی فیلدها فقط توسط `itemMarginBottom` در کانفیگ تم `<a-form-item>` کنترل می‌شود.
- فاصلهٔ افقی/عمودی دکمه‌ها و المان‌ها فقط با `<a-space :size="8 | 12 | 16">` یا `<a-flex gap="small | middle | large" align="center" justify="space-between">` کنترل می‌شود.
## ۴. قفل سینتکس اختصاصی Ant Design Vue v4
- **بایندینگ مقدار (`v-model`):** استفاده از `v-model="..."` بدون آرگومان روی کامپوننت‌های Ant Design Vue خطاست:
- برای `a-input`, `a-textarea`, `a-input-number`, `a-select`, `a-radio-group`, `a-segmented`, `a-date-picker`, `a-time-picker`: الزاماً `v-model:value="..."`.
- برای `a-checkbox`, `a-switch`: الزاماً `v-model:checked="..."`.
- برای `a-modal`, `a-drawer`, `a-dropdown`, `a-popover`, `a-tooltip`: الزاماً `v-model:open="..."` (پراپ `:visible` منسوخ و ممنوع است).
- **الزام عرض ۱۰۰٪ روی کنترل‌های خاص:** کامپوننت‌های `<a-input-number>`, `<a-date-picker>`, `<a-time-picker>` و `<a-select>` در داخل `<a-form-item>` باید دارای `style="width: 100%"` باشند.
- **فیدبک و پیام‌ها:** برای نمایش پیام از `import { message, notification, Modal } from 'ant-design-vue'` استفاده کن.
## ۵. حاکمیت مطلق توکن‌ها و ممنوعیت CSS دستی (Zero Manual Styling & RTL Lock)
1. نوشتن کد رنگ (`#hex`, `rgb`, `rgba`, `hsl`)، سایه (`box-shadow`)، ردیوس (`border-radius`) و فاصله‌های دستی (`margin`, `padding` با `px`/`rem`) در `<style scoped>` ممنوع است.
2. تمام تنظیمات ظاهری (ردیوس، شدو، ارتفاع کنترل‌ها، پدینگ و رنگ‌ها) فقط در فایل مرکزی `apps/web/src/theme/antdTheme.ts` تعریف می‌شوند.
3. در صورت نیاز ضروری به خواندن توکن در یک کانتینر، فقط از هوک `const { token } = theme.useToken()` در `ant-design-vue` استفاده کن.
4. استفاده از `:deep(.ant-...)` برای دست‌کاری ظاهر کامپوننت‌های انت ممنوع است.
5. در RTL، استفاده از `left`, `right`, `margin-left`, `margin-right`, `padding-left`, `padding-right` ممنوع است؛ فقط از خواص منطقی (`inset-inline-start`, `inset-inline-end`, `margin-inline`, `padding-inline`) استفاده شود.
## ۶. گیت خودارزیابی پیش از تحویل کد (Self-Verification Gate)
قبل از اتمام کار روی فایلِ درخواستی، این ۵ شرط را بررسی کن:
- [ ] آیا فقط فایل درخواستی کاربر ویرایش شده و سایر صفحات دست‌نخورده مانده‌اند؟
- [ ] آیا تمام تگ‌های خام فرم، کامپوننت‌های `Ui*`، کلاس‌های `.btn`/`.field`/`.crm-*` و کلاس‌های ناموجود مثل `w-full` از این صفحه حذف شده‌اند؟
- [ ] آیا فرم در ۱ کارت واحد با `<a-divider>` و گرید `<a-row :gutter="[16, 0]">` چیده شده و فیلدهای کوتاه از حالت تمام‌عرض خارج شده‌اند؟
- [ ] آیا تمام بایندینگ‌ها از `v-model:value`، `v-model:checked` و `v-model:open` به همراه `style="width: 100%"` روی سلکت‌ها و شمارنده‌ها استفاده می‌کنند؟
- [ ] آیا `<style scoped>` کاملاً از رنگ‌های Hex، مارجین/پدینگ دستی، ردیوس، شدو و `:deep(.ant-*)` پاک‌سازی شده است؟
@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "VoipConnection" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'generic';
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenCiphertext" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenIv" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "apiTokenTag" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "fromNumber" TEXT;
ALTER TABLE "VoipConnection" ADD COLUMN "callerExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipConnection_organizationId_provider_idx" ON "VoipConnection"("organizationId", "provider");
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "VoipCallLog" ADD COLUMN "lineExtension" TEXT;
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_lineExtension_idx" ON "VoipCallLog"("organizationId", "lineExtension");
-- CreateIndex
CREATE INDEX "VoipCallLog_organizationId_status_idx" ON "VoipCallLog"("organizationId", "status");
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "FinancialPayroll" ADD COLUMN "benefitsReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "bonusReason" TEXT;
ALTER TABLE "FinancialPayroll" ADD COLUMN "deductionsReason" TEXT;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "ActivityCompletionMode" AS ENUM ('any', 'all');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "completionMode" "ActivityCompletionMode" NOT NULL DEFAULT 'any',
ADD COLUMN "completedByIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees');
-- AlterTable
ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator';
@@ -0,0 +1,28 @@
-- مسئولان اختیاری روی گروه‌های مشتری
CREATE TABLE IF NOT EXISTS "_CustomerGroupAssignees" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "_CustomerGroupAssignees_AB_unique" ON "_CustomerGroupAssignees"("A", "B");
CREATE INDEX IF NOT EXISTS "_CustomerGroupAssignees_B_index" ON "_CustomerGroupAssignees"("B");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_A_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_A_fkey"
FOREIGN KEY ("A") REFERENCES "CustomerGroup"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '_CustomerGroupAssignees_B_fkey'
) THEN
ALTER TABLE "_CustomerGroupAssignees"
ADD CONSTRAINT "_CustomerGroupAssignees_B_fkey"
FOREIGN KEY ("B") REFERENCES "User"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "activityPriorities" JSONB NOT NULL DEFAULT '{}';
+42 -2
View File
@@ -33,6 +33,17 @@ enum ActivityStatus {
canceled canceled
} }
enum ActivityCompletionMode {
any
all
}
/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin)
enum ActivityEditPolicy {
creator
assignees
}
enum ActivityPriority { enum ActivityPriority {
low low
medium medium
@@ -151,6 +162,8 @@ model Organization {
enabledCurrencies String[] @default(["IRR", "USD", "EUR"]) enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
/// ماژول‌های فعال منو برای این محیط /// ماژول‌های فعال منو برای این محیط
enabledModules String[] @default([]) enabledModules String[] @default([])
/// برچسب/رنگ اولویت فعالیت و روتین (کلیدهای enum ثابت؛ فقط ظاهر)
activityPriorities Json @default("{}")
/// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر /// تاریخ قطع بایگانی اطلاعات قبل (شروع روز انتخاب‌شده)؛ null = بدون فیلتر
priorArchiveEnabledAt DateTime? priorArchiveEnabledAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@ -295,6 +308,7 @@ model User {
ownedInvoices Invoice[] @relation("InvoiceOwner") ownedInvoices Invoice[] @relation("InvoiceOwner")
ownedPeople Person[] @relation("PersonOwner") ownedPeople Person[] @relation("PersonOwner")
ownedCompanies Company[] @relation("CompanyOwner") ownedCompanies Company[] @relation("CompanyOwner")
assignedCustomerGroups CustomerGroup[] @relation("CustomerGroupAssignees")
financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty") financialPartyDocuments FinancialDocument[] @relation("FinancialDocumentParty")
ownedProjectCards ProjectCard[] @relation("ProjectCardOwner") ownedProjectCards ProjectCard[] @relation("ProjectCardOwner")
activities Activity[] @relation("ActivityAssignee") activities Activity[] @relation("ActivityAssignee")
@@ -565,6 +579,8 @@ model CustomerGroup {
children CustomerGroup[] @relation("CustomerGroupTree") children CustomerGroup[] @relation("CustomerGroupTree")
people PersonGroupLink[] people PersonGroupLink[]
companies CompanyGroupLink[] companies CompanyGroupLink[]
/// مسئولان گروه — اختیاری؛ اعضای گروه را مثل مشتریان خود می‌بینند
assignees User[] @relation("CustomerGroupAssignees")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -883,6 +899,12 @@ model Activity {
/// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود /// زمان ارسال یادآور؛ با تغییر reminderAt پاک می‌شود
reminderSentAt DateTime? reminderSentAt DateTime?
completedAt DateTime? completedAt DateTime?
/// any = با انجام یکی بسته می‌شود؛ all = هر مسئول باید سهم خودش را بزند
completionMode ActivityCompletionMode @default(any)
/// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند
completedByIds String[] @default([])
/// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان
editPolicy ActivityEditPolicy @default(creator)
tags String[] @default([]) tags String[] @default([])
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
@@ -2447,8 +2469,11 @@ model FinancialPayroll {
period String period String
baseAmount Decimal @db.Decimal(18, 2) baseAmount Decimal @db.Decimal(18, 2)
benefits Decimal @default(0) @db.Decimal(18, 2) benefits Decimal @default(0) @db.Decimal(18, 2)
benefitsReason String?
bonus Decimal @default(0) @db.Decimal(18, 2) bonus Decimal @default(0) @db.Decimal(18, 2)
bonusReason String?
deductions Decimal @default(0) @db.Decimal(18, 2) deductions Decimal @default(0) @db.Decimal(18, 2)
deductionsReason String?
payable Decimal @db.Decimal(18, 2) payable Decimal @db.Decimal(18, 2)
dueDate DateTime dueDate DateTime
notes String? notes String?
@@ -2953,18 +2978,28 @@ model MontifyWebhookDelivery {
@@index([connectionId, createdAt]) @@index([connectionId, createdAt])
} }
/// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی) /// اتصال تلفن سازمانی / سانترال (وب‌هوک عمومی یا ارائه‌دهنده)
model VoipConnection { model VoipConnection {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
label String label String
/// generic | daftareshoma
provider String @default("generic")
enabled Boolean @default(true) enabled Boolean @default(true)
webhookSecretCiphertext String? webhookSecretCiphertext String?
webhookSecretIv String? webhookSecretIv String?
webhookSecretTag String? webhookSecretTag String?
/// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند /// قالب URL شروع تماس؛ جای {number} شماره می‌نشیند (generic)
clickToCallUrl String? clickToCallUrl String?
/// توکن Bearer API دفتر شما (رمزنگاری‌شده)
apiTokenCiphertext String?
apiTokenIv String?
apiTokenTag String?
/// شماره مبدأ در تماس خروجی دفتر شما (from_number)
fromNumber String?
/// خط/داخلی سازمان در دفتر شما (caller_extension)
callerExtension String?
lastEventAt DateTime? lastEventAt DateTime?
lastError String? lastError String?
calls VoipCallLog[] calls VoipCallLog[]
@@ -2972,6 +3007,7 @@ model VoipConnection {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([organizationId, enabled]) @@index([organizationId, enabled])
@@index([organizationId, provider])
} }
model VoipCallLog { model VoipCallLog {
@@ -2986,6 +3022,8 @@ model VoipCallLog {
status String @default("completed") status String @default("completed")
fromNumber String? fromNumber String?
toNumber String? toNumber String?
/// خط/داخلی سازمان که تماس روی آن بوده (مثلاً CalleeExtension دفتر شما)
lineExtension String?
personId String? personId String?
activityId String? activityId String?
durationSec Int? durationSec Int?
@@ -3010,6 +3048,8 @@ model VoipCallLog {
@@unique([connectionId, externalCallId]) @@unique([connectionId, externalCallId])
@@index([organizationId, syncedAt]) @@index([organizationId, syncedAt])
@@index([organizationId, lineExtension])
@@index([organizationId, status])
@@index([personId]) @@index([personId])
@@index([activityId]) @@index([activityId])
@@index([summaryStatus, summaryNextAttemptAt]) @@index([summaryStatus, summaryNextAttemptAt])
@@ -7,6 +7,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
Res, Res,
StreamableFile, StreamableFile,
@@ -40,6 +41,7 @@ import {
ReorderActivityTypesDto, ReorderActivityTypesDto,
UpdateActivityBoardDto, UpdateActivityBoardDto,
UpdateActivityDto, UpdateActivityDto,
UpdateActivityPrioritiesDto,
UpdateActivityStageDto, UpdateActivityStageDto,
UpdateActivityTypeDto, UpdateActivityTypeDto,
} from './dto'; } from './dto';
@@ -139,6 +141,18 @@ export class ActivitiesController {
return { ok: true }; return { ok: true };
} }
@Get('priorities')
listPriorities(@CurrentUser() user: Actor) {
return this.typesService.listPriorities(user.organizationId);
}
@Put('priorities')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
updatePriorities(@CurrentUser() user: Actor, @Body() dto: UpdateActivityPrioritiesDto) {
return this.typesService.updatePriorities(user.organizationId, dto.items);
}
@Get('types') @Get('types')
listTypes(@CurrentUser() user: Actor) { listTypes(@CurrentUser() user: Actor) {
return this.typesService.list(user.organizationId); return this.typesService.list(user.organizationId);
@@ -438,8 +452,16 @@ export class ActivitiesController {
} }
@Patch(':id/complete') @Patch(':id/complete')
complete(@CurrentUser() user: Actor, @Param('id') id: string) { complete(
return this.activitiesService.complete(user.organizationId, id, user); @CurrentUser() user: Actor,
@Param('id') id: string,
@Query('force') force?: string,
@Query('undo') undo?: string,
) {
return this.activitiesService.complete(user.organizationId, id, user, {
force: force === '1' || force === 'true',
undo: undo === '1' || undo === 'true',
});
} }
@Delete(':id') @Delete(':id')
+279 -29
View File
@@ -52,6 +52,65 @@ type ActivityActor = Pick<
'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds' 'userId' | 'role' | 'priorLens' | 'priorCutoff' | 'leadsAccess' | 'teamPeerIds'
>; >;
function assigneeIdList(row: {
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
}): string[] {
const ids = row.assignees?.map((item) => item.id) ?? [];
if (ids.length) return [...new Set(ids)];
return row.assigneeId ? [row.assigneeId] : [];
}
function canForceClose(
actor: { userId: string; role: string },
createdById: string | null | undefined,
): boolean {
return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById;
}
function canMutateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
editPolicy?: 'creator' | 'assignees' | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
if ((row.editPolicy ?? 'creator') !== 'assignees') return false;
return assigneeIdList(row).includes(actor.userId);
}
/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */
function canCollaborateActivity(
actor: { userId: string; role: string },
row: {
createdById?: string | null;
assigneeId?: string | null;
assignees?: Array<{ id: string }>;
},
): boolean {
if (actor.role === 'owner' || actor.role === 'admin') return true;
if (row.createdById && actor.userId === row.createdById) return true;
return assigneeIdList(row).includes(actor.userId);
}
/** فقط dueAt / description → تعویق و ثبت نظر */
function isCollaboratorPatch(dto: UpdateActivityRequest): boolean {
const keys = Object.keys(dto).filter(
(key) => (dto as Record<string, unknown>)[key] !== undefined,
);
return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description');
}
function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
return { status: done ? ('done' as const) : ('open' as const), completedByIds: kept };
}
/** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */ /** stageId = شناسهٔ مرحله یا 'all'؛ وقتی ست باشد فیلتر زمانی وضعیت را تحمیل نمی‌کند (جز عقب‌افتاده) */
type StageScope = { stageId: string }; type StageScope = { stageId: string };
@@ -82,6 +141,9 @@ type ActivityRow = {
reminderNotifySms: boolean; reminderNotifySms: boolean;
reminderSentAt: Date | null; reminderSentAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
completionMode?: 'any' | 'all';
completedByIds?: string[];
editPolicy?: 'creator' | 'assignees';
importSource?: string; importSource?: string;
promotedFromPriorAt?: Date | null; promotedFromPriorAt?: Date | null;
tags: string[]; tags: string[];
@@ -186,22 +248,39 @@ export class ActivitiesService {
return { stages, types }; return { stages, types };
} }
/** کارهای بازِ «من» که سهم‌شان ثبت شده، از میز کار شخص خارج می‌شوند. */
private deskScope(
actor: ActivityActor,
desk?: 'mine' | 'team',
): Prisma.ActivityWhereInput {
if (desk !== 'mine') return activityAccessWhere(actor);
return {
AND: [
{
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
},
{
NOT: {
completionMode: 'all',
status: 'open',
completedByIds: { has: actor.userId },
},
},
],
};
}
async summary( async summary(
organizationId: string, organizationId: string,
actor: ActivityActor, actor: ActivityActor,
desk?: 'mine' | 'team', desk?: 'mine' | 'team',
): Promise<ActivitySummaryDto> { ): Promise<ActivitySummaryDto> {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const prior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const [todayCount, overdueCount] = await Promise.all([ const [todayCount, overdueCount] = await Promise.all([
this.prisma.activity.count({ this.prisma.activity.count({
@@ -534,8 +613,24 @@ export class ActivitiesService {
const stage = await this.stages.get(organizationId, stageId); const stage = await this.stages.get(organizationId, stageId);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true, completedAt: true }, select: {
status: true,
completedAt: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (
stage.kind === 'done' &&
existing?.completionMode === 'all' &&
assigneeIdList(existing).length > 1 &&
!canForceClose(actor, existing.createdById)
) {
return this.complete(organizationId, id, actor);
}
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { data: {
@@ -550,6 +645,9 @@ export class ActivitiesService {
: stage.kind === 'open' : stage.kind === 'open'
? null ? null
: undefined, : undefined,
...(stage.kind === 'open' && existing?.status !== 'open'
? { completedByIds: [] }
: {}),
}, },
include, include,
}); });
@@ -675,6 +773,9 @@ export class ActivitiesService {
assignees: assigneeIds.length assignees: assigneeIds.length
? { connect: assigneeIds.map((id) => ({ id })) } ? { connect: assigneeIds.map((id) => ({ id })) }
: undefined, : undefined,
completionMode: dto.completionMode === 'all' ? 'all' : 'any',
completedByIds: [],
editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator',
createdById: userId, createdById: userId,
}, },
include, include,
@@ -743,8 +844,31 @@ export class ActivitiesService {
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true, type: true, typeDefId: true, fields: true, participantIds: true }, select: {
status: true,
type: true,
typeDefId: true,
fields: true,
participantIds: true,
completionMode: true,
completedByIds: true,
editPolicy: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (!existing) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const collabOnly = isCollaboratorPatch(dto);
if (collabOnly) {
if (!canCollaborateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید');
}
} else if (!canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
}
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim(); if (dto.title !== undefined) data.title = dto.title.trim();
if ('boardId' in dto) { if ('boardId' in dto) {
@@ -812,6 +936,7 @@ export class ActivitiesService {
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null); if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null); if ('leadId' in dto) data.leadId = emptyToNull(dto.leadId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined; let assigneeConnect: Array<{ id: string }> | undefined;
let nextAssigneeIds: string[] | undefined;
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) { if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({ const current = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -839,6 +964,7 @@ export class ActivitiesService {
); );
data.assigneeId = assigneeIds[0]!; data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid })); assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
nextAssigneeIds = assigneeIds;
} else { } else {
const assigneeId = emptyToNull(dto.assigneeId ?? null); const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId); if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
@@ -852,12 +978,44 @@ export class ActivitiesService {
data.assigneeId = assigneeId; data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }]; if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = []; else assigneeConnect = [];
nextAssigneeIds = assigneeId ? [assigneeId] : [];
}
}
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
if (modeChanged) data.completionMode = nextMode;
if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') {
data.editPolicy = dto.editPolicy;
}
const prevAssigneeIds = assigneeIdList(existing ?? {});
const nextIds = nextAssigneeIds ?? prevAssigneeIds;
const explicitDone = dto.status === 'done';
const explicitReopen = dto.status === 'open' && existing?.status !== 'open';
const explicitCancel = dto.status === 'canceled';
if (explicitDone) {
data.status = 'done';
data.completedAt = new Date();
} else if (explicitReopen) {
data.status = 'open';
data.completedAt = null;
data.completedByIds = [];
} else if (explicitCancel) {
data.status = 'canceled';
} else if (nextMode === 'any' && modeChanged) {
data.completedByIds = [];
} else if (nextMode === 'all' && (modeChanged || nextAssigneeIds)) {
const reconciled = reconcileAllShares(nextIds, existing?.completedByIds ?? []);
data.completedByIds = reconciled.completedByIds;
const finishedStaysFinished = existing?.status === 'done' && !nextAssigneeIds;
if (existing?.status !== 'canceled' && !finishedStaysFinished) {
if (reconciled.status === 'done' && existing?.status !== 'done') {
data.status = 'done';
data.completedAt = new Date();
} else if (reconciled.status === 'open' && existing?.status === 'done') {
data.status = 'open';
data.completedAt = null;
} }
} }
if (dto.status !== undefined) {
data.status = dto.status;
data.completedAt =
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
} }
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
@@ -881,7 +1039,7 @@ export class ActivitiesService {
addedParticipants.filter((uid) => !assigneeSet.has(uid)), addedParticipants.filter((uid) => !assigneeSet.has(uid)),
); );
} }
if (dto.status === 'done' && existing?.status !== 'done') { if (data.status === 'done' && existing?.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -891,18 +1049,74 @@ export class ActivitiesService {
organizationId: string, organizationId: string,
id: string, id: string,
actor: ActivityActor, actor: ActivityActor,
options: { force?: boolean; undo?: boolean } = {},
): Promise<ActivityDto> { ): Promise<ActivityDto> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({ const existing = await this.prisma.activity.findFirst({
where: { id, organizationId }, where: { id, organizationId },
select: { status: true }, select: {
status: true,
completionMode: true,
completedByIds: true,
createdById: true,
assigneeId: true,
assignees: { select: { id: true } },
},
}); });
if (!existing) throw new NotFoundException('فعالیت پیدا نشد');
if (existing.status === 'canceled') {
throw new BadRequestException('فعالیت لغو شده است');
}
const assignees = assigneeIdList(existing);
const allowedForce = canForceClose(actor, existing.createdById);
if (options.force && !allowedForce) {
throw new ForbiddenException('بستن فعالیت برای همه فقط برای سازنده یا مدیر ممکن است');
}
if (options.undo) {
if (existing.completionMode !== 'all' || existing.status !== 'open') {
throw new BadRequestException('سهم انجام‌شده را فقط تا وقتی فعالیت باز است می‌توان لغو کرد');
}
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: existing.completedByIds.filter((uid) => uid !== actor.userId),
},
include,
});
return this.toDto(row, await this.ctx(organizationId));
}
const personal =
existing.completionMode === 'all' && assignees.length > 1 && !options.force;
if (!personal) {
const row = await this.prisma.activity.update({ const row = await this.prisma.activity.update({
where: { id }, where: { id },
data: { status: 'done', completedAt: new Date() }, data: { status: 'done', completedAt: new Date() },
include, include,
}); });
if (existing?.status !== 'done') { if (existing.status !== 'done') await this.fireDone(organizationId, row);
return this.toDto(row, await this.ctx(organizationId));
}
if (!assignees.includes(actor.userId)) {
throw new ForbiddenException('فقط مسئول این فعالیت می‌تواند سهم خودش را ثبت کند');
}
const reconciled = reconcileAllShares(assignees, [
...existing.completedByIds,
actor.userId,
]);
const row = await this.prisma.activity.update({
where: { id },
data: {
completedByIds: reconciled.completedByIds,
status: reconciled.status,
completedAt: reconciled.status === 'done' ? new Date() : null,
},
include,
});
if (reconciled.status === 'done' && existing.status !== 'done') {
await this.fireDone(organizationId, row); await this.fireDone(organizationId, row);
} }
return this.toDto(row, await this.ctx(organizationId)); return this.toDto(row, await this.ctx(organizationId));
@@ -946,6 +1160,18 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, id, actor); await this.ensureExists(organizationId, id, actor);
const existing = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
if (!existing || !canMutateActivity(actor, existing)) {
throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید');
}
const images = await this.prisma.activityImage.findMany({ const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId }, where: { activityId: id, organizationId },
select: { storageKey: true, sizeBytes: true }, select: { storageKey: true, sizeBytes: true },
@@ -966,6 +1192,19 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<ActivityImageDto> { ): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor); await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
// پیوست در مسیر ثبت نظر — مسئولان هم مجازند حتی با editPolicy=creator
if (!gate || !canCollaborateActivity(actor, gate)) {
throw new ForbiddenException('شما مجوز ثبت نظر یا پیوست برای این فعالیت را ندارید');
}
if (!file?.buffer?.length) { if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است'); throw new BadRequestException('فایلی ارسال نشده است');
} }
@@ -1060,10 +1299,27 @@ export class ActivitiesService {
actor: ActivityActor, actor: ActivityActor,
): Promise<void> { ): Promise<void> {
await this.ensureExists(organizationId, activityId, actor); await this.ensureExists(organizationId, activityId, actor);
const gate = await this.prisma.activity.findFirst({
where: { id: activityId, organizationId },
select: {
createdById: true,
editPolicy: true,
assigneeId: true,
assignees: { select: { id: true } },
},
});
const row = await this.prisma.activityImage.findFirst({ const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId }, where: { id: imageId, activityId, organizationId },
}); });
if (!row) throw new NotFoundException('فایل پیدا نشد'); if (!row) throw new NotFoundException('فایل پیدا نشد');
const canMutate = !!gate && canMutateActivity(actor, gate);
const canRemoveOwn =
!!gate &&
canCollaborateActivity(actor, gate) &&
row.uploadedById === actor.userId;
if (!canMutate && !canRemoveOwn) {
throw new ForbiddenException('شما مجوز حذف این پیوست را ندارید');
}
await this.files.remove(row.storageKey); await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } }); await this.prisma.activityImage.delete({ where: { id: row.id } });
await this.storage.addLocal(organizationId, -row.sizeBytes); await this.storage.addLocal(organizationId, -row.sizeBytes);
@@ -1108,16 +1364,7 @@ export class ActivitiesService {
typeRows: TypeRow[] = [], typeRows: TypeRow[] = [],
): Prisma.ActivityWhereInput { ): Prisma.ActivityWhereInput {
const { start, end } = dayBounds(); const { start, end } = dayBounds();
const scope: Prisma.ActivityWhereInput = const scope = this.deskScope(actor, desk);
desk === 'mine'
? {
OR: [
{ assigneeId: actor.userId },
{ assignees: { some: { id: actor.userId } } },
{ participantIds: { has: actor.userId } },
],
}
: activityAccessWhere(actor);
const facetAssignee = facets?.assigneeId?.trim(); const facetAssignee = facets?.assigneeId?.trim();
const facetTypeDef = facets?.typeDefId?.trim(); const facetTypeDef = facets?.typeDefId?.trim();
const facetTitle = facets?.title?.trim(); const facetTitle = facets?.title?.trim();
@@ -1418,6 +1665,9 @@ export class ActivitiesService {
reminderNotifySms: row.reminderNotifySms, reminderNotifySms: row.reminderNotifySms,
reminderSentAt: row.reminderSentAt?.toISOString() ?? null, reminderSentAt: row.reminderSentAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null, completedAt: row.completedAt?.toISOString() ?? null,
completionMode: row.completionMode ?? 'any',
completedByIds: row.completedByIds ?? [],
editPolicy: row.editPolicy ?? 'creator',
importSource: row.importSource ?? 'manual', importSource: row.importSource ?? 'manual',
promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null,
tags: row.tags ?? [], tags: row.tags ?? [],
@@ -6,6 +6,8 @@ import type {
ActivityFieldValue, ActivityFieldValue,
ActivityFieldValues, ActivityFieldValues,
ActivityParticipantsValue, ActivityParticipantsValue,
ActivityPriority,
ActivityPrioritySetting,
ActivityStageColor, ActivityStageColor,
ActivityType, ActivityType,
ActivityTypeDefDto, ActivityTypeDefDto,
@@ -17,8 +19,11 @@ import type {
import { import {
ACTIVITY_FIELD_TYPES, ACTIVITY_FIELD_TYPES,
ACTIVITY_FIELD_TYPES_WITH_OPTIONS, ACTIVITY_FIELD_TYPES_WITH_OPTIONS,
ACTIVITY_PRIORITY_KEYS,
ACTIVITY_STAGE_COLORS, ACTIVITY_STAGE_COLORS,
ACTIVITY_TYPE_ICONS, ACTIVITY_TYPE_ICONS,
DEFAULT_ACTIVITY_PRIORITY_SETTINGS,
parseActivityPrioritySettings,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@@ -619,6 +624,43 @@ export class ActivityTypesService {
: 'green'; : 'green';
} }
async listPriorities(organizationId: string): Promise<ActivityPrioritySetting[]> {
const row = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { activityPriorities: true },
});
return parseActivityPrioritySettings(row?.activityPriorities);
}
async updatePriorities(
organizationId: string,
items: Array<{ key: ActivityPriority; label: string; color: string }>,
): Promise<ActivityPrioritySetting[]> {
const byKey = new Map(items.map((i) => [i.key, i]));
if (ACTIVITY_PRIORITY_KEYS.some((k) => !byKey.has(k))) {
throw new BadRequestException('هر پنج سطح اولویت باید ارسال شوند');
}
const hex = /^#[0-9a-fA-F]{6}$/;
const stored: Record<string, { label: string; color: string }> = {};
for (const key of ACTIVITY_PRIORITY_KEYS) {
const item = byKey.get(key)!;
const label = item.label.trim().slice(0, 40);
const color = item.color.trim();
if (!label) throw new BadRequestException(`عنوان اولویت «${key}» خالی است`);
if (!hex.test(color)) throw new BadRequestException(`رنگ اولویت «${key}» نامعتبر است`);
const def = DEFAULT_ACTIVITY_PRIORITY_SETTINGS.find((d) => d.key === key)!;
// فقط تفاوت با پیش‌فرض را نگه می‌داریم تا JSON جمع‌وجور بماند
if (label !== def.label || color.toLowerCase() !== def.color.toLowerCase()) {
stored[key] = { label, color };
}
}
await this.prisma.organization.update({
where: { id: organizationId },
data: { activityPriorities: stored as Prisma.InputJsonValue },
});
return parseActivityPrioritySettings(stored);
}
private mapError(e: unknown) { private mapError(e: unknown) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
return new BadRequestException('نوع فعالیتی با این نام وجود دارد'); return new BadRequestException('نوع فعالیتی با این نام وجود دارد');
+41
View File
@@ -9,11 +9,13 @@ import {
IsObject, IsObject,
IsOptional, IsOptional,
IsString, IsString,
Matches,
Max, Max,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared'; import type { ActivityFieldDef, ActivityFieldValues, ActivityTypeIcon } from '@mento/shared';
import { ACTIVITY_TYPE_ICONS } from '@mento/shared'; import { ACTIVITY_TYPE_ICONS } from '@mento/shared';
@@ -384,6 +386,14 @@ export class CreateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
} }
export class UpdateActivityDto { export class UpdateActivityDto {
@@ -484,4 +494,35 @@ export class UpdateActivityDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
assigneeIds?: string[]; assigneeIds?: string[];
@IsOptional()
@IsIn(['any', 'all'])
completionMode?: 'any' | 'all';
@IsOptional()
@IsIn(['creator', 'assignees'])
editPolicy?: 'creator' | 'assignees';
}
const PRIORITY_KEYS = ['very_low', 'low', 'medium', 'high', 'very_high'] as const;
export class ActivityPriorityItemDto {
@IsIn(PRIORITY_KEYS)
key!: (typeof PRIORITY_KEYS)[number];
@IsString()
@MinLength(1)
@MaxLength(40)
label!: string;
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/)
color!: string;
}
export class UpdateActivityPrioritiesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ActivityPriorityItemDto)
items!: ActivityPriorityItemDto[];
} }
+11 -3
View File
@@ -19,6 +19,10 @@ import type {
StorageUsageDto, StorageUsageDto,
SubscriptionSummaryDto, SubscriptionSummaryDto,
} from '@mento/shared'; } from '@mento/shared';
import {
parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
} from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
/** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */ /** Ensures Express.Multer.File augmentation is loaded for saveAvatar. */
import type {} from 'multer'; import type {} from 'multer';
@@ -848,6 +852,9 @@ export class AuthService {
}, },
}); });
const role = membership?.role ?? user.role; const role = membership?.role ?? user.role;
const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.filter((item) => typeof item === 'string')
: [];
const orgName = membership?.organization.name; const orgName = membership?.organization.name;
const sub = const sub =
subscription ?? subscription ??
@@ -879,9 +886,10 @@ export class AuthService {
storage, storage,
isOrgOwner: role === 'owner', isOrgOwner: role === 'owner',
priorArchive: this.priorArchiveFor(membership), priorArchive: this.priorArchiveFor(membership),
permissions: (Array.isArray(membership?.orgRole?.permissions) permissions: permissionsWithTreasuryOverride(
? membership.orgRole.permissions.filter((item) => typeof item === 'string') rolePermissions,
: []) as AuthUserDto['permissions'], parseUserTreasuryAccess(membership?.treasuryAccessOverride),
) as AuthUserDto['permissions'],
...(viewAs ? { viewAs } : {}), ...(viewAs ? { viewAs } : {}),
}); });
} }
+4
View File
@@ -195,6 +195,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
const peers = await this.prisma.teamMember.findMany({ const peers = await this.prisma.teamMember.findMany({
where: { where: {
teamId: { in: memberships.map((m) => m.teamId) }, teamId: { in: memberships.map((m) => m.teamId) },
team: { organizationId },
user: {
memberships: { some: { organizationId, isActive: true } },
},
}, },
select: { userId: true }, select: { userId: true },
}); });
+34 -1
View File
@@ -21,6 +21,9 @@ import {
defaultEnabledModules, defaultEnabledModules,
isPermanentBillingPlan, isPermanentBillingPlan,
isOrgModuleKey, isOrgModuleKey,
modulesForProductPlan,
PRODUCT_PLAN_LIMITS,
type ProductPlanCode,
} from '@mento/shared'; } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PlatformAdminAlertService } from './platform-admin-alert.service'; import { PlatformAdminAlertService } from './platform-admin-alert.service';
@@ -414,7 +417,37 @@ export class BillingService {
} }
defaultModulesForNewOrg(): OrgModuleKey[] { defaultModulesForNewOrg(): OrgModuleKey[] {
return defaultEnabledModules(); return modulesForProductPlan('core');
}
/** سطح امکانات را روی همهٔ محیط‌های مالک می‌گذارد و سقف هسته/تیم/سازمان را هم تنظیم می‌کند. */
async applyProductPlan(organizationId: string, code: ProductPlanCode) {
const modules = modulesForProductPlan(code);
const owner = await this.prisma.membership.findFirst({
where: { organizationId, role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
select: { userId: true },
});
const orgIds = owner ? await this.ownedOrganizationIds(owner.userId) : [organizationId];
for (const id of orgIds) {
await this.setOrgModules(id, modules);
}
const limits = PRODUCT_PLAN_LIMITS[code];
if (owner && (limits.maxUsers != null || limits.maxOrganizations != null)) {
const account = await this.getByOwnerUserId(owner.userId);
if (account) {
await this.prisma.billingAccount.update({
where: { id: account.id },
data: {
...(limits.maxUsers != null ? { maxUsers: limits.maxUsers } : {}),
...(limits.maxOrganizations != null
? { maxOrganizations: limits.maxOrganizations }
: {}),
},
});
}
}
return modules;
} }
async countExpiringSoon() { async countExpiringSoon() {
+106 -77
View File
@@ -105,29 +105,33 @@ export function resolveAllSectionAccess(input: {
return out; return out;
} }
/** ابعاد پرشدهٔ یک قانون با OR؛ خالی = null */
function unionFacets<T>(facets: T[]): T | { OR: T[] } | null {
if (!facets.length) return null;
return facets.length === 1 ? facets[0]! : { OR: facets };
}
export function buildContactRuleWhere( export function buildContactRuleWhere(
rule: DataAccessRule, rule: DataAccessRule,
ctx: { userId: string; teamPeerIds: string[] | null }, ctx: { userId: string; teamPeerIds: string[] | null },
): Prisma.PersonWhereInput | null { ): Prisma.PersonWhereInput | null {
const and: Prisma.PersonWhereInput[] = []; const facets: Prisma.PersonWhereInput[] = [];
const ids = ownerIdList(rule, ctx);
if (rule.owners?.length) { if (rule.owners?.length) {
if (!ids.length) return null; const ids = ownerIdList(rule, ctx);
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); if (ids.length) {
facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
} }
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (personViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if (rule.moods?.length) { if (rule.moods?.length) {
and.push({ mood: { in: rule.moods } }); facets.push({ mood: { in: rule.moods } });
} }
if (!and.length) return null; return unionFacets(facets);
return and.length === 1 ? and[0]! : { AND: and };
} }
function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } { function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
@@ -135,6 +139,23 @@ function orParts<T>(parts: T[]): T | { OR: T[] } | { id: string } {
return parts.length === 1 ? parts[0]! : { OR: parts }; return parts.length === 1 ? parts[0]! : { OR: parts };
} }
/** اعضای گروه‌هایی که کاربر مسئولشان است */
function personViaGroupAssignee(userId: string): Prisma.PersonWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
function companyViaGroupAssignee(userId: string): Prisma.CompanyWhereInput {
return {
groupLinks: {
some: { group: { assignees: { some: { id: userId } } } },
},
};
}
export function customersPersonWhere( export function customersPersonWhere(
actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>, actor: Pick<Actor, 'userId' | 'role' | 'customersAccess' | 'teamPeerIds'>,
): Prisma.PersonWhereInput { ): Prisma.PersonWhereInput {
@@ -149,6 +170,8 @@ export function customersPersonWhere(
}); });
if (w) parts.push(w); if (w) parts.push(w);
} }
// مسئول گروه: اتحاد با محدودیت نقش (مالک خودم یا عضو گروه تحت مسئولیت)
parts.push(personViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -160,34 +183,28 @@ export function customersCompanyWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.CompanyWhereInput[] = []; const parts: Prisma.CompanyWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.CompanyWhereInput[] = []; // حال مشتری روی شرکت معنا ندارد؛ فقط مالک/منبع
if (rule.moods?.length && !rule.owners?.length && !rule.sources?.length) continue;
const facets: Prisma.CompanyWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } }); facets.push(ids.length === 1 ? { ownerId: ids[0]! } : { ownerId: { in: ids } });
} }
if (rule.groupIds?.length) {
and.push({ groupLinks: { some: { groupId: { in: rule.groupIds } } } });
} }
// groupIds دیگر فیلتر دسترسی نیست؛ دید گروه فقط از مسئول‌گروه (companyViaGroupAssignee)
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }], OR: [{ importSource: { in: rule.sources } }, { sourceRef: { in: rule.sources } }],
}); });
} }
if ( const w = unionFacets(facets);
rule.moods?.length && if (w) parts.push(w);
!rule.owners?.length &&
!rule.groupIds?.length &&
!rule.sources?.length
) {
continue;
}
if (!and.length) continue;
parts.push(and.length === 1 ? and[0]! : { AND: and });
} }
parts.push(companyViaGroupAssignee(actor.userId));
return orParts(parts); return orParts(parts);
} }
@@ -199,27 +216,28 @@ export function leadsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.LeadWhereInput[] = []; const parts: Prisma.LeadWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.LeadWhereInput[] = []; const facets: Prisma.LeadWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
OR: rule.sources.map((s) => ({ OR: rule.sources.map((s) => ({
source: { equals: s, mode: 'insensitive' as const }, source: { equals: s, mode: 'insensitive' as const },
})), })),
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -232,28 +250,29 @@ export function dealsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.DealWhereInput[] = []; const parts: Prisma.DealWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.DealWhereInput[] = []; const facets: Prisma.DealWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
} }
}
if (rule.pipelineIds?.length) { if (rule.pipelineIds?.length) {
and.push({ pipelineId: { in: rule.pipelineIds } }); facets.push({ pipelineId: { in: rule.pipelineIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -266,14 +285,14 @@ export function activitiesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ActivityWhereInput[] = []; const parts: Prisma.ActivityWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ActivityWhereInput[] = []; const facets: Prisma.ActivityWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
@@ -281,17 +300,18 @@ export function activitiesDeepWhere(
], ],
}); });
} }
}
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ typeDefId: { in: rule.typeIds } }); facets.push({ typeDefId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -310,14 +330,14 @@ export function notesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return personalGate; if (!sectionHasDataAccessLimits(section)) return personalGate;
const parts: Prisma.NoteWhereInput[] = []; const parts: Prisma.NoteWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.NoteWhereInput[] = []; const facets: Prisma.NoteWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ authorId: { in: ids } }, { authorId: { in: ids } },
{ {
@@ -333,11 +353,12 @@ export function notesDeepWhere(
], ],
}); });
} }
if (rule.sources?.length) {
and.push({ noteType: { in: rule.sources } });
} }
if (!and.length) continue; if (rule.sources?.length) {
parts.push(and.length === 1 ? and[0]! : { AND: and }); facets.push({ noteType: { in: rule.sources } });
}
const w = unionFacets(facets);
if (w) parts.push(w);
} }
return { return {
AND: [personalGate, orParts(parts)], AND: [personalGate, orParts(parts)],
@@ -352,26 +373,27 @@ export function ticketsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.TicketWhereInput[] = []; const parts: Prisma.TicketWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.TicketWhereInput[] = []; const facets: Prisma.TicketWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }], OR: [{ assigneeId: { in: ids } }, { reporterId: { in: ids } }],
}); });
} }
}
if (rule.sources?.length) { if (rule.sources?.length) {
and.push({ facets.push({
status: { status: {
in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>, in: rule.sources as Array<'open' | 'in_progress' | 'waiting' | 'resolved' | 'closed'>,
}, },
}); });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -384,28 +406,29 @@ export function projectsDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.ProjectCardWhereInput[] = []; const parts: Prisma.ProjectCardWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.ProjectCardWhereInput[] = []; const facets: Prisma.ProjectCardWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push({ facets.push({
OR: [ OR: [
{ ownerId: { in: ids } }, { ownerId: { in: ids } },
{ assignees: { some: { id: { in: ids } } } }, { assignees: { some: { id: { in: ids } } } },
], ],
}); });
} }
}
if (rule.boardIds?.length) { if (rule.boardIds?.length) {
and.push({ boardId: { in: rule.boardIds } }); facets.push({ boardId: { in: rule.boardIds } });
} }
if (rule.stageIds?.length) { if (rule.stageIds?.length) {
and.push({ stageId: { in: rule.stageIds } }); facets.push({ stageId: { in: rule.stageIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -418,20 +441,21 @@ export function routinesDeepWhere(
if (!sectionHasDataAccessLimits(section)) return {}; if (!sectionHasDataAccessLimits(section)) return {};
const parts: Prisma.RoutineTaskWhereInput[] = []; const parts: Prisma.RoutineTaskWhereInput[] = [];
for (const rule of section.rules ?? []) { for (const rule of section.rules ?? []) {
const and: Prisma.RoutineTaskWhereInput[] = []; const facets: Prisma.RoutineTaskWhereInput[] = [];
if (rule.owners?.length) { if (rule.owners?.length) {
const ids = ownerIdList(rule, { const ids = ownerIdList(rule, {
userId: actor.userId, userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null, teamPeerIds: actor.teamPeerIds ?? null,
}); });
if (!ids.length) continue; if (ids.length) {
and.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } }); facets.push(ids.length === 1 ? { assigneeId: ids[0]! } : { assigneeId: { in: ids } });
}
} }
if (rule.typeIds?.length) { if (rule.typeIds?.length) {
and.push({ categoryId: { in: rule.typeIds } }); facets.push({ categoryId: { in: rule.typeIds } });
} }
if (!and.length) continue; const w = unionFacets(facets);
parts.push(and.length === 1 ? and[0]! : { AND: and }); if (w) parts.push(w);
} }
return orParts(parts); return orParts(parts);
} }
@@ -449,9 +473,14 @@ export function sectionOwnerIdUnion(
if (rule.owners?.length) { if (rule.owners?.length) {
sawOwnerRule = true; sawOwnerRule = true;
for (const id of ownerIdList(rule, ctx)) ids.add(id); for (const id of ownerIdList(rule, ctx)) ids.add(id);
} else if (rule.groupIds?.length || rule.sources?.length || rule.moods?.length || } else if (
rule.stageIds?.length || rule.pipelineIds?.length || rule.boardIds?.length || rule.sources?.length ||
rule.typeIds?.length) { rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length
) {
sawNonOwnerRule = true; sawNonOwnerRule = true;
} }
} }
+78 -6
View File
@@ -5,7 +5,7 @@ import type {
SectionDataAccess, SectionDataAccess,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { DATA_SCOPES, canViewAllRecords } from '@mento/shared'; import { DATA_SCOPES, canViewAllRecords, sectionHasDataAccessLimits } from '@mento/shared';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { import {
activitiesDeepWhere, activitiesDeepWhere,
@@ -13,6 +13,7 @@ import {
notesDeepWhere, notesDeepWhere,
projectsDeepWhere, projectsDeepWhere,
routinesDeepWhere, routinesDeepWhere,
sectionOwnerIdUnion,
ticketsDeepWhere, ticketsDeepWhere,
} from './data-access'; } from './data-access';
@@ -60,6 +61,65 @@ export type Actor = {
export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>; export type ScopedActor = Pick<Actor, 'userId' | 'role' | 'dataVisibility' | 'visibleUserIds' | 'scopes'>;
type DeepAccessActor = ScopedActor &
Partial<
Pick<
Actor,
| 'customersAccess'
| 'leadsAccess'
| 'salesAccess'
| 'activitiesAccess'
| 'notesAccess'
| 'routinesAccess'
| 'projectsAccess'
| 'ticketsAccess'
| 'teamPeerIds'
>
>;
function sectionForScope(actor: DeepAccessActor, scope: DataScope): SectionDataAccess | undefined {
switch (scope) {
case 'contacts':
return actor.customersAccess;
case 'leads':
return actor.leadsAccess;
case 'deals':
return actor.salesAccess;
case 'activities':
return actor.activitiesAccess;
case 'notes':
return actor.notesAccess;
case 'routines':
return actor.routinesAccess;
case 'projects':
return actor.projectsAccess;
case 'tickets':
return actor.ticketsAccess;
default:
return undefined;
}
}
/**
* شناسه‌های مالک از قانون عمق داده.
* legacy = بخش عمیق روی این Actor نیست.
* open = محدودیت مالکی ندارد (همه، یا فقط فیلترهای غیرمالک).
*/
function deepOwnerIds(
actor: DeepAccessActor,
scope: DataScope,
): { kind: 'legacy' } | { kind: 'open' } | { kind: 'ids'; ids: string[] } {
const section = sectionForScope(actor, scope);
if (!section) return { kind: 'legacy' };
if (!sectionHasDataAccessLimits(section)) return { kind: 'open' };
const ids = sectionOwnerIdUnion(section, {
userId: actor.userId,
teamPeerIds: actor.teamPeerIds ?? null,
});
if (ids === null) return { kind: 'open' };
return { kind: 'ids', ids };
}
/** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */ /** Actor با سطح دید مخصوص یک بخش (اگر تنظیم شده باشد). */
export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T { export function forScope<T extends ScopedActor>(actor: T, scope: DataScope): T {
const s = actor.scopes?.[scope]; const s = actor.scopes?.[scope];
@@ -109,9 +169,16 @@ export function scopedOwnerIds(
/** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */ /** فیلتر مالکیت؛ پیش‌فرض بخش «مشتریان» (Person / Company). برای فاکتور/پیش‌فاکتور scope=finance. */
export function ownerWhere( export function ownerWhere(
actor: ScopedActor, actor: DeepAccessActor,
scope: DataScope = 'contacts', scope: DataScope = 'contacts',
): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> { ): { ownerId: string } | { ownerId: { in: string[] } } | Record<string, never> {
const deep = deepOwnerIds(actor, scope);
if (deep.kind === 'open') return {};
if (deep.kind === 'ids') {
if (deep.ids.length === 1) return { ownerId: deep.ids[0]! };
if (!deep.ids.length) return { ownerId: '__no_access__' };
return { ownerId: { in: deep.ids } };
}
const ids = scopedOwnerIds(actor, scope); const ids = scopedOwnerIds(actor, scope);
if (!ids) return {}; if (!ids) return {};
if (ids.length === 1) return { ownerId: ids[0]! }; if (ids.length === 1) return { ownerId: ids[0]! };
@@ -153,11 +220,16 @@ export function projectCardAccessWhere(
} }
/** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */ /** فعالیت‌ها بر اساس مسئول اصلی یا مسئولان مشترک */
export function assigneeWhere( export function assigneeWhere(actor: DeepAccessActor): Prisma.ActivityWhereInput {
actor: ScopedActor, const deep = deepOwnerIds(actor, 'activities');
): Prisma.ActivityWhereInput { const ids =
const ids = scopedOwnerIds(actor, 'activities'); deep.kind === 'open'
? null
: deep.kind === 'ids'
? deep.ids
: scopedOwnerIds(actor, 'activities');
if (!ids) return {}; if (!ids) return {};
if (!ids.length) return { assigneeId: '__no_access__' };
return { return {
OR: [ OR: [
{ assigneeId: { in: ids } }, { assigneeId: { in: ids } },
+1
View File
@@ -60,6 +60,7 @@ export function selectMonthBirthdays(people: BirthdayRow[], now = new Date(), pr
const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay); const upcoming = matched.filter((row) => row.day >= today.jd).sort(byDay);
const passed = matched.filter((row) => row.day < today.jd).sort(byDay); const passed = matched.filter((row) => row.day < today.jd).sort(byDay);
return { return {
year: today.jy,
month: today.jm, month: today.jm,
monthName: jalaliMonthName(today.jm), monthName: jalaliMonthName(today.jm),
today: today.jd, today: today.jd,
+6 -1
View File
@@ -112,9 +112,13 @@ export class CompaniesService {
...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []), ...(groupIds.length ? [{ groupLinks: { some: { groupId: { in: groupIds } } } }] : []),
]; ];
const where: Prisma.CompanyWhereInput = { const where: Prisma.CompanyWhereInput = {
AND: [
{
organizationId: actor.organizationId, organizationId: actor.organizationId,
...customersCompanyWhere(actor), ...customersCompanyWhere(actor),
...(and.length ? { AND: and } : {}), },
...and,
],
}; };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
@@ -165,6 +169,7 @@ export class CompaniesService {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: 0, personCount: 0,
companyCount: countMap.get(g.id) ?? 0, companyCount: countMap.get(g.id) ?? 0,
assignees: [],
})); }));
} }
+2
View File
@@ -3,6 +3,7 @@ import { LoyaltyModule } from '../loyalty/loyalty.module';
import { AutomationsModule } from '../automations/automations.module'; import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module'; import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { PublicApiModule } from '../public-api/public-api.module'; import { PublicApiModule } from '../public-api/public-api.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CompaniesController } from './companies.controller'; import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller'; import { PeopleController } from './people.controller';
@@ -20,6 +21,7 @@ import { CustomerGroupsModule } from '../customer-groups/customer-groups.module'
PublicApiModule, PublicApiModule,
BehaviorModule, BehaviorModule,
CustomerGroupsModule, CustomerGroupsModule,
NotificationsModule,
], ],
controllers: [PeopleController, CompaniesController], controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService], providers: [PeopleService, CompaniesService, ContactsExcelService, SameMobileMergeService],
+5
View File
@@ -23,6 +23,11 @@ export class BirthdaySmsDto {
@MinLength(1) @MinLength(1)
@MaxLength(500) @MaxLength(500)
text!: string; text!: string;
/** تأیید ارسال مجدد وقتی قبلاً مسئول دیگری تبریک فرستاده */
@IsOptional()
@IsBoolean()
force?: boolean;
} }
export class BulkDeletePeopleDto { export class BulkDeletePeopleDto {
+2 -1
View File
@@ -139,6 +139,7 @@ export class PeopleController {
} }
@Get('birthdays') @Get('birthdays')
@RequirePermission('customers', { reads: true })
birthdays(@CurrentUser() user: Actor) { birthdays(@CurrentUser() user: Actor) {
return this.peopleService.birthdaysThisMonth(user); return this.peopleService.birthdaysThisMonth(user);
} }
@@ -171,7 +172,7 @@ export class PeopleController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: BirthdaySmsDto, @Body() dto: BirthdaySmsDto,
) { ) {
return this.peopleService.sendBirthdaySms(user, id, dto.text); return this.peopleService.sendBirthdaySms(user, id, dto.text, Boolean(dto.force));
} }
@Get(':id') @Get(':id')
+98 -9
View File
@@ -22,6 +22,7 @@ import {
FILTERED_IDS_MAX, FILTERED_IDS_MAX,
PERSON_MOODS, PERSON_MOODS,
applyPersonGenderTitle, applyPersonGenderTitle,
canViewAllRecords,
splitPersonHonorific, splitPersonHonorific,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@@ -48,6 +49,7 @@ import {
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access'; import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service'; import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service'; import { AutomationsService } from '../automations/automations.service';
import { NotificationsService } from '../notifications/notifications.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service'; import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
@@ -157,6 +159,7 @@ export class PeopleService implements OnModuleInit {
private readonly outboundSms: OutboundSmsLogService, private readonly outboundSms: OutboundSmsLogService,
private readonly behaviorAccess: BehaviorAccessService, private readonly behaviorAccess: BehaviorAccessService,
private readonly groups: CustomerGroupsService, private readonly groups: CustomerGroupsService,
private readonly notifications: NotificationsService,
) {} ) {}
private personListWhere( private personListWhere(
@@ -216,13 +219,27 @@ export class PeopleService implements OnModuleInit {
} }
const disc = discFilter(opts?.disc); const disc = discFilter(opts?.disc);
if (disc) and.push(disc); if (disc) and.push(disc);
if (groupIds.length) and.push({ groupLinks: { some: { groupId: { in: groupIds } } } }); // گروه / منبع / حال: اتحاد (هر بُعد کافی است)؛ داخل هر بُعد چند مقدار با in = OR
const facets: Prisma.PersonWhereInput[] = [];
if (groupIds.length) {
facets.push({ groupLinks: { some: { groupId: { in: groupIds } } } });
}
if (moods.length) {
facets.push({ mood: { in: moods } });
}
if (sources.length) {
facets.push({ importSource: { in: sources } });
}
if (facets.length === 1) and.push(facets[0]!);
else if (facets.length > 1) and.push({ OR: facets });
return { return {
AND: [
{
organizationId: actor.organizationId, organizationId: actor.organizationId,
...customersPersonWhere(actor), ...customersPersonWhere(actor),
...(moods.length ? { mood: { in: moods } } : {}), },
...(sources.length ? { importSource: { in: sources } } : {}), ...and,
...(and.length ? { AND: and } : {}), ],
}; };
} }
@@ -345,6 +362,7 @@ export class PeopleService implements OnModuleInit {
fromTahesab: Boolean(g.tahesabGid), fromTahesab: Boolean(g.tahesabGid),
personCount: countMap.get(g.id) ?? 0, personCount: countMap.get(g.id) ?? 0,
companyCount: 0, companyCount: 0,
assignees: [],
})); }));
} }
@@ -680,8 +698,8 @@ export class PeopleService implements OnModuleInit {
const rows = await this.prisma.person.findMany({ const rows = await this.prisma.person.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
ownerId: actor.userId,
birthDate: { not: null }, birthDate: { not: null },
...customersPersonWhere(actor),
}, },
select: { select: {
id: true, id: true,
@@ -728,13 +746,17 @@ export class PeopleService implements OnModuleInit {
}; };
} }
async sendBirthdaySms(actor: Actor, personId: string, text: string) { async sendBirthdaySms(actor: Actor, personId: string, text: string, force = false) {
const body = text.trim(); const body = text.trim();
if (!body || body.length > 500) { if (!body || body.length > 500) {
throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.'); throw new BadRequestException('متن پیامک را تا ۵۰۰ نویسه بنویسید.');
} }
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId: actor.organizationId }, where: {
id: personId,
organizationId: actor.organizationId,
...customersPersonWhere(actor),
},
select: { select: {
id: true, id: true,
firstName: true, firstName: true,
@@ -744,11 +766,29 @@ export class PeopleService implements OnModuleInit {
ownerId: true, ownerId: true,
birthDate: true, birthDate: true,
company: { select: { name: true } }, company: { select: { name: true } },
groupLinks: {
select: {
group: {
select: {
assignees: { select: { id: true, fullName: true } },
},
},
},
},
}, },
}); });
if (!person) throw new NotFoundException('مشتری پیدا نشد'); if (!person) throw new NotFoundException('مشتری پیدا نشد');
if (person.ownerId !== actor.userId) { const assigneeIds = new Set(
throw new ForbiddenException('فقط مسئول این مشتری می‌تواند پیامک تبریک بفرستد.'); person.groupLinks.flatMap((link) => link.group.assignees.map((a) => a.id)),
);
const canSend =
canViewAllRecords(actor.role) ||
person.ownerId === actor.userId ||
assigneeIds.has(actor.userId);
if (!canSend) {
throw new ForbiddenException(
'فقط مالک مشتری یا مسئول گروه او می‌تواند پیامک تبریک بفرستد.',
);
} }
if (!person.birthDate) { if (!person.birthDate) {
throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.'); throw new BadRequestException('برای این مشتری تاریخ تولد ثبت نشده است.');
@@ -770,6 +810,32 @@ export class PeopleService implements OnModuleInit {
const mobile = person.mobile || person.phone; const mobile = person.mobile || person.phone;
if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.'); if (!mobile) throw new BadRequestException('برای این مشتری شماره موبایل ثبت نشده است.');
const fullName = `${person.firstName} ${person.lastName}`.trim(); const fullName = `${person.firstName} ${person.lastName}`.trim();
const birthdayKey = `birthday:${inMonth.year}-${inMonth.month}`;
if (!force) {
const prior = await this.prisma.outboundSmsLog.findFirst({
where: {
organizationId: actor.organizationId,
personId: person.id,
status: 'sent',
sourceId: birthdayKey,
NOT: { actorUserId: actor.userId },
},
orderBy: { createdAt: 'desc' },
select: { actorUserId: true },
});
if (prior) {
const priorUser = prior.actorUserId
? await this.prisma.user.findFirst({
where: { id: prior.actorUserId },
select: { fullName: true },
})
: null;
const who = priorUser?.fullName?.trim() || 'مسئول دیگری';
throw new ConflictException(
`${who} قبلاً پیامک تبریک این مشتری را فرستاده است. آیا می‌خواهید دوباره بفرستید؟`,
);
}
}
const message = renderSmsText(body, { const message = renderSmsText(body, {
name: fullName, name: fullName,
phone: mobile, phone: mobile,
@@ -789,6 +855,7 @@ export class PeopleService implements OnModuleInit {
mobile, mobile,
body: message, body: message,
source: 'manual', source: 'manual',
sourceId: birthdayKey,
status: result.ok ? 'sent' : 'failed', status: result.ok ? 'sent' : 'failed',
error: result.ok ? null : result.reason, error: result.ok ? null : result.reason,
actorUserId: actor.userId, actorUserId: actor.userId,
@@ -802,6 +869,28 @@ export class PeopleService implements OnModuleInit {
: 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.', : 'پیامک ارسال نشد. کمی بعد دوباره تلاش کنید.',
); );
} }
const senderName = (
await this.prisma.user.findFirst({
where: { id: actor.userId },
select: { fullName: true },
})
)?.fullName?.trim() || 'یکی از مسئولان';
const peers = [...assigneeIds].filter((id) => id !== actor.userId);
await Promise.all(
peers.map((userId) =>
this.notifications.create({
organizationId: actor.organizationId,
userId,
type: 'birthday_sms_sent',
title: 'پیامک تبریک تولد ارسال شد',
body: `${senderName} برای «${fullName}» پیامک تبریک فرستاد. اگر لازم است می‌توانید دوباره بفرستید.`,
link: `/contacts/people/${person.id}`,
entityType: 'person',
entityId: person.id,
dedupe: false,
}),
),
);
return { ok: true }; return { ok: true };
} }
@@ -8,7 +8,14 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared'; import { CUSTOMER_GROUP_COLORS, type CustomerGroupColor } from '@mento/shared';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -26,6 +33,11 @@ class CreateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsIn([...CUSTOMER_GROUP_COLORS]) @IsIn([...CUSTOMER_GROUP_COLORS])
color?: CustomerGroupColor; color?: CustomerGroupColor;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
class UpdateCustomerGroupDto { class UpdateCustomerGroupDto {
@@ -43,6 +55,11 @@ class UpdateCustomerGroupDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
assigneeIds?: string[];
} }
@Controller('customer-groups') @Controller('customer-groups')
@@ -51,26 +51,21 @@ export class CustomerGroupsService {
include: { include: {
_count: { select: { people: true, companies: true } }, _count: { select: { people: true, companies: true } },
parent: { select: { name: true } }, parent: { select: { name: true } },
assignees: { select: { id: true, fullName: true }, orderBy: { fullName: 'asc' } },
}, },
}); });
return rows.map((r) => ({ return rows.map((r) => this.toDto(r));
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
}));
} }
async create( async create(
organizationId: string, organizationId: string,
dto: { name: string; color?: CustomerGroupColor }, dto: { name: string; color?: CustomerGroupColor; assigneeIds?: string[] },
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
const name = this.cleanName(dto.name); const name = this.cleanName(dto.name);
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: [];
const max = await this.prisma.customerGroup.aggregate({ const max = await this.prisma.customerGroup.aggregate({
where: { organizationId }, where: { organizationId },
_max: { position: true }, _max: { position: true },
@@ -82,6 +77,9 @@ export class CustomerGroupsService {
name, name,
color: this.cleanColor(dto.color), color: this.cleanColor(dto.color),
position: (max._max.position ?? -1) + 1, position: (max._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -93,7 +91,12 @@ export class CustomerGroupsService {
async update( async update(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string; color?: CustomerGroupColor; parentId?: string | null }, dto: {
name?: string;
color?: CustomerGroupColor;
parentId?: string | null;
assigneeIds?: string[];
},
): Promise<CustomerGroupDto> { ): Promise<CustomerGroupDto> {
await this.require(organizationId, id); await this.require(organizationId, id);
const parentId = const parentId =
@@ -101,6 +104,10 @@ export class CustomerGroupsService {
if (parentId !== undefined) { if (parentId !== undefined) {
await this.assertParent(organizationId, id, parentId); await this.assertParent(organizationId, id, parentId);
} }
const assigneeIds =
dto.assigneeIds !== undefined
? await this.validAssigneeIds(organizationId, dto.assigneeIds)
: undefined;
try { try {
await this.prisma.customerGroup.update({ await this.prisma.customerGroup.update({
where: { id }, where: { id },
@@ -108,6 +115,9 @@ export class CustomerGroupsService {
...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}),
...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}), ...(dto.color != null ? { color: this.cleanColor(dto.color) } : {}),
...(parentId !== undefined ? { parentId } : {}), ...(parentId !== undefined ? { parentId } : {}),
...(assigneeIds !== undefined
? { assignees: { set: assigneeIds.map((uid) => ({ id: uid })) } }
: {}),
}, },
}); });
} catch (e) { } catch (e) {
@@ -317,6 +327,48 @@ export class CustomerGroupsService {
return link?.group.name ?? null; return link?.group.name ?? null;
} }
private toDto(r: {
id: string;
name: string;
color: string;
position: number;
tahesabGid: string | null;
parentId: string | null;
parent?: { name: string } | null;
_count: { people: number; companies: number };
assignees: Array<{ id: string; fullName: string }>;
}): CustomerGroupDto {
return {
id: r.id,
name: r.name,
color: r.color,
position: r.position,
fromTahesab: Boolean(r.tahesabGid),
personCount: r._count.people,
companyCount: r._count.companies,
parentId: r.parentId,
parentName: r.parent?.name ?? null,
assignees: r.assignees.map((a) => ({ id: a.id, fullName: a.fullName })),
};
}
private async validAssigneeIds(organizationId: string, ids: string[]): Promise<string[]> {
const unique = [...new Set((ids ?? []).map((id) => id?.trim()).filter(Boolean))];
if (!unique.length) return [];
const rows = await this.prisma.user.findMany({
where: {
id: { in: unique },
isActive: true,
memberships: { some: { organizationId, isActive: true } },
},
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
}
return rows.map((r) => r.id);
}
private async require(organizationId: string, id: string) { private async require(organizationId: string, id: string) {
const row = await this.prisma.customerGroup.findFirst({ const row = await this.prisma.customerGroup.findFirst({
where: { id, organizationId }, where: { id, organizationId },
@@ -1,4 +1,11 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
pickAddress,
pickCity,
pickNationalId,
pickRasteh,
tahesabProfilePatch,
} from '../moshtari-profile';
import { import {
assertFactorCode, assertFactorCode,
buildFactorCode, buildFactorCode,
@@ -116,12 +123,52 @@ function testSanadBuilders() {
assert(bank[12] === fc, 'bank factor index'); assert(bank[12] === fc, 'bank factor index');
} }
function testMoshtariProfile() {
const row = {
CodeMelli: '00112312311',
Address: 'خیابان ولیعصر',
City: 'تهران',
Rasteh: 2,
BDate: null,
};
assert(pickNationalId(row) === '00112312311', 'national id');
assert(pickAddress(row) === 'خیابان ولیعصر', 'address');
assert(pickCity(row) === 'تهران', 'city');
assert(pickRasteh(row) === 2, 'rasteh');
assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national');
const patch = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: { keep: true },
});
assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city');
const fields = patch.customFields as Record<string, unknown>;
assert(fields.nationalId === '00112312311', 'cf national');
assert(fields.city === 'تهران', 'cf city');
assert(fields.rasteh === 2, 'cf rasteh');
assert(fields.keep === true, 'cf merge');
const emptyKeep = tahesabProfilePatch(
{ CodeMelli: '', Address: '', City: '', Rasteh: '' },
{
birthDate: new Date('2000-01-01'),
addresses: ['قدیمی'],
customFields: { nationalId: '111' },
},
);
assert(emptyKeep.addresses == null, 'empty does not wipe address');
assert(emptyKeep.customFields == null, 'empty does not wipe custom');
assert(emptyKeep.birthDate == null, 'existing birth kept');
}
function main() { function main() {
testCryptoRoundtrip(); testCryptoRoundtrip();
testFactorCode(); testFactorCode();
testMobile(); testMobile();
testMandehNormalize(); testMandehNormalize();
testSanadBuilders(); testSanadBuilders();
testMoshtariProfile();
console.log('tahesab unit checks: OK'); console.log('tahesab unit checks: OK');
} }
+6
View File
@@ -205,6 +205,12 @@ export class SyncGroupsPageDto {
pageSize?: number; pageSize?: number;
} }
/** اجرای دستی همگام‌سازی همهٔ مشتریان (وارد + به‌روز کردن لینک‌شده‌ها) */
export class SyncAllCustomersDto {
@IsString()
connectionId!: string;
}
export class ExportPeopleToTahesabDto { export class ExportPeopleToTahesabDto {
@IsString() @IsString()
connectionId!: string; connectionId!: string;
@@ -0,0 +1,113 @@
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../../common/excel';
export function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function trimStr(raw: unknown): string | null {
if (raw == null) return null;
const s = String(raw).trim();
if (!s || s === '-1') return null;
return s;
}
/** کد ملی از پاسخ لیست مشتری ته‌حساب */
export function pickNationalId(row: Record<string, unknown>): string | null {
return (
trimStr(row.CodeMelli) ??
trimStr(row.CodeMeli) ??
trimStr(row.codeMeli) ??
trimStr(row.NationalId) ??
null
);
}
export function pickAddress(row: Record<string, unknown>): string | null {
return trimStr(row.Address) ?? trimStr(row.address) ?? null;
}
export function pickCity(row: Record<string, unknown>): string | null {
return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null;
}
/** رسته حساب در ته‌حساب (عدد یا رشته) */
export function pickRasteh(row: Record<string, unknown>): string | number | null {
const raw = row.Rasteh ?? row.rasteh ?? row.Raste;
if (raw == null || raw === '') return null;
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
const s = String(raw).trim();
if (!s || s === '-1') return null;
const n = Number(s);
return Number.isFinite(n) && String(n) === s ? n : s;
}
function asCustomFieldRecord(raw: unknown): Record<string, unknown> {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return { ...(raw as Record<string, unknown>) };
}
return {};
}
function asAddressList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((a) => (typeof a === 'string' ? a.trim() : ''))
.filter(Boolean);
}
/**
* فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند.
* مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند.
*/
export function tahesabProfilePatch(
row: Record<string, unknown>,
existing: {
birthDate: Date | null;
addresses: unknown;
customFields: unknown;
},
): Prisma.PersonUpdateInput {
const data: Prisma.PersonUpdateInput = {};
const birthDate = pickBirthDate(row);
if (!existing.birthDate && birthDate) data.birthDate = birthDate;
const address = pickAddress(row);
const city = pickCity(row);
if (address) {
const line = city ? `${address}، ${city}` : address;
data.addresses = [line] as Prisma.InputJsonValue;
} else if (city && asAddressList(existing.addresses).length === 0) {
data.addresses = [city] as Prisma.InputJsonValue;
}
const fields = asCustomFieldRecord(existing.customFields);
let fieldsChanged = false;
const nationalId = pickNationalId(row);
if (nationalId) {
fields.nationalId = nationalId;
fieldsChanged = true;
}
if (city) {
fields.city = city;
fieldsChanged = true;
}
if (address) {
fields.address = address;
fieldsChanged = true;
}
const rasteh = pickRasteh(row);
if (rasteh != null) {
fields.rasteh = rasteh;
fieldsChanged = true;
}
if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue;
return data;
}
@@ -26,8 +26,9 @@ import {
SearchTahesabDto, SearchTahesabDto,
SyncGroupsPageDto, SyncGroupsPageDto,
} from './dto'; } from './dto';
import { formatJalaliDate, parseBirthDate } from '../../common/excel'; import { formatJalaliDate } from '../../common/excel';
import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service';
import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */ /** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */
@@ -83,17 +84,6 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
} }
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
function firstAddress(raw: unknown): string | undefined { function firstAddress(raw: unknown): string | undefined {
if (!Array.isArray(raw)) return undefined; if (!Array.isArray(raw)) return undefined;
const first = raw.find((a) => typeof a === 'string' && a.trim()); const first = raw.find((a) => typeof a === 'string' && a.trim());
@@ -438,6 +428,77 @@ export class TahesabPersonLinkService {
}; };
} }
/**
* کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است.
* یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند.
*/
private async duplicateOwners(
organizationId: string,
connectionId: string,
rows: Array<{ moshtariCode: number | null; tel: string }>,
) {
const pending = rows.filter(
(row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null,
);
const keys = [
...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))),
];
const out = new Map<
number,
{ moshtariCode: number; personId: string; personName: string }
>();
if (!keys.length) return out;
const people = await this.prisma.person.findMany({
where: {
organizationId,
OR: [{ mobile: { in: keys } }, { phone: { in: keys } }],
accountingLinks: { some: { connectionId } },
},
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
accountingLinks: {
where: { connectionId },
select: { moshtariCode: true },
take: 1,
},
},
});
const byKey = new Map<
string,
{ moshtariCode: number; personId: string; personName: string }
>();
for (const person of people) {
const link = person.accountingLinks[0];
if (!link) continue;
const info = {
moshtariCode: link.moshtariCode,
personId: person.id,
personName: `${person.firstName} ${person.lastName}`.trim(),
};
for (const key of [
...phoneIdentityKeys(person.mobile),
...phoneIdentityKeys(person.phone),
]) {
if (!byKey.has(key)) byKey.set(key, info);
}
}
for (const row of pending) {
for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) {
const hit = byKey.get(key);
if (hit && hit.moshtariCode !== row.moshtariCode) {
out.set(row.moshtariCode, hit);
break;
}
}
}
return out;
}
async search(organizationId: string, dto: SearchTahesabDto) { async search(organizationId: string, dto: SearchTahesabDto) {
const { api } = await this.connections.getApi( const { api } = await this.connections.getApi(
organizationId, organizationId,
@@ -516,7 +577,7 @@ export class TahesabPersonLinkService {
}); });
const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); const byCode = new Map(existing.map((e) => [e.moshtariCode, e]));
const results = mapped.map((row) => { const linked = mapped.map((row) => {
const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined; const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined;
return { return {
...row, ...row,
@@ -527,6 +588,22 @@ export class TahesabPersonLinkService {
: null, : null,
}; };
}); });
const duplicates = await this.duplicateOwners(
organizationId,
dto.connectionId,
linked.filter((row) => !row.alreadyLinked),
);
const results = linked.map((row) => {
if (row.alreadyLinked || row.moshtariCode == null) return row;
const dup = duplicates.get(row.moshtariCode);
if (!dup) return row;
return {
...row,
duplicateOfCode: dup.moshtariCode,
duplicatePersonId: dup.personId,
duplicatePersonName: dup.personName,
};
});
await this.prisma.accountingSyncJob.create({ await this.prisma.accountingSyncJob.create({
data: { data: {
@@ -601,7 +678,6 @@ export class TahesabPersonLinkService {
const sourceRef = String(dto.moshtariCode); const sourceRef = String(dto.moshtariCode);
let personId = dto.personId; let personId = dto.personId;
let preserveExistingLink = false;
if (personId) { if (personId) {
const existing = await this.getPerson(organizationId, personId); const existing = await this.getPerson(organizationId, personId);
const prevFirst = existing.firstName; const prevFirst = existing.firstName;
@@ -624,14 +700,14 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, existing), ...canonicalMobileUpdate(mobile, existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
} else { } else {
// اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین.
// اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند
// تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود. // تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد.
const mobileKeys = phoneIdentityKeys(mobile); const mobileKeys = phoneIdentityKeys(mobile);
const matches = mobileKeys.length const matches = mobileKeys.length
? await this.prisma.person.findMany({ ? await this.prisma.person.findMany({
@@ -651,7 +727,22 @@ export class TahesabPersonLinkService {
const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null;
if (byMobile) { if (byMobile) {
const linkedCode = byMobile.accountingLinks[0]?.moshtariCode; const linkedCode = byMobile.accountingLinks[0]?.moshtariCode;
preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode; if (linkedCode != null && linkedCode !== dto.moshtariCode) {
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId: byMobile.id, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
return {
personId: byMobile.id,
link: this.linkDto(existing),
duplicateOf: linkedCode,
};
}
const nameChanged = const nameChanged =
`${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${byMobile.firstName} ${byMobile.lastName}`.trim() !==
`${firstName} ${lastName}`.trim(); `${firstName} ${lastName}`.trim();
@@ -670,13 +761,18 @@ export class TahesabPersonLinkService {
: {}), : {}),
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef, sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...canonicalMobileUpdate(mobile, byMobile), ...canonicalMobileUpdate(mobile, byMobile),
...tahesabProfilePatch(row, byMobile),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
} else { } else {
const profile = tahesabProfilePatch(row, {
birthDate: null,
addresses: [],
customFields: {},
});
const created = await this.prisma.person.create({ const created = await this.prisma.person.create({
data: { data: {
organizationId, organizationId,
@@ -689,39 +785,15 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
birthDate: pickBirthDate(row), birthDate: (profile.birthDate as Date | undefined) ?? null,
addresses: (profile.addresses as Prisma.InputJsonValue) ?? [],
customFields: (profile.customFields as Prisma.InputJsonValue) ?? {},
}, },
}); });
personId = created.id; personId = created.id;
} }
} }
if (preserveExistingLink) {
await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row);
const existing = await this.prisma.personAccountingLink.findFirst({
where: { personId, connectionId: dto.connectionId },
include: {
connection: { select: { label: true, dbName: true, isDefault: true } },
},
});
if (!existing) {
throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.');
}
if (!options?.skipJob) {
await this.prisma.accountingSyncJob.create({
data: {
organizationId,
connectionId: dto.connectionId,
kind: 'import',
status: 'success',
request: dto as unknown as Prisma.InputJsonValue,
result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue,
},
});
}
return { personId, link: this.linkDto(existing) };
}
const link = await this.prisma.personAccountingLink.upsert({ const link = await this.prisma.personAccountingLink.upsert({
where: { where: {
personId_connectionId: { personId_connectionId: {
@@ -762,7 +834,7 @@ export class TahesabPersonLinkService {
return { personId, link: this.linkDto(link) }; return { personId, link: this.linkDto(link) };
} }
/** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ /** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */
private async refreshLinkedCustomer( private async refreshLinkedCustomer(
organizationId: string, organizationId: string,
connectionId: string, connectionId: string,
@@ -803,10 +875,8 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef: String(moshtariCode), sourceRef: String(moshtariCode),
...(!existing.birthDate && pickBirthDate(row)
? { birthDate: pickBirthDate(row) }
: {}),
...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing),
...tahesabProfilePatch(row, existing),
}, },
}); });
await this.applyTahesabGroup(organizationId, connectionId, personId, row); await this.applyTahesabGroup(organizationId, connectionId, personId, row);
@@ -822,6 +892,7 @@ export class TahesabPersonLinkService {
let emptyStreak = 0; let emptyStreak = 0;
let imported = 0; let imported = 0;
let updated = 0; let updated = 0;
let duplicate = 0;
let failed = 0; let failed = 0;
const errors: string[] = []; const errors: string[] = [];
const maxPages = 300; const maxPages = 300;
@@ -869,13 +940,14 @@ export class TahesabPersonLinkService {
); );
updated += 1; updated += 1;
} else { } else {
await this.importFromTahesab( const created = await this.importFromTahesab(
organizationId, organizationId,
{ connectionId, moshtariCode: code }, { connectionId, moshtariCode: code },
{ api, row }, { api, row },
{ skipJob: true }, { skipJob: true },
); );
imported += 1; if (created.duplicateOf) duplicate += 1;
else imported += 1;
} }
} catch (err) { } catch (err) {
failed += 1; failed += 1;
@@ -891,23 +963,38 @@ export class TahesabPersonLinkService {
errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.');
} }
const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`; const summary = [
`${imported} وارد شد`,
`${updated} به‌روز شد`,
...(duplicate ? [`${duplicate} شماره تکراری`] : []),
`${failed} خطا`,
].join('، ');
const error = errors.length ? errors.join('؛ ') : null;
await this.prisma.accountingSyncJob.create({ await this.prisma.accountingSyncJob.create({
data: { data: {
organizationId, organizationId,
connectionId, connectionId,
kind: 'customer-sync', kind: 'customer-sync',
status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success',
result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue, result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue,
},
});
await this.prisma.accountingConnection.update({
where: { id: connectionId },
data: {
customerSyncLastRunAt: new Date(),
customerSyncLastSummary: summary,
customerSyncLastError: error,
}, },
}); });
this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`); this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`);
return { return {
imported, imported,
updated, updated,
duplicate,
failed, failed,
summary, summary,
error: errors.length ? errors.join('؛ ') : null, error,
}; };
} }
@@ -1003,19 +1090,16 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); const row = rowsByCode.get(moshtariCode);
if (birthDate) { if (row) {
await this.prisma.person.updateMany({ await this.refreshLinkedCustomer(
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
}
await this.applyTahesabGroup(
organizationId, organizationId,
dto.connectionId, dto.connectionId,
linkedPersonId, linkedPersonId,
rowsByCode.get(moshtariCode), moshtariCode,
row,
); );
}
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -1029,6 +1113,16 @@ export class TahesabPersonLinkService {
{ connectionId: dto.connectionId, moshtariCode }, { connectionId: dto.connectionId, moshtariCode },
bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined, bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined,
); );
if (r.duplicateOf) {
items.push({
moshtariCode,
ok: true,
skipped: true,
personId: r.personId,
error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`,
});
continue;
}
items.push({ moshtariCode, ok: true, personId: r.personId }); items.push({ moshtariCode, ok: true, personId: r.personId });
} catch (e) { } catch (e) {
const message = const message =
@@ -37,6 +37,7 @@ import {
SearchTahesabDto, SearchTahesabDto,
SearchTahesabProductsDto, SearchTahesabProductsDto,
SyncAsnadReportDto, SyncAsnadReportDto,
SyncAllCustomersDto,
SyncGroupsPageDto, SyncGroupsPageDto,
UpdateTahesabConnectionDto, UpdateTahesabConnectionDto,
UpdateTahesabInvoiceSettingsDto, UpdateTahesabInvoiceSettingsDto,
@@ -237,6 +238,16 @@ export class TahesabController {
return this.links.syncGroupsPage(user.organizationId, dto); return this.links.syncGroupsPage(user.organizationId, dto);
} }
/** همهٔ مشتریان ته‌حساب: تازه‌ها وارد، لینک‌شده‌ها (نام/موبایل/کدملی/آدرس/شهر/رسته/گروه) به‌روز */
@Post('sync/customers-now')
@Roles('owner', 'admin')
syncCustomersNow(
@CurrentUser() user: { organizationId: string },
@Body() dto: SyncAllCustomersDto,
) {
return this.links.syncAllCustomers(user.organizationId, dto.connectionId);
}
@Get('sync/unlinked-people') @Get('sync/unlinked-people')
@Roles('owner', 'admin') @Roles('owner', 'admin')
unlinkedPeople( unlinkedPeople(
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { AiService } from '../../ai/ai.service'; import { AiService } from '../../ai/ai.service';
import { NotesService } from '../../notes/notes.service'; import { NotesService } from '../../notes/notes.service';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { parseDaftaresomaRecordingRef } from './daftareshoma-webhook';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
@@ -17,6 +20,8 @@ export class CallSummaryService {
private readonly stt: SttClientService, private readonly stt: SttClientService,
private readonly ai: AiService, private readonly ai: AiService,
private readonly notes: NotesService, private readonly notes: NotesService,
private readonly connections: VoipConnectionsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
@Cron('*/1 * * * *') @Cron('*/1 * * * *')
@@ -74,7 +79,31 @@ export class CallSummaryService {
); );
return; return;
} }
const daftId = parseDaftaresomaRecordingRef(row.recordingUrl);
if (daftId) {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: row.connectionId },
});
const token = conn
? await this.connections.decryptApiToken(conn)
: null;
if (!token) {
await this.markFailed(
id,
row.summaryAttempts,
'توکن API دفتر شما برای دانلود ضبط موجود نیست',
true,
);
return;
}
const audio = await this.daftareshoma.downloadRecording(
token,
daftId,
);
transcript = await this.stt.transcribeAudio(audio);
} else {
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl); transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
}
await this.prisma.voipCallLog.update({ await this.prisma.voipCallLog.update({
where: { id }, where: { id },
data: { transcript }, data: { transcript },
+159 -4
View File
@@ -13,6 +13,11 @@ import { PrismaService } from '../../prisma/prisma.service';
import { NotificationsService } from '../../notifications/notifications.service'; import { NotificationsService } from '../../notifications/notifications.service';
import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize'; import { iranMobileLookupKeys, normalizePhone } from '../../contacts/normalize';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { DaftaresomaClient } from './daftareshoma-client';
import {
daftareshomaRecordingRef,
parseDaftaresomaRecordingRef,
} from './daftareshoma-webhook';
function digitsOnly(value: string | null | undefined): string { function digitsOnly(value: string | null | undefined): string {
return (value ?? '').replace(/\D/g, ''); return (value ?? '').replace(/\D/g, '');
@@ -57,19 +62,32 @@ export class VoipCallsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: VoipConnectionsService, private readonly connections: VoipConnectionsService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly daftareshoma: DaftaresomaClient,
) {} ) {}
async listRecent( async listRecent(
organizationId: string, organizationId: string,
connectionId?: string, opts?: {
connectionId?: string;
direction?: string;
status?: string;
lineExtension?: string;
},
): Promise<VoipCallLogDto[]> { ): Promise<VoipCallLogDto[]> {
const rows = await this.prisma.voipCallLog.findMany({ const rows = await this.prisma.voipCallLog.findMany({
where: { where: {
organizationId, organizationId,
...(connectionId ? { connectionId } : {}), ...(opts?.connectionId ? { connectionId: opts.connectionId } : {}),
...(opts?.direction === 'inbound' || opts?.direction === 'outbound'
? { direction: opts.direction }
: {}),
...(opts?.status?.trim() ? { status: opts.status.trim() } : {}),
...(opts?.lineExtension?.trim()
? { lineExtension: opts.lineExtension.trim() }
: {}),
}, },
orderBy: { syncedAt: 'desc' }, orderBy: { syncedAt: 'desc' },
take: 50, take: 100,
}); });
const personIds = rows const personIds = rows
.map((r) => r.personId) .map((r) => r.personId)
@@ -91,6 +109,7 @@ export class VoipCallsService {
status: r.status, status: r.status,
fromNumber: r.fromNumber, fromNumber: r.fromNumber,
toNumber: r.toNumber, toNumber: r.toNumber,
lineExtension: r.lineExtension ?? null,
personId: r.personId, personId: r.personId,
personName: r.personId ? (nameById.get(r.personId) ?? null) : null, personName: r.personId ? (nameById.get(r.personId) ?? null) : null,
activityId: r.activityId, activityId: r.activityId,
@@ -127,6 +146,9 @@ export class VoipCallsService {
const toDigits = digitsOnly(event.to ?? event.callee); const toDigits = digitsOnly(event.to ?? event.callee);
const fromNumber = fromNormalized || fromDigits || null; const fromNumber = fromNormalized || fromDigits || null;
const toNumber = toNormalized || toDigits || null; const toNumber = toNormalized || toDigits || null;
const lineRaw = String(event.lineExtension ?? '').trim();
const lineExtension =
normalizePhone(lineRaw) || digitsOnly(lineRaw) || lineRaw || null;
const matchNumber = direction === 'inbound' ? fromNumber : toNumber; const matchNumber = direction === 'inbound' ? fromNumber : toNumber;
const personId = await this.findPersonId( const personId = await this.findPersonId(
conn.organizationId, conn.organizationId,
@@ -193,6 +215,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension: lineExtension ?? existing.lineExtension,
personId: personId ?? existing.personId, personId: personId ?? existing.personId,
activityId: activityId ?? existing.activityId, activityId: activityId ?? existing.activityId,
durationSec, durationSec,
@@ -213,6 +236,7 @@ export class VoipCallsService {
status, status,
fromNumber, fromNumber,
toNumber, toNumber,
lineExtension,
personId, personId,
activityId, activityId,
durationSec, durationSec,
@@ -240,6 +264,17 @@ export class VoipCallsService {
await this.notifyIncoming(conn.organizationId, personId, fromNumber); await this.notifyIncoming(conn.organizationId, personId, fromNumber);
} }
if (
conn.provider === 'daftareshoma' &&
['completed', 'answered', 'missed'].includes(status)
) {
void this.enrichDaftaresomaRecording(connectionId, callId).catch((e) => {
this.logger.warn(
`daftareshoma recording enrich failed: ${e instanceof Error ? e.message : e}`,
);
});
}
const link = await this.prisma.voipCallLog.findUnique({ const link = await this.prisma.voipCallLog.findUnique({
where: { where: {
connectionId_externalCallId: { connectionId_externalCallId: {
@@ -261,10 +296,67 @@ export class VoipCallsService {
userId: string, userId: string,
number: string, number: string,
personId?: string, personId?: string,
): Promise<{ ok: true; mode: 'url' | 'tel'; dialUrl: string }> { ): Promise<{ ok: true; mode: 'url' | 'tel' | 'daftareshoma'; dialUrl: string }> {
const phone = normalizePhone(number) ?? digitsOnly(number); const phone = normalizePhone(number) ?? digitsOnly(number);
if (!phone) throw new BadRequestException('شماره معتبر نیست'); if (!phone) throw new BadRequestException('شماره معتبر نیست');
const daftareshomaConn = await this.prisma.voipConnection.findFirst({
where: {
organizationId,
enabled: true,
provider: 'daftareshoma',
},
orderBy: { createdAt: 'asc' },
});
if (daftareshomaConn) {
const token = await this.connections.decryptApiToken(daftareshomaConn);
const fromNumber = daftareshomaConn.fromNumber?.trim();
const callerExtension = daftareshomaConn.callerExtension?.trim();
if (!token || !fromNumber || !callerExtension) {
throw new BadRequestException(
'اتصال دفتر شما ناقص است؛ توکن، شماره مبدأ و خط را تکمیل کنید',
);
}
const toE164 = this.toPlusDigits(phone);
const fromE164 = this.toPlusDigits(fromNumber);
const extE164 = this.toPlusDigits(callerExtension);
try {
await this.daftareshoma.outgoingCall(token, {
fromNumber: fromE164,
toNumber: toE164,
callerExtension: extE164,
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'خطا';
await this.prisma.voipConnection.update({
where: { id: daftareshomaConn.id },
data: { lastError: msg.slice(0, 400) },
});
throw new BadRequestException(msg);
}
await this.prisma.voipCallLog.create({
data: {
organizationId,
connectionId: daftareshomaConn.id,
externalCallId: `ctc-${Date.now()}-${userId.slice(0, 6)}`,
direction: 'outbound',
status: 'ringing',
fromNumber: fromE164,
toNumber: toE164,
personId: personId ?? null,
startedAt: new Date(),
},
});
return {
ok: true,
mode: 'daftareshoma',
dialUrl: `daftareshoma:${toE164}`,
};
}
const conn = await this.prisma.voipConnection.findFirst({ const conn = await this.prisma.voipConnection.findFirst({
where: { where: {
organizationId, organizationId,
@@ -318,6 +410,69 @@ export class VoipCallsService {
return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` }; return { ok: true, mode: 'tel', dialUrl: `tel:${phone}` };
} }
private toPlusDigits(raw: string): string {
const d = digitsOnly(raw);
if (!d) return raw.trim();
if (raw.trim().startsWith('+')) return `+${d}`;
if (d.startsWith('98')) return `+${d}`;
if (d.startsWith('0') && d.length >= 10) return `+98${d.slice(1)}`;
if (d.length === 10 && d.startsWith('9')) return `+98${d}`;
return `+${d}`;
}
private async enrichDaftaresomaRecording(
connectionId: string,
callId: string,
): Promise<void> {
const conn = await this.prisma.voipConnection.findFirst({
where: { id: connectionId, provider: 'daftareshoma', enabled: true },
});
if (!conn) return;
const log = await this.prisma.voipCallLog.findUnique({
where: {
connectionId_externalCallId: { connectionId, externalCallId: callId },
},
});
if (!log) return;
if (parseDaftaresomaRecordingRef(log.recordingUrl) || log.recordingUrl) {
if (
parseDaftaresomaRecordingRef(log.recordingUrl) &&
!['done', 'pending', 'processing'].includes(log.summaryStatus)
) {
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
summaryStatus: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
return;
}
const token = await this.connections.decryptApiToken(conn);
if (!token) return;
const row = await this.daftareshoma.findCallById(token, callId);
const recordingId = row?.recordingId?.trim();
if (!recordingId) return;
const ref = daftareshomaRecordingRef(recordingId);
await this.prisma.voipCallLog.update({
where: { id: log.id },
data: {
recordingUrl: ref,
summaryStatus: ['done', 'pending', 'processing'].includes(
log.summaryStatus,
)
? log.summaryStatus
: 'pending',
summaryError: null,
summaryNextAttemptAt: new Date(),
},
});
}
private shouldCreateActivity(status: string): boolean { private shouldCreateActivity(status: string): boolean {
return ['answered', 'completed', 'missed', 'failed'].includes(status); return ['answered', 'completed', 'missed', 'failed'].includes(status);
} }
@@ -7,12 +7,17 @@ import type {
CreateVoipConnectionRequest, CreateVoipConnectionRequest,
UpdateVoipConnectionRequest, UpdateVoipConnectionRequest,
VoipConnectionDto, VoipConnectionDto,
VoipProvider,
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service'; import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
function asProvider(value: string | null | undefined): VoipProvider {
return value === 'daftareshoma' ? 'daftareshoma' : 'generic';
}
@Injectable() @Injectable()
export class VoipConnectionsService { export class VoipConnectionsService {
constructor( constructor(
@@ -43,19 +48,35 @@ export class VoipConnectionsService {
): Promise<VoipConnectionDto> { ): Promise<VoipConnectionDto> {
const label = dto.label?.trim(); const label = dto.label?.trim();
if (!label) throw new BadRequestException('نام اتصال الزامی است'); if (!label) throw new BadRequestException('نام اتصال الزامی است');
const provider = asProvider(dto.provider);
if (provider === 'daftareshoma') {
const token = dto.apiToken?.trim();
if (!token) throw new BadRequestException('توکن API دفتر شما الزامی است');
// fromNumber / callerExtension فقط برای تماس خروجی از CRM لازم‌اند؛ ثبت وب‌هوک بدون آن‌ها ممکن است
}
const secret = dto.webhookSecret?.trim(); const secret = dto.webhookSecret?.trim();
const secretEnc = secret ? this.crypto.encrypt(secret) : null; const secretEnc = secret ? this.crypto.encrypt(secret) : null;
const clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); const token = dto.apiToken?.trim();
const tokenEnc = token ? this.crypto.encrypt(token) : null;
const clickToCallUrl =
provider === 'generic' ? this.normalizeClickUrl(dto.clickToCallUrl) : null;
const row = await this.prisma.voipConnection.create({ const row = await this.prisma.voipConnection.create({
data: { data: {
organizationId, organizationId,
label, label,
provider,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
clickToCallUrl, clickToCallUrl,
fromNumber: dto.fromNumber?.trim() || null,
callerExtension: dto.callerExtension?.trim() || null,
webhookSecretCiphertext: secretEnc?.ciphertext, webhookSecretCiphertext: secretEnc?.ciphertext,
webhookSecretIv: secretEnc?.iv, webhookSecretIv: secretEnc?.iv,
webhookSecretTag: secretEnc?.tag, webhookSecretTag: secretEnc?.tag,
apiTokenCiphertext: tokenEnc?.ciphertext,
apiTokenIv: tokenEnc?.iv,
apiTokenTag: tokenEnc?.tag,
}, },
}); });
return this.toDto(row, 0); return this.toDto(row, 0);
@@ -77,6 +98,12 @@ export class VoipConnectionsService {
if (dto.clickToCallUrl !== undefined) { if (dto.clickToCallUrl !== undefined) {
data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl); data.clickToCallUrl = this.normalizeClickUrl(dto.clickToCallUrl);
} }
if (dto.fromNumber !== undefined) {
data.fromNumber = dto.fromNumber?.trim() || null;
}
if (dto.callerExtension !== undefined) {
data.callerExtension = dto.callerExtension?.trim() || null;
}
if (dto.webhookSecret !== undefined) { if (dto.webhookSecret !== undefined) {
const wh = dto.webhookSecret?.trim(); const wh = dto.webhookSecret?.trim();
if (!wh) { if (!wh) {
@@ -90,6 +117,19 @@ export class VoipConnectionsService {
data.webhookSecretTag = enc.tag; data.webhookSecretTag = enc.tag;
} }
} }
if (dto.apiToken !== undefined) {
const tok = dto.apiToken?.trim();
if (!tok) {
data.apiTokenCiphertext = null;
data.apiTokenIv = null;
data.apiTokenTag = null;
} else {
const enc = this.crypto.encrypt(tok);
data.apiTokenCiphertext = enc.ciphertext;
data.apiTokenIv = enc.iv;
data.apiTokenTag = enc.tag;
}
}
const row = await this.prisma.voipConnection.update({ const row = await this.prisma.voipConnection.update({
where: { id: existing.id }, where: { id: existing.id },
data, data,
@@ -124,6 +164,21 @@ export class VoipConnectionsService {
}); });
} }
async decryptApiToken(row: {
apiTokenCiphertext: string | null;
apiTokenIv: string | null;
apiTokenTag: string | null;
}): Promise<string | null> {
if (!row.apiTokenCiphertext || !row.apiTokenIv || !row.apiTokenTag) {
return null;
}
return this.crypto.decrypt({
ciphertext: row.apiTokenCiphertext,
iv: row.apiTokenIv,
tag: row.apiTokenTag,
});
}
verifySignature( verifySignature(
rawBody: string | Buffer, rawBody: string | Buffer,
signature: string | undefined, signature: string | undefined,
@@ -153,9 +208,13 @@ export class VoipConnectionsService {
row: { row: {
id: string; id: string;
label: string; label: string;
provider?: string | null;
enabled: boolean; enabled: boolean;
webhookSecretCiphertext: string | null; webhookSecretCiphertext: string | null;
apiTokenCiphertext?: string | null;
clickToCallUrl: string | null; clickToCallUrl: string | null;
fromNumber?: string | null;
callerExtension?: string | null;
lastEventAt: Date | null; lastEventAt: Date | null;
lastError: string | null; lastError: string | null;
createdAt: Date; createdAt: Date;
@@ -166,9 +225,13 @@ export class VoipConnectionsService {
return { return {
id: row.id, id: row.id,
label: row.label, label: row.label,
provider: asProvider(row.provider),
enabled: row.enabled, enabled: row.enabled,
webhookSecretSet: Boolean(row.webhookSecretCiphertext), webhookSecretSet: Boolean(row.webhookSecretCiphertext),
apiTokenSet: Boolean(row.apiTokenCiphertext),
clickToCallUrl: row.clickToCallUrl, clickToCallUrl: row.clickToCallUrl,
fromNumber: row.fromNumber ?? null,
callerExtension: row.callerExtension ?? null,
lastEventAt: row.lastEventAt?.toISOString() ?? null, lastEventAt: row.lastEventAt?.toISOString() ?? null,
lastError: row.lastError, lastError: row.lastError,
callCount, callCount,
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
const BASE_URL = 'https://coreapi.daftareshoma.com/api/Customize';
const MAX_AUDIO_BYTES = 8 * 1024 * 1024;
export type DaftaresomaOutgoingCallInput = {
fromNumber: string;
toNumber: string;
callerExtension: string;
};
export type DaftaresomaCallSearchRow = {
id?: string;
fromNumber?: string;
toNumber?: string;
recordingId?: string | null;
direction?: string;
reportDispositionType?: string;
startTime?: string;
endTime?: string;
duration?: string;
extension?: string;
};
@Injectable()
export class DaftaresomaClient {
private readonly logger = new Logger(DaftaresomaClient.name);
async outgoingCall(
apiToken: string,
input: DaftaresomaOutgoingCallInput,
): Promise<void> {
const res = await fetch(`${BASE_URL}/OutgoingCall`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
from_number: input.fromNumber,
to_number: input.toNumber,
caller_extension: input.callerExtension,
}),
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`OutgoingCall failed (${res.status}): ${text.slice(0, 300)}`,
);
throw new Error(
text.trim()
? `تماس خروجی دفتر شما ناموفق بود (${res.status}): ${text.slice(0, 200)}`
: `تماس خروجی دفتر شما ناموفق بود (${res.status})`,
);
}
}
async findCallById(
apiToken: string,
callId: string,
): Promise<DaftaresomaCallSearchRow | null> {
const res = await fetch(`${BASE_URL}/CustomerCallSearch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ id: callId, limit: 1, pagination: 1 }),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
this.logger.warn(
`CustomerCallSearch failed (${res.status}): ${text.slice(0, 200)}`,
);
return null;
}
const json = (await res.json()) as {
data?: DaftaresomaCallSearchRow[];
};
const row = Array.isArray(json.data) ? json.data[0] : null;
return row ?? null;
}
async downloadRecording(
apiToken: string,
recordingId: string,
): Promise<{ buffer: ArrayBuffer; contentType: string; filename: string }> {
const url = new URL(`${BASE_URL}/GetCallRecording`);
url.searchParams.set('recordingId', recordingId);
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${apiToken}`,
Accept: '*/*',
'User-Agent': 'MontactCRM-VoIP/1.0',
},
signal: AbortSignal.timeout(60000),
redirect: 'follow',
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`دانلود ضبط دفتر شما ناموفق بود (${res.status})${text ? `: ${text.slice(0, 160)}` : ''}`,
);
}
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
const buffer = await res.arrayBuffer();
if (buffer.byteLength > MAX_AUDIO_BYTES) {
throw new Error('حجم فایل ضبط بیش از حد مجاز است');
}
if (!buffer.byteLength) {
throw new Error('فایل ضبط خالی است');
}
const contentType =
res.headers.get('content-type')?.split(';')[0]?.trim() || 'audio/wav';
const filename = `daftareshoma-${recordingId}.wav`;
return { buffer, contentType, filename };
}
}
@@ -0,0 +1,168 @@
import type { VoipWebhookEvent } from '@mento/shared';
/** پیشوند ذخیره‌شده در recordingUrl برای واکشی بعدی با توکن API */
export const DAFTARESHOMA_RECORDING_PREFIX = 'daftareshoma-recording:';
export function daftareshomaRecordingRef(recordingId: string): string {
return `${DAFTARESHOMA_RECORDING_PREFIX}${recordingId}`;
}
export function parseDaftaresomaRecordingRef(
value: string | null | undefined,
): string | null {
const raw = String(value ?? '').trim();
if (!raw.startsWith(DAFTARESHOMA_RECORDING_PREFIX)) return null;
const id = raw.slice(DAFTARESHOMA_RECORDING_PREFIX.length).trim();
return id || null;
}
type Loose = Record<string, unknown>;
function str(v: unknown): string {
return v == null ? '' : String(v).trim();
}
function pick(obj: Loose, ...keys: string[]): string {
for (const k of keys) {
const v = str(obj[k]);
if (v) return v;
}
return '';
}
function parseDurationSec(raw: unknown): number | undefined {
if (raw == null || raw === '') return undefined;
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.max(0, Math.round(raw));
}
const s = String(raw).trim();
if (!s) return undefined;
if (/^\d+(\.\d+)?$/.test(s)) return Math.max(0, Math.round(Number(s)));
const parts = s.split(':').map((p) => Number(p));
if (parts.some((n) => !Number.isFinite(n))) return undefined;
if (parts.length === 3) {
return Math.max(0, Math.round(parts[0]! * 3600 + parts[1]! * 60 + parts[2]!));
}
if (parts.length === 2) {
return Math.max(0, Math.round(parts[0]! * 60 + parts[1]!));
}
return undefined;
}
/**
* رویداد وب‌هوک دفتر شما را به VoipWebhookEvent تبدیل می‌کند.
* اگر payload از قبل فرمت Montact باشد، همان را برمی‌گرداند.
*/
export function mapDaftaresomaWebhook(
body: unknown,
): VoipWebhookEvent | null {
if (!body || typeof body !== 'object') return null;
const obj = body as Loose;
const legacyCallId = pick(obj, 'callId');
if (legacyCallId && (obj.direction || obj.status || obj.from || obj.to)) {
return body as VoipWebhookEvent;
}
const eventType = pick(obj, 'EventType', 'eventType', 'event');
const callId = pick(obj, 'CallId', 'callId', 'Id', 'id');
if (!callId) return null;
const caller = pick(
obj,
'CallerNumber',
'callerNumber',
'from',
'From',
'fromNumber',
);
/** خط سازمانی که تماس روی آن آمده/رفته */
const lineExtension =
pick(obj, 'CalleeExtension', 'calleeExtension', 'Extension', 'extension') ||
undefined;
/** مقصد انتقال / شماره طرف مقابل روی خط */
const transferred = pick(
obj,
'TransferredToNumber',
'transferredToNumber',
);
const startedAt =
pick(obj, 'StartDateTime', 'startDateTime', 'startedAt') || undefined;
const endedAt =
pick(obj, 'EndDateTime', 'endDateTime', 'endedAt', 'DateTime') ||
undefined;
const durationSec = parseDurationSec(
obj.DurationInSeconds ?? obj.durationInSeconds ?? obj.durationSec,
);
const lower = eventType.toLowerCase();
const isOutbound = lower.includes('outgoing') || lower.includes('outbound');
const isStarted =
lower.includes('started') ||
lower.includes('ringing') ||
lower.includes('transferstarted');
const isEnded =
lower.includes('ended') ||
lower.includes('completed') ||
lower.includes('hangup');
let status: string = 'completed';
if (isStarted && !isEnded) {
status = 'ringing';
} else if (isEnded) {
const result = obj.Result ?? obj.result;
if (result === false || result === 'false' || result === 0) {
status = durationSec && durationSec > 0 ? 'completed' : 'missed';
} else if (result === true || result === 'true' || result === 1) {
status = 'completed';
} else {
status = 'completed';
}
}
const recordingId = pick(
obj,
'RecordingId',
'recordingId',
'RecordId',
'recordId',
);
// inbound: from=مشتری، to=همکار/مقصد انتقال یا خود خط
// outbound: from=همکار، to=مشتری (TransferredToNumber)
let from = caller || undefined;
let to: string | undefined;
if (isOutbound) {
to = transferred || undefined;
} else {
to = transferred || lineExtension || undefined;
}
const event: VoipWebhookEvent = {
callId,
direction: isOutbound ? 'outbound' : 'inbound',
status,
from,
to,
lineExtension,
startedAt,
endedAt,
durationSec,
caller: from,
callee: to,
};
if (recordingId) {
event.recordingUrl = daftareshomaRecordingRef(recordingId);
}
return event;
}
export function isDaftaresomaWebhookPayload(body: unknown): boolean {
if (!body || typeof body !== 'object') return false;
const obj = body as Loose;
return Boolean(
pick(obj, 'EventType', 'eventType') ||
(pick(obj, 'CallId') && pick(obj, 'CallerNumber', 'callerNumber')),
);
}
+35
View File
@@ -1,5 +1,6 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
@@ -12,6 +13,10 @@ export class CreateVoipConnectionDto {
@MaxLength(80) @MaxLength(80)
label!: string; label!: string;
@IsOptional()
@IsIn(['generic', 'daftareshoma'])
provider?: 'generic' | 'daftareshoma';
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(200) @MaxLength(200)
@@ -22,6 +27,21 @@ export class CreateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -44,6 +64,21 @@ export class UpdateVoipConnectionDto {
@MaxLength(500) @MaxLength(500)
clickToCallUrl?: string | null; clickToCallUrl?: string | null;
@IsOptional()
@IsString()
@MaxLength(500)
apiToken?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
fromNumber?: string | null;
@IsOptional()
@IsString()
@MaxLength(40)
callerExtension?: string | null;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
enabled?: boolean; enabled?: boolean;
@@ -18,17 +18,31 @@ export class SttClientService {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست'); throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
} }
const timeoutMs = Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000,
);
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
return this.transcribeAudio(audio, timeoutMs);
}
async transcribeAudio(
audio: { buffer: ArrayBuffer; contentType: string; filename: string },
timeoutMs?: number,
): Promise<string> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('سرویس تبدیل گفتار فعال نیست');
}
const baseUrl = this.config.get<string>('STT_URL')?.trim(); const baseUrl = this.config.get<string>('STT_URL')?.trim();
if (!baseUrl) { if (!baseUrl) {
throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده'); throw new ServiceUnavailableException('آدرس سرویس STT تنظیم نشده');
} }
const timeoutMs = Number( const waitMs = timeoutMs ?? Number(
this.config.get<string>('STT_TIMEOUT_MS') || 180000, this.config.get<string>('STT_TIMEOUT_MS') || 180000,
); );
const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || ''; const token = this.config.get<string>('STT_INTERNAL_TOKEN')?.trim() || '';
const audio = await this.downloadAudio(recordingUrl, timeoutMs);
const form = new FormData(); const form = new FormData();
const blob = new Blob([new Uint8Array(audio.buffer)], { const blob = new Blob([new Uint8Array(audio.buffer)], {
type: audio.contentType, type: audio.contentType,
@@ -39,7 +53,7 @@ export class SttClientService {
if (token) headers['X-STT-Token'] = token; if (token) headers['X-STT-Token'] = token;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), waitMs);
try { try {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, { const res = await fetch(`${baseUrl.replace(/\/$/, '')}/transcribe`, {
method: 'POST', method: 'POST',
@@ -28,6 +28,10 @@ import {
UpdateVoipConnectionDto, UpdateVoipConnectionDto,
VoipClickToCallDto, VoipClickToCallDto,
} from './dto'; } from './dto';
import {
isDaftaresomaWebhookPayload,
mapDaftaresomaWebhook,
} from './daftareshoma-webhook';
@Controller('integrations/voip') @Controller('integrations/voip')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard) @UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@@ -78,8 +82,16 @@ export class VoipController {
recent( recent(
@CurrentUser() user: { organizationId: string }, @CurrentUser() user: { organizationId: string },
@Query('connectionId') connectionId?: string, @Query('connectionId') connectionId?: string,
@Query('direction') direction?: string,
@Query('status') status?: string,
@Query('lineExtension') lineExtension?: string,
) { ) {
return this.calls.listRecent(user.organizationId, connectionId); return this.calls.listRecent(user.organizationId, {
connectionId,
direction,
status,
lineExtension,
});
} }
@Post('click-to-call') @Post('click-to-call')
@@ -129,11 +141,21 @@ export class VoipWebhookController {
if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است'); if (!ok) throw new UnauthorizedException('امضای وب‌هوک نامعتبر است');
} }
if (!body?.callId) { let event: VoipWebhookEvent | null = null;
if (
row.provider === 'daftareshoma' ||
isDaftaresomaWebhookPayload(body)
) {
event = mapDaftaresomaWebhook(body);
} else if (body?.callId) {
event = body;
}
if (!event?.callId) {
return { ok: false, error: 'callId required' }; return { ok: false, error: 'callId required' };
} }
const result = await this.calls.ingestWebhook(connectionId, body); const result = await this.calls.ingestWebhook(connectionId, event);
return { ok: true, ...result }; return { ok: true, ...result };
} }
} }
@@ -7,6 +7,7 @@ import { TahesabCryptoService } from '../tahesab/tahesab-crypto.service';
import { CallSummaryService } from './call-summary.service'; import { CallSummaryService } from './call-summary.service';
import { VoipConnectionsService } from './connections.service'; import { VoipConnectionsService } from './connections.service';
import { VoipCallsService } from './calls.service'; import { VoipCallsService } from './calls.service';
import { DaftaresomaClient } from './daftareshoma-client';
import { SttClientService } from './stt-client.service'; import { SttClientService } from './stt-client.service';
import { VoipController, VoipWebhookController } from './voip.controller'; import { VoipController, VoipWebhookController } from './voip.controller';
@@ -17,6 +18,7 @@ import { VoipController, VoipWebhookController } from './voip.controller';
TahesabCryptoService, TahesabCryptoService,
VoipConnectionsService, VoipConnectionsService,
VoipCallsService, VoipCallsService,
DaftaresomaClient,
SttClientService, SttClientService,
CallSummaryService, CallSummaryService,
], ],
+16
View File
@@ -246,7 +246,11 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.listIds(user, { return this.leads.listIds(user, {
q, q,
@@ -258,7 +262,11 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
@@ -276,8 +284,12 @@ export class LeadsController {
@Query('source') source?: string, @Query('source') source?: string,
@Query('unassigned') unassigned?: string, @Query('unassigned') unassigned?: string,
@Query('archived') archived?: string, @Query('archived') archived?: string,
@Query('converted') converted?: string,
@Query('sort') sort?: string, @Query('sort') sort?: string,
@Query('hasMobile') hasMobile?: string, @Query('hasMobile') hasMobile?: string,
@Query('followUp') followUp?: string,
@Query('createdFrom') createdFrom?: string,
@Query('createdTo') createdTo?: string,
) { ) {
return this.leads.list(user, { return this.leads.list(user, {
q, q,
@@ -291,8 +303,12 @@ export class LeadsController {
source, source,
unassigned: unassigned === '1' || unassigned === 'true', unassigned: unassigned === '1' || unassigned === 'true',
archived: archived === '1' || archived === 'true', archived: archived === '1' || archived === 'true',
converted: converted === '1' || converted === 'true',
sort, sort,
hasMobile: hasMobile === '1' || hasMobile === 'true', hasMobile: hasMobile === '1' || hasMobile === 'true',
followUp,
createdFrom,
createdTo,
}); });
} }
+77 -4
View File
@@ -49,6 +49,12 @@ import { StorageQuotaService } from '../billing/storage-quota.service';
import { LeadStagesService } from './lead-stages.service'; import { LeadStagesService } from './lead-stages.service';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import {
addDays,
isDayKey,
tehranDayKey,
tehranDayStart,
} from '../routines/routine-dates';
const LOGO_MAX_BYTES = 2 * 1024 * 1024; const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = { const LOGO_EXT: Record<string, string> = {
@@ -142,7 +148,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Prisma.LeadWhereInput { ): Prisma.LeadWhereInput {
const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [ const search = buildSearchWhere<Prisma.LeadWhereInput>(opts.q, (t) => [
@@ -161,13 +171,18 @@ export class LeadsService {
if (opts.hasMobile) { if (opts.hasMobile) {
and.push({ mobile: { not: '' } }); and.push({ mobile: { not: '' } });
} }
const follow = this.followUpWhere(opts.followUp);
if (follow) and.push(follow);
const createdRange = this.createdAtWhere(opts.createdFrom, opts.createdTo);
if (createdRange) and.push(createdRange);
return { return {
AND: [
{
organizationId: actor.organizationId, organizationId: actor.organizationId,
...leadAccessWhere(actor), ...leadAccessWhere(actor),
archivedAt: opts.archived ? { not: null } : null, archivedAt: opts.archived ? { not: null } : null,
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
...(opts.stageId ? { stageId: opts.stageId } : {}), ...(opts.stageId ? { stageId: opts.stageId } : {}),
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
...(opts.unassigned ? { assigneeId: null } : {}),
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}), ...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
...(opts.region ...(opts.region
? { region: { contains: opts.region, mode: 'insensitive' } } ? { region: { contains: opts.region, mode: 'insensitive' } }
@@ -176,7 +191,57 @@ export class LeadsService {
...(opts.source ...(opts.source
? { source: { contains: opts.source, mode: 'insensitive' } } ? { source: { contains: opts.source, mode: 'insensitive' } }
: {}), : {}),
...(and.length ? { AND: and } : {}), },
...(opts.unassigned ? [{ assigneeId: null }] : []),
...(opts.assigneeId && !opts.unassigned ? [{ assigneeId: opts.assigneeId }] : []),
...and,
],
};
}
/** مرز روز به وقت تهران برای فیلترهای «امروز». */
private tehranDayBounds(day = tehranDayKey()) {
const start = tehranDayStart(day);
const end = tehranDayStart(addDays(day, 1));
return { start, end };
}
private followUpWhere(followUp?: string): Prisma.LeadWhereInput | null {
if (!followUp) return null;
const { start, end } = this.tehranDayBounds();
switch (followUp) {
case 'due_today':
return { nextFollowUpAt: { gte: start, lt: end } };
case 'overdue':
return { nextFollowUpAt: { not: null, lt: start } };
case 'called_today':
return { lastContactAt: { gte: start, lt: end } };
case 'missed_today':
return {
nextFollowUpAt: { not: null, lt: end },
OR: [{ lastContactAt: null }, { lastContactAt: { lt: start } }],
};
case 'no_follow_up':
return { nextFollowUpAt: null };
default:
return null;
}
}
private createdAtWhere(
createdFrom?: string,
createdTo?: string,
): Prisma.LeadWhereInput | null {
const from = isDayKey(createdFrom) ? tehranDayStart(createdFrom) : null;
const to = isDayKey(createdTo)
? tehranDayStart(addDays(createdTo, 1))
: null;
if (!from && !to) return null;
return {
createdAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}; };
} }
@@ -194,8 +259,12 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
sort?: string; sort?: string;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
): Promise<PaginatedResponse<LeadDto>> { ): Promise<PaginatedResponse<LeadDto>> {
await this.stages.ensureDefaults(actor.organizationId); await this.stages.ensureDefaults(actor.organizationId);
@@ -243,7 +312,11 @@ export class LeadsService {
source?: string; source?: string;
unassigned?: boolean; unassigned?: boolean;
archived?: boolean; archived?: boolean;
converted?: boolean;
hasMobile?: boolean; hasMobile?: boolean;
followUp?: string;
createdFrom?: string;
createdTo?: string;
}, },
max = FILTERED_IDS_MAX, max = FILTERED_IDS_MAX,
): Promise<FilteredIdsResponse> { ): Promise<FilteredIdsResponse> {
@@ -1116,7 +1189,7 @@ export class LeadsService {
newStage newStage
? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } }) ? this.prisma.lead.count({ where: { ...base, stageId: newStage.id } })
: Promise.resolve(0), : Promise.resolve(0),
this.prisma.lead.count({ where: { ...base, assigneeId: null } }), this.prisma.lead.count({ where: { AND: [base, { assigneeId: null }] } }),
convertedIds.length convertedIds.length
? this.prisma.lead.count({ ? this.prisma.lead.count({
where: { ...base, stageId: { in: convertedIds } }, where: { ...base, stageId: { in: convertedIds } },
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -70,6 +71,16 @@ export class MembershipsService {
})); }));
} }
/** هر محیط یک مالک فعال دارد: همان کسی که محیط را ثبت کرده. */
async assertSoleOwner(organizationId: string, userId: string) {
const others = await this.countActiveOwners(organizationId, userId);
if (others > 0) {
throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
}
}
async createMembership(data: { async createMembership(data: {
userId: string; userId: string;
organizationId: string; organizationId: string;
@@ -78,6 +89,9 @@ export class MembershipsService {
commissionPercent?: number; commissionPercent?: number;
isActive?: boolean; isActive?: boolean;
}) { }) {
if (data.role === 'owner' && data.isActive !== false) {
await this.assertSoleOwner(data.organizationId, data.userId);
}
return this.prisma.membership.create({ return this.prisma.membership.create({
data: { data: {
userId: data.userId, userId: data.userId,
@@ -31,8 +31,11 @@ export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {} constructor(private readonly pipelinesService: PipelinesService) {}
@Get() @Get()
list(@CurrentUser() user: { organizationId: string }) { list(
return this.pipelinesService.list(user.organizationId); @CurrentUser() user: Actor,
@Query('forLens') forLens?: string,
) {
return this.pipelinesService.list(user, forLens === '1');
} }
@Get('board') @Get('board')
+152 -7
View File
@@ -4,6 +4,10 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
ActivityStatus,
ActivityType,
DealActivityCue,
DealActivitySummaryDto,
DealDto, DealDto,
MergePipelineResultDto, MergePipelineResultDto,
PipelineBoardDto, PipelineBoardDto,
@@ -102,24 +106,55 @@ export class PipelinesService {
return this.toPipelineDto(created); return this.toPipelineDto(created);
} }
async list(organizationId: string): Promise<PipelineDto[]> { async list(actorOrOrgId: Actor | string, forLens = false): Promise<PipelineDto[]> {
const organizationId =
typeof actorOrOrgId === 'string' ? actorOrOrgId : actorOrOrgId.organizationId;
const actor = typeof actorOrOrgId === 'string' ? undefined : actorOrOrgId;
await this.ensureDefaultPipeline(organizationId); await this.ensureDefaultPipeline(organizationId);
const rows = await this.prisma.pipeline.findMany({ const rows = await this.prisma.pipeline.findMany({
where: { organizationId }, where: { organizationId },
include: { stages: { orderBy: { position: 'asc' } } }, include: { stages: { orderBy: { position: 'asc' } } },
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }], orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
}); });
const counts = await this.prisma.deal.groupBy({ const lens = actor?.priorLens;
const lensActive = forLens && (lens === 'current' || lens === 'archive');
const access = actor ? dealAccessWhere(actor) : {};
const prior = lensActive
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
: {};
const [counts, lensCounts] = await Promise.all([
this.prisma.deal.groupBy({
by: ['pipelineId'], by: ['pipelineId'],
where: { organizationId }, where: lensActive ? { organizationId, AND: [access] } : { organizationId },
_count: { _all: true }, _count: { _all: true },
}); }),
lensActive
? this.prisma.deal.groupBy({
by: ['pipelineId'],
where: { organizationId, AND: [access, prior] },
_count: { _all: true },
})
: Promise.resolve([]),
]);
const dealCountByPipeline = new Map( const dealCountByPipeline = new Map(
counts.map((row) => [row.pipelineId, row._count._all]), counts.map((row) => [row.pipelineId, row._count._all]),
); );
return rows.map((row) => ({ const lensCountByPipeline = new Map(
lensCounts.map((row) => [row.pipelineId, row._count._all]),
);
const visible = lensActive
? rows.filter((row) => {
const inLens = lensCountByPipeline.get(row.id) ?? 0;
const total = dealCountByPipeline.get(row.id) ?? 0;
if (lens === 'archive') return inLens > 0;
return inLens > 0 || total === 0;
})
: rows;
return visible.map((row) => ({
...this.toPipelineDto(row), ...this.toPipelineDto(row),
dealCount: dealCountByPipeline.get(row.id) ?? 0, dealCount: lensActive
? (lensCountByPipeline.get(row.id) ?? 0)
: (dealCountByPipeline.get(row.id) ?? 0),
})); }));
} }
@@ -343,12 +378,23 @@ export class PipelinesService {
}); });
const rateMap = await loadOrgRates(this.prisma, organizationId); const rateMap = await loadOrgRates(this.prisma, organizationId);
const activityHints = await this.loadDealActivityHints(
organizationId,
deals.map((deal) => deal.id),
);
const byStage = new Map<string, DealDto[]>(); const byStage = new Map<string, DealDto[]>();
for (const stage of resolved.stages) byStage.set(stage.id, []); for (const stage of resolved.stages) byStage.set(stage.id, []);
for (const deal of deals) { for (const deal of deals) {
const list = byStage.get(deal.stageId); const list = byStage.get(deal.stageId);
if (list) list.push(this.toDealDto(deal)); if (list) {
const hint = activityHints.get(deal.id);
list.push({
...this.toDealDto(deal),
activityCue: hint?.cue ?? 'none',
activitySummaries: hint?.summaries ?? [],
});
}
} }
return { return {
@@ -566,6 +612,105 @@ export class PipelinesService {
}; };
} }
private async loadDealActivityHints(
organizationId: string,
dealIds: string[],
): Promise<
Map<string, { cue: DealActivityCue; summaries: DealActivitySummaryDto[] }>
> {
const hints = new Map<
string,
{ cue: DealActivityCue; summaries: DealActivitySummaryDto[] }
>();
if (!dealIds.length) return hints;
const rows = await this.prisma.activity.findMany({
where: {
organizationId,
dealId: { in: dealIds },
status: { in: ['open', 'done'] },
},
select: {
id: true,
dealId: true,
title: true,
type: true,
status: true,
dueAt: true,
completedAt: true,
},
});
type Row = {
id: string;
title: string;
type: string;
status: 'open' | 'done';
dueAt: Date | null;
completedAt: Date | null;
};
const byDeal = new Map<string, Row[]>();
for (const row of rows) {
if (!row.dealId) continue;
const list = byDeal.get(row.dealId) ?? [];
list.push({
id: row.id,
title: row.title,
type: row.type,
status: row.status === 'done' ? 'done' : 'open',
dueAt: row.dueAt,
completedAt: row.completedAt,
});
byDeal.set(row.dealId, list);
}
const now = Date.now();
const cueOf = (item: Row): Exclude<DealActivityCue, 'none'> => {
if (item.status === 'done') return 'done';
if (item.dueAt && item.dueAt.getTime() < now) return 'overdue';
return 'upcoming';
};
const rank = (cue: Exclude<DealActivityCue, 'none'>) =>
cue === 'overdue' ? 0 : cue === 'upcoming' ? 1 : 2;
for (const dealId of dealIds) {
const list = byDeal.get(dealId) ?? [];
const summaries = list
.map((item) => {
const cue = cueOf(item);
return {
id: item.id,
title: item.title,
type: item.type as ActivityType,
status: item.status as ActivityStatus,
dueAt: item.dueAt?.toISOString() ?? null,
cue,
_due: item.dueAt?.getTime() ?? Number.POSITIVE_INFINITY,
_done: item.completedAt?.getTime() ?? 0,
};
})
.sort((a, b) => {
const byRank = rank(a.cue) - rank(b.cue);
if (byRank) return byRank;
if (a.cue === 'done' && b.cue === 'done') return b._done - a._done;
return a._due - b._due;
})
.map(({ _due: _d, _done: _c, ...summary }) => summary);
const open = list.filter((item) => item.status === 'open');
let cue: DealActivityCue = 'none';
if (open.some((item) => item.dueAt && item.dueAt.getTime() < now)) {
cue = 'overdue';
} else if (open.length) {
cue = 'upcoming';
} else if (list.some((item) => item.status === 'done')) {
cue = 'done';
}
hints.set(dealId, { cue, summaries });
}
return hints;
}
toDealDto(row: DealRow): DealDto { toDealDto(row: DealRow): DealDto {
return { return {
id: row.id, id: row.id,
+6 -1
View File
@@ -13,7 +13,7 @@ import {
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
import { USER_ROLES, type UserRole } from '@mento/shared'; import { PRODUCT_PLAN_CODES, USER_ROLES, type ProductPlanCode, type UserRole } from '@mento/shared';
export class PlatformLoginDto { export class PlatformLoginDto {
@IsString() @IsString()
@@ -189,6 +189,11 @@ export class SetStorageQuotaDto {
quotaGb!: number | null; quotaGb!: number | null;
} }
export class ApplyProductPlanDto {
@IsIn(PRODUCT_PLAN_CODES)
planCode!: ProductPlanCode;
}
export class UpdateOrgModulesDto { export class UpdateOrgModulesDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -13,6 +13,7 @@ import {
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import { import {
ApplyBillingPlanDto, ApplyBillingPlanDto,
ApplyProductPlanDto,
SetStorageQuotaDto, SetStorageQuotaDto,
SetUserCapDto, SetUserCapDto,
CreatePlatformAdminDto, CreatePlatformAdminDto,
@@ -168,6 +169,11 @@ export class PlatformController {
return this.platform.setUserCap(id, dto.maxUsers); return this.platform.setUserCap(id, dto.maxUsers);
} }
@Post('organizations/:id/product-plan')
applyProductPlan(@Param('id') id: string, @Body() dto: ApplyProductPlanDto) {
return this.platform.applyProductPlan(id, dto.planCode);
}
@Patch('organizations/:id/modules') @Patch('organizations/:id/modules')
updateOrganizationModules( updateOrganizationModules(
@Param('id') id: string, @Param('id') id: string,
+18 -1
View File
@@ -9,6 +9,7 @@ import type {
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformStatsDto, PlatformStatsDto,
PlatformUserDto, PlatformUserDto,
ProductPlanCode,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { isBusinessField } from '@mento/shared'; import { isBusinessField } from '@mento/shared';
@@ -472,6 +473,16 @@ export class PlatformService {
return this.getUser(userId); return this.getUser(userId);
} }
async applyProductPlan(id: string, planCode: ProductPlanCode) {
const existing = await this.prisma.organization.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('محیط پیدا نشد');
await this.billing.applyProductPlan(id, planCode);
return this.getOrganization(id);
}
async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) { async updateOrganizationModules(id: string, dto: UpdateOrgModulesDto) {
const existing = await this.prisma.organization.findUnique({ const existing = await this.prisma.organization.findUnique({
where: { id }, where: { id },
@@ -532,6 +543,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (isActive && !row.isActive) { if (isActive && !row.isActive) {
if (row.role === 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
await this.billing.assertSeatAvailable(row.organizationId, row.userId); await this.billing.assertSeatAvailable(row.organizationId, row.userId);
} }
if (!isActive && row.role === 'owner') { if (!isActive && row.role === 'owner') {
@@ -562,6 +576,9 @@ export class PlatformService {
}); });
if (!row) throw new NotFoundException('عضویت پیدا نشد'); if (!row) throw new NotFoundException('عضویت پیدا نشد');
if (role === 'owner' && row.role !== 'owner') {
await this.memberships.assertSoleOwner(row.organizationId, row.userId);
}
if (row.role === 'owner' && role !== 'owner') { if (row.role === 'owner' && role !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
row.organizationId, row.organizationId,
@@ -569,7 +586,7 @@ export class PlatformService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
+21 -264
View File
@@ -18,6 +18,7 @@ import {
} from '@mento/shared'; } from '@mento/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customersPersonWhere } from '../common/data-access';
import { import {
type Actor, type Actor,
assigneeWhere, assigneeWhere,
@@ -54,7 +55,7 @@ export class ReportsService {
const invoiceScope = ownerWhere(actor, 'finance'); const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor); const ticketScope = ticketAccessWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const durationMs = end.getTime() - start.getTime(); const durationMs = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1); const prevEnd = new Date(start.getTime() - 1);
@@ -1533,9 +1534,8 @@ export class ReportsService {
async home(actor: Actor): Promise<HomeDashboardDto> { async home(actor: Actor): Promise<HomeDashboardDto> {
const orgId = actor.organizationId; const orgId = actor.organizationId;
const dealScope = ownerWhere(actor, 'deals'); const dealScope = ownerWhere(actor, 'deals');
const invoiceScope = ownerWhere(actor, 'finance');
const activityScope = assigneeWhere(actor); const activityScope = assigneeWhere(actor);
const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; const personScope = customersPersonWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId); const rates = await loadOrgRates(this.prisma, orgId);
const now = new Date(); const now = new Date();
const { jy, jm } = dateToJalali(now); const { jy, jm } = dateToJalali(now);
@@ -1544,7 +1544,6 @@ export class ReportsService {
const prev = addJalaliMonths(jy, jm, -1); const prev = addJalaliMonths(jy, jm, -1);
const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm); const prevMonthStart = startOfJalaliMonth(prev.jy, prev.jm);
const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm); const prevMonthEnd = endOfJalaliMonth(prev.jy, prev.jm);
const trendWindowStart = addMonthsStart(now, -12);
const todayStart = startOfDay(now); const todayStart = startOfDay(now);
const todayEnd = endOfDay(now); const todayEnd = endOfDay(now);
@@ -1557,25 +1556,17 @@ export class ReportsService {
const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff); const dealsPrior = dealPriorWhere(actor.priorLens, actor.priorCutoff);
const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff); const activitiesPrior = activityPriorWhere(actor.priorLens, actor.priorCutoff);
const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [
'issued',
'partially_paid',
'paid',
];
const [ const [
wonDeals, wonDeals,
openDealRows, openDeals,
lostDeals,
staleDeals, staleDeals,
peopleRecent, peopleCur,
peoplePrev,
openActivitiesToday, openActivitiesToday,
openActivitiesYesterday, openActivitiesYesterday,
openDealsWeekAgo, openDealsWeekAgo,
goals, goals,
overdueCount, overdueCount,
invoices,
activityTypeGroups,
] = await Promise.all([ ] = await Promise.all([
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
@@ -1583,7 +1574,7 @@ export class ReportsService {
{ {
organizationId: orgId, organizationId: orgId,
status: 'won', status: 'won',
updatedAt: { gte: trendWindowStart }, updatedAt: { gte: addMonthsStart(now, -5) },
}, },
dealScope, dealScope,
dealsPrior, dealsPrior,
@@ -1600,7 +1591,7 @@ export class ReportsService {
person: { select: { id: true, firstName: true, lastName: true } }, person: { select: { id: true, firstName: true, lastName: true } },
}, },
}), }),
this.prisma.deal.findMany({ this.prisma.deal.count({
where: { where: {
AND: [ AND: [
{ organizationId: orgId, status: 'open' }, { organizationId: orgId, status: 'open' },
@@ -1608,30 +1599,6 @@ export class ReportsService {
dealsPrior, dealsPrior,
], ],
}, },
select: {
amount: true,
currency: true,
stageId: true,
stage: { select: { id: true, name: true, position: true } },
},
}),
this.prisma.deal.findMany({
where: {
AND: [
{
organizationId: orgId,
status: 'lost',
updatedAt: { gte: trendWindowStart },
},
dealScope,
dealsPrior,
],
},
select: {
amount: true,
currency: true,
lostReason: true,
},
}), }),
this.prisma.deal.findMany({ this.prisma.deal.findMany({
where: { where: {
@@ -1658,13 +1625,19 @@ export class ReportsService {
orderBy: { updatedAt: 'asc' }, orderBy: { updatedAt: 'asc' },
take: 5, take: 5,
}), }),
this.prisma.person.findMany({ this.prisma.person.count({
where: { where: {
organizationId: orgId, organizationId: orgId,
...personScope, ...personScope,
createdAt: { gte: trendWindowStart, lte: curMonthEnd }, createdAt: { gte: curMonthStart, lte: curMonthEnd },
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
...personScope,
createdAt: { gte: prevMonthStart, lte: prevMonthEnd },
}, },
select: { createdAt: true, importSource: true },
}), }),
this.prisma.activity.count({ this.prisma.activity.count({
where: { where: {
@@ -1684,7 +1657,6 @@ export class ReportsService {
AND: [ AND: [
{ {
organizationId: orgId, organizationId: orgId,
status: 'open',
dueAt: { gte: yesterdayStart, lte: yesterdayEnd }, dueAt: { gte: yesterdayStart, lte: yesterdayEnd },
}, },
activityScope, activityScope,
@@ -1726,67 +1698,8 @@ export class ReportsService {
], ],
}, },
}), }),
this.prisma.invoice.findMany({
where: {
organizationId: orgId,
...invoiceScope,
status: { in: invoiceStatuses },
OR: [
{ issuedAt: { gte: trendWindowStart, lte: curMonthEnd } },
{ createdAt: { gte: trendWindowStart, lte: curMonthEnd } },
],
},
select: {
issuedAt: true,
createdAt: true,
lineItems: {
select: {
quantity: true,
unitPrice: true,
currency: true,
},
},
},
}),
this.prisma.activity.groupBy({
by: ['type'],
where: {
AND: [
{
organizationId: orgId,
status: 'open',
},
activityScope,
activitiesPrior,
],
},
_count: { _all: true },
}),
]); ]);
const openDeals = openDealRows.length;
const invoiceInRange = (
inv: { issuedAt: Date | null; createdAt: Date },
from: Date,
to: Date,
) => {
const anchor = inv.issuedAt ?? inv.createdAt;
return anchor >= from && anchor <= to;
};
const sumInvoiceInRange = (from: Date, to: Date) => {
let amount = 0;
for (const inv of invoices) {
if (!invoiceInRange(inv, from, to)) continue;
for (const line of inv.lineItems) {
const lineTotal = Number(line.quantity) * Number(line.unitPrice);
amount += toBaseAmount(lineTotal, line.currency, rates);
}
}
return amount;
};
const sumWonInRange = (start: Date, end: Date) => const sumWonInRange = (start: Date, end: Date) =>
wonDeals wonDeals
.filter((d) => d.updatedAt >= start && d.updatedAt <= end) .filter((d) => d.updatedAt >= start && d.updatedAt <= end)
@@ -1795,21 +1708,8 @@ export class ReportsService {
0, 0,
); );
const salesInRange = (start: Date, end: Date) => { const salesCur = sumWonInRange(curMonthStart, curMonthEnd);
const inv = sumInvoiceInRange(start, end); const salesPrev = sumWonInRange(prevMonthStart, prevMonthEnd);
const won = sumWonInRange(start, end);
return Math.round(inv > 0 ? inv : won);
};
const peopleCur = peopleRecent.filter(
(p) => p.createdAt >= curMonthStart && p.createdAt <= curMonthEnd,
).length;
const peoplePrev = peopleRecent.filter(
(p) => p.createdAt >= prevMonthStart && p.createdAt <= prevMonthEnd,
).length;
const salesCur = salesInRange(curMonthStart, curMonthEnd);
const salesPrev = salesInRange(prevMonthStart, prevMonthEnd);
const targetAmount = goals.reduce( const targetAmount = goals.reduce(
(acc, g) => acc + Number(g.targetAmount), (acc, g) => acc + Number(g.targetAmount),
0, 0,
@@ -1817,7 +1717,7 @@ export class ReportsService {
const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> = const trendMonths: Array<{ key: string; label: string; start: Date; end: Date }> =
[]; [];
for (let i = 11; i >= 0; i -= 1) { for (let i = 4; i >= 0; i -= 1) {
const m = addJalaliMonths(jy, jm, -i); const m = addJalaliMonths(jy, jm, -i);
trendMonths.push({ trendMonths.push({
key: `${m.jy}-${String(m.jm).padStart(2, '0')}`, key: `${m.jy}-${String(m.jm).padStart(2, '0')}`,
@@ -1830,28 +1730,14 @@ export class ReportsService {
const salesTrend = trendMonths.map((m) => ({ const salesTrend = trendMonths.map((m) => ({
monthKey: m.key, monthKey: m.key,
monthLabel: m.label, monthLabel: m.label,
amount: salesInRange(m.start, m.end), amount: Math.round(sumWonInRange(m.start, m.end)),
}));
const customerGrowth = trendMonths.map((m) => ({
monthKey: m.key,
monthLabel: m.label,
value: peopleRecent.filter(
(p) => p.createdAt >= m.start && p.createdAt <= m.end,
).length,
})); }));
const sourceMap = new Map<string, number>(); const sourceMap = new Map<string, number>();
for (const p of peopleRecent) {
const label = normalizeImportSource(p.importSource);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
}
if (sourceMap.size === 0) {
for (const d of wonDeals) { for (const d of wonDeals) {
const label = normalizeSource(d.source); const label = normalizeSource(d.source);
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1); sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
} }
}
const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1; const sourceTotal = [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1;
const sources = [...sourceMap.entries()] const sources = [...sourceMap.entries()]
.map(([label, count]) => ({ .map(([label, count]) => ({
@@ -1895,101 +1781,6 @@ export class ReportsService {
.slice(0, 5) .slice(0, 5)
.map((row, index) => ({ ...row, rank: index + 1 })); .map((row, index) => ({ ...row, rank: index + 1 }));
const wonAmountAll = Math.round(
wonDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const lostAmountAll = Math.round(
lostDeals.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const openAmountAll = Math.round(
openDealRows.reduce(
(acc, d) => acc + decimalToBase(d.amount, d.currency, rates),
0,
),
);
const closedCount = wonDeals.length + lostDeals.length;
const winRate = closedCount
? Math.round((wonDeals.length / closedCount) * 1000) / 10
: 0;
const salesSummary: HomeDashboardDto['salesSummary'] = {
openCount: openDeals,
wonCount: wonDeals.length,
lostCount: lostDeals.length,
openAmount: openAmountAll,
wonAmount: wonAmountAll,
lostAmount: lostAmountAll,
winRate,
};
const funnelMap = new Map<
string,
{ stageId: string; stageName: string; position: number; count: number; amount: number }
>();
for (const d of openDealRows) {
const stageId = d.stage?.id ?? d.stageId ?? 'unknown';
const stageName = d.stage?.name ?? 'بدون مرحله';
const position = d.stage?.position ?? 999;
const cur = funnelMap.get(stageId) ?? {
stageId,
stageName,
position,
count: 0,
amount: 0,
};
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
funnelMap.set(stageId, cur);
}
const funnel = [...funnelMap.values()]
.sort((a, b) => a.position - b.position)
.slice(0, 8)
.map((row) => ({
stageId: row.stageId,
stageName: row.stageName,
count: row.count,
amount: Math.round(row.amount),
}));
const lostReasonMap = new Map<string, { count: number; amount: number }>();
for (const d of lostDeals) {
const label = normalizeLostReason(d.lostReason);
const prevRow = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
prevRow.count += 1;
prevRow.amount += decimalToBase(d.amount, d.currency, rates);
lostReasonMap.set(label, prevRow);
}
const lostReasonTotal =
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
const lostReasons = [...lostReasonMap.entries()]
.map(([label, row]) => ({
label,
count: row.count,
amount: Math.round(row.amount),
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 6);
const activityTypeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'کار',
note: 'یادداشت',
};
const activityByType = activityTypeGroups
.map((row) => ({
type: row.type,
label: activityTypeLabel[row.type] ?? row.type,
count: row._count._all,
}))
.sort((a, b) => b.count - a.count);
const alerts: HomeDashboardDto['alerts'] = []; const alerts: HomeDashboardDto['alerts'] = [];
if (staleDeals.length) { if (staleDeals.length) {
const first = staleDeals[0]!; const first = staleDeals[0]!;
@@ -2067,30 +1858,11 @@ export class ReportsService {
changePercent: pctChange(openActivitiesToday, openActivitiesYesterday), changePercent: pctChange(openActivitiesToday, openActivitiesYesterday),
changeLabel: 'نسبت به دیروز', changeLabel: 'نسبت به دیروز',
}, },
{
key: 'win-rate',
label: 'نرخ برد',
value: winRate,
changePercent: null,
changeLabel: '۱۲ ماه اخیر',
},
{
key: 'overdue',
label: 'عقب‌افتاده',
value: overdueCount,
changePercent: null,
changeLabel: 'فعالیت باز',
},
], ],
alerts, alerts,
salesTrend, salesTrend,
customerGrowth,
sources, sources,
topCustomers, topCustomers,
salesSummary,
funnel,
lostReasons,
activityByType,
}; };
} }
} }
@@ -2148,21 +1920,6 @@ function normalizeSource(raw: string | null | undefined): string {
return map[value.toLowerCase()] ?? value; return map[value.toLowerCase()] ?? value;
} }
function normalizeImportSource(raw: string | null | undefined): string {
const value = raw?.trim().toLowerCase();
if (!value) return 'سایر';
const map: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'تلفن',
lead_form: 'فرم لید',
other: 'سایر',
};
return map[value] ?? normalizeSource(raw);
}
function normalizeLostReason(raw: string | null | undefined): string { function normalizeLostReason(raw: string | null | undefined): string {
const value = raw?.trim(); const value = raw?.trim();
if (!value) return 'بدون دلیل ثبت‌شده'; if (!value) return 'بدون دلیل ثبت‌شده';
+212
View File
@@ -0,0 +1,212 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_INVOICE_STATUSES = ['issued', 'partially_paid', 'paid'] as const;
export type InvoicePartyPosition = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
/** فاکتورهای عادی به‌علاوهٔ ماندهٔ اول دورهٔ مثبت */
billed: Prisma.Decimal;
/** برگشتی‌ها و اضافه‌دریافت، برای ستون تسویه‌شدهٔ بدهکار */
offset: Prisma.Decimal;
/** مثبت = طلب ما، منفی = بدهی ما */
net: Prisma.Decimal;
};
type Bucket = {
key: string;
partyKind: 'person' | 'company';
partyName: string;
sales: Prisma.Decimal;
credits: Prisma.Decimal;
paid: Prisma.Decimal;
opening: Prisma.Decimal;
};
function zero() {
return new Prisma.Decimal(0);
}
function round2(value: Prisma.Decimal) {
return value.toDecimalPlaces(2);
}
/** همان فرمول ماندهٔ فاکتور: جمع ردیف‌ها − تخفیف + مالیات، برگشتی منفی. */
export function signedInvoicePayable(invoice: {
kind: string;
discountAmount: Prisma.Decimal;
taxPercent: Prisma.Decimal;
lineItems: Array<{ quantity: Prisma.Decimal; unitPrice: Prisma.Decimal }>;
}) {
const subtotal = invoice.lineItems.reduce(
(sum, line) => sum.plus(line.quantity.mul(line.unitPrice)),
zero(),
);
const discount = invoice.discountAmount ?? zero();
const taxable = Prisma.Decimal.max(subtotal.minus(discount), 0);
const tax = taxable.mul(invoice.taxPercent ?? 0).div(100);
const signed = round2(subtotal.minus(discount).plus(tax));
return invoice.kind === 'credit' ? signed.neg() : signed;
}
export async function internalBooksEnabled(prisma: PrismaService, organizationId: string) {
const connections = await prisma.accountingConnection.count({
where: { organizationId, enabled: true },
});
return connections === 0;
}
/**
* ماندهٔ طرف‌حساب از فاکتور صادرشده و ماندهٔ اول دوره.
* سند درآمد جدا ساخته نمی‌شود تا همان فروش دو بار طلب نشود.
* اگر حسابداری بیرونی وصل باشد، خالی برمی‌گردد.
*/
export async function loadInvoicePartyPositions(
prisma: PrismaService,
organizationId: string,
): Promise<InvoicePartyPosition[]> {
if (!(await internalBooksEnabled(prisma, organizationId))) return [];
const [invoices, openings] = await Promise.all([
prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
personId: true,
companyId: true,
person: { select: { firstName: true, lastName: true } },
company: { select: { name: true } },
lineItems: { select: { quantity: true, unitPrice: true } },
payments: { select: { amount: true } },
},
}),
prisma.partyOpeningBalance.findMany({
where: { organizationId },
select: {
personId: true,
amount: true,
person: { select: { firstName: true, lastName: true } },
},
}),
]);
const buckets = new Map<string, Bucket>();
const ensure = (
key: string,
partyKind: 'person' | 'company',
partyName: string,
) => {
const current = buckets.get(key);
if (current) return current;
const created: Bucket = {
key,
partyKind,
partyName,
sales: zero(),
credits: zero(),
paid: zero(),
opening: zero(),
};
buckets.set(key, created);
return created;
};
for (const invoice of invoices) {
const personId = invoice.personId;
const companyId = invoice.companyId;
const party = personId
? {
key: `person:${personId}`,
partyKind: 'person' as const,
partyName:
`${invoice.person?.firstName ?? ''} ${invoice.person?.lastName ?? ''}`.trim() ||
'مشتری',
}
: companyId
? {
key: `company:${companyId}`,
partyKind: 'company' as const,
partyName: invoice.company?.name?.trim() || 'شرکت',
}
: null;
if (!party) continue;
const bucket = ensure(party.key, party.partyKind, party.partyName);
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) bucket.sales = bucket.sales.plus(payable);
else if (payable.lessThan(0)) bucket.credits = bucket.credits.plus(payable.abs());
const paid = invoice.payments.reduce((sum, row) => sum.plus(row.amount), zero());
bucket.paid = bucket.paid.plus(paid);
}
for (const opening of openings) {
if (opening.amount.equals(0)) continue;
const bucket = ensure(
`person:${opening.personId}`,
'person',
`${opening.person.firstName} ${opening.person.lastName}`.trim() || 'مشتری',
);
bucket.opening = bucket.opening.plus(opening.amount);
}
const positions: InvoicePartyPosition[] = [];
for (const bucket of buckets.values()) {
const net = round2(
bucket.opening.plus(bucket.sales).minus(bucket.credits).minus(bucket.paid),
);
if (net.equals(0)) continue;
const billed = round2(bucket.sales.plus(Prisma.Decimal.max(bucket.opening, 0)));
const offset = round2(billed.minus(net));
positions.push({
key: bucket.key,
partyKind: bucket.partyKind,
partyName: bucket.partyName,
billed,
offset: Prisma.Decimal.max(offset, 0),
net,
});
}
return positions;
}
/** فروش و برگشتیِ فاکتورهای صادرشده در بازه. وجه نقد را دوباره جمع نمی‌کند. */
export async function invoiceSalesInPeriod(
prisma: PrismaService,
organizationId: string,
from: Date | null,
to: Date | null,
) {
const empty = { sales: '0.00', credits: '0.00', net: '0.00' };
if (!(await internalBooksEnabled(prisma, organizationId))) return empty;
const invoices = await prisma.invoice.findMany({
where: { organizationId, status: { in: [...OPEN_INVOICE_STATUSES] } },
select: {
kind: true,
discountAmount: true,
taxPercent: true,
issuedAt: true,
createdAt: true,
lineItems: { select: { quantity: true, unitPrice: true } },
},
});
let sales = zero();
let credits = zero();
for (const invoice of invoices) {
const when = invoice.issuedAt ?? invoice.createdAt;
if (from && when < from) continue;
if (to && when > to) continue;
const payable = signedInvoicePayable(invoice);
if (payable.greaterThan(0)) sales = sales.plus(payable);
else if (payable.lessThan(0)) credits = credits.plus(payable.abs());
}
return {
sales: round2(sales).toFixed(2),
credits: round2(credits).toFixed(2),
net: round2(sales.minus(credits)).toFixed(2),
};
}
+20 -10
View File
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { import {
parseUserTreasuryAccess, parseUserTreasuryAccess,
permissionsWithTreasuryOverride,
treasurySectionAllowed, treasurySectionAllowed,
type TreasurySectionKey, type TreasurySectionKey,
} from '@mento/shared'; } from '@mento/shared';
@@ -16,7 +17,12 @@ export class TreasuryAccessGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<{ user?: Actor; originalUrl?: string; url?: string }>(); const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user; const actor = req.user;
if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.'); if (!actor) throw new ForbiddenException('دسترسی مالی مجاز نیست.');
if (actor.role === 'owner' || actor.role === 'admin') return true; if (actor.role === 'owner' || actor.role === 'admin') return true;
@@ -30,11 +36,18 @@ export class TreasuryAccessGuard implements CanActivate {
const rolePermissions = Array.isArray(membership?.orgRole?.permissions) const rolePermissions = Array.isArray(membership?.orgRole?.permissions)
? membership.orgRole.permissions.map(String) ? membership.orgRole.permissions.map(String)
: []; : [];
const permissions = permissionsForTreasury( const permissions = permissionsWithTreasuryOverride(
rolePermissions, rolePermissions,
parseUserTreasuryAccess(membership?.treasuryAccessOverride), parseUserTreasuryAccess(membership?.treasuryAccessOverride),
); );
const method = (req.method || 'GET').toUpperCase();
const url = req.originalUrl || req.url || ''; const url = req.originalUrl || req.url || '';
if (
isLookupForDocuments(method, url) &&
treasurySectionAllowed(actor.role, permissions, 'treasury_documents')
) {
return true;
}
if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) { if (!treasurySectionAllowed(actor.role, permissions, sectionForUrl(url))) {
throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.'); throw new ForbiddenException('به این بخش مدیریت مالی دسترسی ندارید.');
} }
@@ -42,14 +55,11 @@ export class TreasuryAccessGuard implements CanActivate {
} }
} }
function permissionsForTreasury( /** فهرست حساب و دسته برای ثبت سند لازم است؛ صفحهٔ مدیریت‌شان جدا می‌ماند. */
rolePermissions: string[], function isLookupForDocuments(method: string, url: string): boolean {
override: ReturnType<typeof parseUserTreasuryAccess>, if (method !== 'GET' && method !== 'HEAD') return false;
): string[] { const path = (url.split('?')[0] || '').replace(/\/+$/, '');
if (!override) return rolePermissions; return path.endsWith('/treasury/accounts') || path.endsWith('/treasury/categories');
const rest = rolePermissions.filter((key) => key !== 'treasury' && !key.startsWith('treasury_'));
if (!override.enabled) return rest;
return ['treasury', ...override.sections, ...rest];
} }
function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' { function sectionForUrl(url: string): TreasurySectionKey | 'treasury_documents' {
+180 -6
View File
@@ -5,6 +5,7 @@ import ExcelJS from 'exceljs';
import type { TreasuryPartyBalanceDto } from '@mento/shared'; import type { TreasuryPartyBalanceDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { invoiceSalesInPeriod } from './invoice-ledger';
function dec(value: Prisma.Decimal | number | string | null | undefined) { function dec(value: Prisma.Decimal | number | string | null | undefined) {
return new Prisma.Decimal(value ?? 0); return new Prisma.Decimal(value ?? 0);
@@ -12,11 +13,20 @@ function dec(value: Prisma.Decimal | number | string | null | undefined) {
function money(value: Prisma.Decimal | number | string) { function money(value: Prisma.Decimal | number | string) {
return new Prisma.Decimal(value).toFixed(2); return new Prisma.Decimal(value).toFixed(2);
} }
function reasonText(value?: string | null) {
const text = value?.trim() ?? '';
return text ? text.slice(0, 500) : null;
}
function parseDate(iso?: string | null) { function parseDate(iso?: string | null) {
if (!iso) return null; if (!iso) return null;
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
function payrollStatus(payable: Prisma.Decimal, paid: Prisma.Decimal) {
if (paid.lessThanOrEqualTo(0)) return 'open';
if (paid.greaterThanOrEqualTo(payable)) return 'settled';
return 'partial';
}
function periodKey(date: Date) { function periodKey(date: Date) {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
@@ -36,9 +46,15 @@ export class TreasuryOpsService {
async listPayroll(actor: Actor) { async listPayroll(actor: Actor) {
const rows = await this.prisma.financialPayroll.findMany({ const rows = await this.prisma.financialPayroll.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
orderBy: { dueDate: 'desc' }, orderBy: { dueDate: 'desc' },
take: 100, take: 500,
}); });
const users = await this.prisma.user.findMany({ const users = await this.prisma.user.findMany({
where: { id: { in: rows.map((row) => row.userId) } }, where: { id: { in: rows.map((row) => row.userId) } },
@@ -55,8 +71,11 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: string | number; baseAmount: string | number;
benefits?: string | number; benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number; bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number; deductions?: string | number;
deductionsReason?: string | null;
dueDate?: string; dueDate?: string;
notes?: string | null; notes?: string | null;
}, },
@@ -81,14 +100,23 @@ export class TreasuryOpsService {
period: input.period.trim(), period: input.period.trim(),
baseAmount: base, baseAmount: base,
benefits, benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus, bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions, deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable, payable,
dueDate: parseDate(input.dueDate) ?? new Date(), dueDate: parseDate(input.dueDate) ?? new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
createdById: actor.userId, createdById: actor.userId,
}, },
include: { movements: true }, include: {
movements: {
where: { voidedAt: null },
include: { account: { select: { name: true } } },
orderBy: { occurredAt: 'desc' },
},
},
}); });
const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } }); const user = await this.prisma.user.findUnique({ where: { id: row.userId }, select: { fullName: true } });
return this.payrollDto(row, user?.fullName ?? ''); return this.payrollDto(row, user?.fullName ?? '');
@@ -124,7 +152,7 @@ export class TreasuryOpsService {
if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.'); if (!account) throw new BadRequestException('حساب پرداخت پیدا نشد.');
const balance = await this.balance(account.id); const balance = await this.balance(account.id);
if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.'); if (balance.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
const status = paid.plus(amount).equals(row.payable) ? 'settled' : 'partial'; const status = payrollStatus(row.payable, paid.plus(amount));
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.financialMovement.create({ this.prisma.financialMovement.create({
data: { data: {
@@ -145,6 +173,123 @@ export class TreasuryOpsService {
return this.listPayroll(actor); return this.listPayroll(actor);
} }
async updatePayroll(
actor: Actor,
id: string,
input: {
userId: string;
period: string;
baseAmount: string | number;
benefits?: string | number;
benefitsReason?: string | null;
bonus?: string | number;
bonusReason?: string | null;
deductions?: string | number;
deductionsReason?: string | null;
},
) {
const row = await this.prisma.financialPayroll.findFirst({
where: { id, organizationId: actor.organizationId },
include: { movements: true },
});
if (!row || row.voidedAt || row.status === 'void') {
throw new NotFoundException('رکورد حقوق برای ویرایش پیدا نشد.');
}
const base = dec(input.baseAmount);
const benefits = dec(input.benefits ?? 0);
const bonus = dec(input.bonus ?? 0);
const deductions = dec(input.deductions ?? 0);
const payable = base.plus(benefits).plus(bonus).minus(deductions);
if (!input.userId || !input.period?.trim()) {
throw new BadRequestException('پرسنل و دوره حقوق لازم است.');
}
if (!payable.greaterThan(0)) throw new BadRequestException('مبلغ قابل پرداخت باید بزرگ‌تر از صفر باشد.');
const paid = this.paidOf(row.movements);
if (payable.lessThan(paid)) {
throw new BadRequestException('مبلغ قابل پرداخت از پرداخت‌های ثبت‌شده کمتر است.');
}
const member = await this.prisma.membership.findFirst({
where: { organizationId: actor.organizationId, userId: input.userId, isActive: true },
});
if (!member) throw new BadRequestException('این کاربر عضو محیط نیست.');
try {
await this.prisma.financialPayroll.update({
where: { id: row.id },
data: {
userId: input.userId,
period: input.period.trim(),
baseAmount: base,
benefits,
benefitsReason: benefits.greaterThan(0) ? reasonText(input.benefitsReason) : null,
bonus,
bonusReason: bonus.greaterThan(0) ? reasonText(input.bonusReason) : null,
deductions,
deductionsReason: deductions.greaterThan(0) ? reasonText(input.deductionsReason) : null,
payable,
status: payrollStatus(payable, paid),
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new BadRequestException('برای این پرسنل در این دوره حقوق ثبت شده است.');
}
throw error;
}
return this.listPayroll(actor);
}
async updatePayrollPayment(
actor: Actor,
payrollId: string,
movementId: string,
input: { amount: string | number; accountId: string; occurredAt?: string },
) {
const movement = await this.prisma.financialMovement.findFirst({
where: {
id: movementId,
payrollId,
organizationId: actor.organizationId,
voidedAt: null,
},
});
const payroll = await this.prisma.financialPayroll.findFirst({
where: { id: payrollId, organizationId: actor.organizationId },
include: { movements: true },
});
if (!movement || !payroll || payroll.voidedAt || payroll.status === 'void') {
throw new NotFoundException('پرداخت حقوق برای ویرایش پیدا نشد.');
}
const amount = dec(input.amount);
const otherPaid = this.paidOf(payroll.movements.filter((item) => item.id !== movement.id));
if (!amount.greaterThan(0) || otherPaid.plus(amount).greaterThan(payroll.payable)) {
throw new BadRequestException('مبلغ پرداخت حقوق نامعتبر است.');
}
const account = await this.prisma.financialAccount.findFirst({
where: { id: input.accountId, organizationId: actor.organizationId },
});
if (!account || (!account.isActive && account.id !== movement.accountId)) {
throw new BadRequestException('حساب پرداخت پیدا نشد.');
}
const available = await this.balance(account.id);
const room = account.id === movement.accountId ? available.plus(movement.amount) : available;
if (room.lessThan(amount)) throw new BadRequestException('موجودی حساب کافی نیست.');
await this.prisma.$transaction([
this.prisma.financialMovement.update({
where: { id: movement.id },
data: {
accountId: account.id,
amount,
occurredAt: parseDate(input.occurredAt) ?? movement.occurredAt,
},
}),
this.prisma.financialPayroll.update({
where: { id: payroll.id },
data: { status: payrollStatus(payroll.payable, otherPaid.plus(amount)) },
}),
]);
return this.listPayroll(actor);
}
async listRecurring(actor: Actor) { async listRecurring(actor: Actor) {
const rows = await this.prisma.financialRecurringItem.findMany({ const rows = await this.prisma.financialRecurringItem.findMany({
where: { organizationId: actor.organizationId }, where: { organizationId: actor.organizationId },
@@ -327,6 +472,8 @@ export class TreasuryOpsService {
sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']); sheet.addRow(['شاخص', 'این دوره', 'دوره قبل']);
const rows: Array<[string, string, string]> = [ const rows: Array<[string, string, string]> = [
['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome], ['درآمد ثبت‌شده', data.current.bookedIncome, data.previous.bookedIncome],
['فروش فاکتور', data.current.invoiceSales, data.previous.invoiceSales],
['فاکتور برگشتی', data.current.invoiceCredits, data.previous.invoiceCredits],
['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense], ['هزینه ثبت‌شده', data.current.bookedExpense, data.previous.bookedExpense],
['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit], ['سود عملیاتی', data.current.operatingProfit, data.previous.operatingProfit],
['ورود وجه', data.current.cashIn, data.previous.cashIn], ['ورود وجه', data.current.cashIn, data.previous.cashIn],
@@ -411,10 +558,15 @@ export class TreasuryOpsService {
amount: money(amount), amount: money(amount),
share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1), share: bookedExpense.equals(0) ? '0' : amount.div(bookedExpense).mul(100).toFixed(1),
})); }));
const invoiceSales = await invoiceSalesInPeriod(this.prisma, organizationId, from, to);
return { return {
bookedIncome: money(bookedIncome), bookedIncome: money(bookedIncome),
invoiceSales: invoiceSales.sales,
invoiceCredits: invoiceSales.credits,
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(
bookedIncome.plus(invoiceSales.net).minus(bookedExpense),
),
cashIn: money(cashIn), cashIn: money(cashIn),
cashOut: money(cashOut), cashOut: money(cashOut),
cashNet: money(cashIn.minus(cashOut)), cashNet: money(cashIn.minus(cashOut)),
@@ -475,12 +627,22 @@ export class TreasuryOpsService {
period: string; period: string;
baseAmount: Prisma.Decimal; baseAmount: Prisma.Decimal;
benefits: Prisma.Decimal; benefits: Prisma.Decimal;
benefitsReason?: string | null;
bonus: Prisma.Decimal; bonus: Prisma.Decimal;
bonusReason?: string | null;
deductions: Prisma.Decimal; deductions: Prisma.Decimal;
deductionsReason?: string | null;
payable: Prisma.Decimal; payable: Prisma.Decimal;
dueDate: Date; dueDate: Date;
status: string; status: string;
movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>; movements: Array<{
id: string;
amount: Prisma.Decimal;
voidedAt: Date | null;
accountId: string;
occurredAt: Date;
account?: { name: string } | null;
}>;
}, },
fullName: string, fullName: string,
) { ) {
@@ -493,13 +655,25 @@ export class TreasuryOpsService {
period: row.period, period: row.period,
baseAmount: money(row.baseAmount), baseAmount: money(row.baseAmount),
benefits: money(row.benefits), benefits: money(row.benefits),
benefitsReason: row.benefitsReason?.trim() || null,
bonus: money(row.bonus), bonus: money(row.bonus),
bonusReason: row.bonusReason?.trim() || null,
deductions: money(row.deductions), deductions: money(row.deductions),
deductionsReason: row.deductionsReason?.trim() || null,
payable: money(row.payable), payable: money(row.payable),
paidAmount: money(paid), paidAmount: money(paid),
remaining: money(left.isNegative() ? 0 : left), remaining: money(left.isNegative() ? 0 : left),
dueDate: row.dueDate.toISOString(), dueDate: row.dueDate.toISOString(),
status: row.status, status: row.status,
payments: row.movements
.filter((item) => !item.voidedAt)
.map((item) => ({
id: item.id,
amount: money(item.amount),
occurredAt: item.occurredAt.toISOString(),
accountId: item.accountId,
accountName: item.account?.name ?? '',
})),
}; };
} }
@@ -129,6 +129,29 @@ export class TreasuryController {
return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income'); return this.treasury.partyBalances(user, kind === 'expense' ? 'expense' : 'income');
} }
@Post('party-balances/collect')
collectPartyBalance(
@CurrentUser() user: Actor,
@Body()
body: {
kind?: string;
partyKind?: 'person' | 'company' | 'user';
partyId?: string;
amount?: string | number;
accountId?: string;
occurredAt?: string;
},
) {
return this.treasury.collectPartyBalance(user, {
kind: body.kind === 'expense' ? 'expense' : 'income',
partyKind: body.partyKind ?? 'person',
partyId: body.partyId ?? '',
amount: body.amount ?? 0,
accountId: body.accountId ?? '',
occurredAt: body.occurredAt,
});
}
@Get('documents') @Get('documents')
documents( documents(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
@@ -232,6 +255,25 @@ export class TreasuryController {
return this.ops.createPayroll(user, body); return this.ops.createPayroll(user, body);
} }
@Patch('payroll/:id')
updatePayroll(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() body: Parameters<TreasuryOpsService['updatePayroll']>[2],
) {
return this.ops.updatePayroll(user, id, body);
}
@Patch('payroll/:id/payments/:movementId')
updatePayrollPayment(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('movementId') movementId: string,
@Body() body: Parameters<TreasuryOpsService['updatePayrollPayment']>[3],
) {
return this.ops.updatePayrollPayment(user, id, movementId, body);
}
@Post('payroll/:id/pay') @Post('payroll/:id/pay')
payPayroll( payPayroll(
@CurrentUser() user: Actor, @CurrentUser() user: Actor,
+324 -9
View File
@@ -25,6 +25,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage'; import { LocalFileStorage } from '../storage/local-file.storage';
import { StorageQuotaService } from '../billing/storage-quota.service'; import { StorageQuotaService } from '../billing/storage-quota.service';
import type { Actor } from '../common/record-scope'; import type { Actor } from '../common/record-scope';
import { loadInvoicePartyPositions, invoiceSalesInPeriod } from './invoice-ledger';
import { TreasuryReceivablesService } from './treasury-receivables.service'; import { TreasuryReceivablesService } from './treasury-receivables.service';
const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها']; const INCOME_CATEGORIES = ['فروش خدمات', 'فروش کالا', 'سایر درآمدها'];
@@ -359,6 +360,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
...(from || to ...(from || to
? { ? {
occurredAt: { occurredAt: {
@@ -377,6 +379,7 @@ export class TreasuryService {
organizationId: actor.organizationId, organizationId: actor.organizationId,
accountId, accountId,
voidedAt: null, voidedAt: null,
kind: { not: 'allocation' },
occurredAt: { lt: from }, occurredAt: { lt: from },
}, },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
@@ -530,6 +533,8 @@ export class TreasuryService {
settleAmount?: string | number | null; settleAmount?: string | number | null;
accountId?: string | null; accountId?: string | null;
settledAt?: string | null; settledAt?: string | null;
/** دریافت یا پرداخت روی مانده؛ سند تعهدی جدید نمی‌سازد. */
settlement?: boolean;
}, },
): Promise<FinancialDocumentDto> { ): Promise<FinancialDocumentDto> {
const title = input.title.trim(); const title = input.title.trim();
@@ -560,6 +565,17 @@ export class TreasuryService {
} }
} }
const bookedAt = startOfDay(input.bookedAt) ?? new Date(); const bookedAt = startOfDay(input.bookedAt) ?? new Date();
const settlement = input.settlement === true;
if (settlement) {
if (party.partyKind === 'other') {
throw new BadRequestException('برای دریافت یا پرداخت، طرف حساب واقعی لازم است.');
}
if (!settle.equals(amount) || !settle.greaterThan(0)) {
throw new BadRequestException('مبلغ دریافت یا پرداخت را وارد کنید.');
}
if (!input.accountId) throw new BadRequestException('حساب را انتخاب کنید.');
}
const payExisting = settlement;
const doc = await this.prisma.$transaction(async (tx) => { const doc = await this.prisma.$transaction(async (tx) => {
const reference = await this.nextDocumentReference( const reference = await this.nextDocumentReference(
tx, tx,
@@ -606,6 +622,45 @@ export class TreasuryService {
}, },
}); });
} }
if (payExisting && input.accountId) {
const leftover = await this.allocatePartyBalance(tx, actor, {
party,
sourceId: created.id,
kind: input.kind,
amount: settle,
accountId: input.accountId,
occurredAt: startOfDay(input.settledAt ?? undefined) ?? bookedAt,
memo: title,
});
if (leftover.greaterThan(0)) {
const opposite = input.kind === 'income' ? 'expense' : 'income';
const oppositeRef = await this.nextDocumentReference(
tx,
actor.organizationId,
opposite,
input.bookedAt,
bookedAt,
);
await tx.financialDocument.create({
data: {
organizationId: actor.organizationId,
kind: opposite,
title,
amount: leftover,
bookedAt,
personId: party.personId,
companyId: party.companyId,
userId: party.userId,
partyKind: party.partyKind,
partyName: party.partyName,
status: 'open',
reference: oppositeRef,
notes: `source:${created.id}`,
createdById: actor.userId,
},
});
}
}
return tx.financialDocument.findUniqueOrThrow({ return tx.financialDocument.findUniqueOrThrow({
where: { id: created.id }, where: { id: created.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -727,6 +782,7 @@ export class TreasuryService {
amount: Prisma.Decimal; amount: Prisma.Decimal;
settled: Prisma.Decimal; settled: Prisma.Decimal;
remaining: Prisma.Decimal; remaining: Prisma.Decimal;
documentRemaining: Prisma.Decimal;
}>(); }>();
for (const row of rows) { for (const row of rows) {
if (row.partyKind === 'other') continue; if (row.partyKind === 'other') continue;
@@ -747,13 +803,43 @@ export class TreasuryService {
amount: new Prisma.Decimal(0), amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0), settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0), remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
}; };
current.amount = current.amount.plus(row.amount); current.amount = current.amount.plus(row.amount);
current.settled = current.settled.plus(settled); current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(rawRemaining); current.remaining = current.remaining.plus(rawRemaining);
current.documentRemaining = current.documentRemaining.plus(rawRemaining);
buckets.set(key, current); buckets.set(key, current);
} }
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
const onDebtorSide = party.net.greaterThan(0);
if (kind === 'income' && !onDebtorSide) continue;
if (kind === 'expense' && onDebtorSide) continue;
const remaining = party.net.abs();
const amount = onDebtorSide ? party.billed : Prisma.Decimal.max(party.billed, remaining);
const settled = Prisma.Decimal.max(amount.minus(remaining), 0);
const current = buckets.get(party.key) ?? {
key: party.key,
partyKind: party.partyKind,
partyName: party.partyName,
amount: new Prisma.Decimal(0),
settled: new Prisma.Decimal(0),
remaining: new Prisma.Decimal(0),
documentRemaining: new Prisma.Decimal(0),
};
current.amount = current.amount.plus(amount);
current.settled = current.settled.plus(settled);
current.remaining = current.remaining.plus(remaining);
if (!current.partyName || current.partyName === 'بدون طرف حساب') {
current.partyName = party.partyName;
}
buckets.set(party.key, current);
}
return [...buckets.values()] return [...buckets.values()]
.filter((row) => row.remaining.greaterThan(0))
.sort((a, b) => b.remaining.comparedTo(a.remaining)) .sort((a, b) => b.remaining.comparedTo(a.remaining))
.map((row) => ({ .map((row) => ({
key: row.key, key: row.key,
@@ -762,9 +848,122 @@ export class TreasuryService {
amount: money(row.amount), amount: money(row.amount),
settledAmount: money(row.settled), settledAmount: money(row.settled),
remaining: money(row.remaining), remaining: money(row.remaining),
documentRemaining: money(row.documentRemaining),
})); }));
} }
/** دریافت یا پرداختِ بخشی از ماندهٔ سندهای باز یک طرف حساب، بدون ساخت سند جدید. */
async collectPartyBalance(
actor: Actor,
input: {
kind: 'income' | 'expense';
partyKind: 'person' | 'company' | 'user';
partyId: string;
amount: string | number;
accountId: string;
occurredAt?: string;
},
): Promise<{ applied: string; documentCount: number }> {
const amount = dec(input.amount);
if (!amount.greaterThan(0)) throw new BadRequestException('مبلغ باید بزرگ‌تر از صفر باشد.');
const partyId = input.partyId.trim();
if (!partyId) throw new BadRequestException('طرف حساب را انتخاب کنید.');
if (input.partyKind !== 'person' && input.partyKind !== 'company' && input.partyKind !== 'user') {
throw new BadRequestException('این مانده به سند بازی وصل نیست.');
}
if (!input.accountId.trim()) throw new BadRequestException('حساب را انتخاب کنید.');
const account = await this.requireAccount(actor.organizationId, input.accountId, actor);
if (!account.isActive) throw new BadRequestException('حساب غیرفعال است.');
const scope = await this.accountScope(actor);
const docs = await this.prisma.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
kind: input.kind,
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.partyKind,
...(input.partyKind === 'person' ? { personId: partyId } : {}),
...(input.partyKind === 'company' ? { companyId: partyId } : {}),
...(input.partyKind === 'user' ? { userId: partyId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const open = docs
.map((doc) => {
const remaining = doc.amount.minus(this.settledOf(doc.movements));
return { doc, remaining };
})
.filter((row) => row.remaining.greaterThan(0));
const available = open.reduce((sum, row) => sum.plus(row.remaining), new Prisma.Decimal(0));
if (!available.greaterThan(0)) {
throw new BadRequestException(
'سند بازی برای کم کردن این مانده نیست. اگر مانده از فاکتور است، دریافت را روی خود فاکتور ثبت کنید.',
);
}
if (amount.greaterThan(available)) {
throw new BadRequestException('مبلغ از ماندهٔ سندهای باز این طرف حساب بیشتر است.');
}
if (input.kind === 'expense') {
const balance = await this.accountBalance(account.id);
if (balance.lessThan(amount)) {
throw new BadRequestException('موجودی حساب برای این پرداخت کافی نیست.');
}
}
const occurredAt = startOfDay(input.occurredAt) ?? new Date();
let left = amount;
const touched: string[] = [];
await this.prisma.$transaction(async (tx) => {
for (const row of open) {
if (!left.greaterThan(0)) break;
const slice = left.lessThan(row.remaining) ? left : row.remaining;
const status = slice.equals(row.remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: account.id,
documentId: row.doc.id,
direction: input.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: input.kind === 'income' ? 'receipt' : 'payment',
occurredAt,
memo: input.kind === 'income' ? 'دریافت از مانده' : 'پرداخت از مانده',
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.doc.id },
data: { status, accountId: account.id },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.doc.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
touched.push(row.doc.id);
left = left.minus(slice);
}
});
await this.prisma.financialAuditLog.create({
data: {
organizationId: actor.organizationId,
entityType: 'document',
entityId: touched[0] ?? partyId,
action: 'settle',
after: {
amount: amount.toFixed(2),
accountId: account.id,
documentIds: touched,
partyId,
},
actorUserId: actor.userId,
},
}).catch(() => undefined);
return { applied: money(amount), documentCount: touched.length };
}
private async resolveParty( private async resolveParty(
actor: Actor, actor: Actor,
input: { input: {
@@ -908,16 +1107,51 @@ export class TreasuryService {
}); });
if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.'); if (!doc || doc.voidedAt) throw new NotFoundException('سند پیدا نشد.');
const now = new Date(); const now = new Date();
await this.prisma.$transaction([ await this.prisma.$transaction(async (tx) => {
this.prisma.financialMovement.updateMany({ const allocations = await tx.financialMovement.findMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
select: { documentId: true },
});
await tx.financialMovement.updateMany({
where: { documentId: doc.id, voidedAt: null }, where: { documentId: doc.id, voidedAt: null },
data: { voidedAt: now }, data: { voidedAt: now },
}), });
this.prisma.financialDocument.update({ await tx.financialMovement.updateMany({
where: { organizationId: actor.organizationId, reference: `alloc:${doc.id}`, voidedAt: null },
data: { voidedAt: now },
});
await tx.financialDocument.update({
where: { id: doc.id }, where: { id: doc.id },
data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' }, data: { status: 'void', voidedAt: now, voidReason: reason?.trim() || 'ابطال' },
}), });
]); const excess = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
notes: `source:${doc.id}`,
voidedAt: null,
status: { in: ['open', 'partial'] },
},
include: { movements: { select: { voidedAt: true } } },
});
for (const child of excess) {
if (child.movements.some((row) => !row.voidedAt)) continue;
await tx.financialDocument.update({
where: { id: child.id },
data: { status: 'void', voidedAt: now, voidReason: 'ابطال سند مبدأ' },
});
}
const affected = [...new Set(allocations.map((row) => row.documentId).filter((id): id is string => Boolean(id)))];
for (const documentId of affected) {
const row = await tx.financialDocument.findUnique({
where: { id: documentId },
include: { movements: { select: { amount: true, voidedAt: true } } },
});
if (!row || row.voidedAt || row.status === 'void') continue;
const settled = this.settledOf(row.movements);
const status = settled.equals(0) ? 'open' : settled.greaterThanOrEqualTo(row.amount) ? 'settled' : 'partial';
await tx.financialDocument.update({ where: { id: row.id }, data: { status } });
}
});
const fresh = await this.prisma.financialDocument.findUniqueOrThrow({ const fresh = await this.prisma.financialDocument.findUniqueOrThrow({
where: { id: doc.id }, where: { id: doc.id },
include: { category: true, account: true, movements: true }, include: { category: true, account: true, movements: true },
@@ -1092,6 +1326,17 @@ export class TreasuryService {
} }
const bookedIncome = dec(income._sum.amount); const bookedIncome = dec(income._sum.amount);
const bookedExpense = dec(expense._sum.amount); const bookedExpense = dec(expense._sum.amount);
const invoiceSales = await invoiceSalesInPeriod(
this.prisma,
actor.organizationId,
from,
to,
);
const invoiceParties = await loadInvoicePartyPositions(this.prisma, actor.organizationId);
for (const party of invoiceParties) {
if (party.net.greaterThan(0)) receivables = receivables.plus(party.net);
else payables = payables.plus(party.net.abs());
}
const totalBalance = accounts.reduce( const totalBalance = accounts.reduce(
(sum, account) => sum.plus(account.balance), (sum, account) => sum.plus(account.balance),
new Prisma.Decimal(0), new Prisma.Decimal(0),
@@ -1101,7 +1346,7 @@ export class TreasuryService {
actualReceipts: money(dec(receipts._sum.amount)), actualReceipts: money(dec(receipts._sum.amount)),
bookedExpense: money(bookedExpense), bookedExpense: money(bookedExpense),
actualPayments: money(dec(payments._sum.amount)), actualPayments: money(dec(payments._sum.amount)),
operatingProfit: money(bookedIncome.minus(bookedExpense)), operatingProfit: money(bookedIncome.plus(invoiceSales.net).minus(bookedExpense)),
receivables: money(receivables), receivables: money(receivables),
payables: money(payables), payables: money(payables),
totalBalance: money(totalBalance), totalBalance: money(totalBalance),
@@ -1111,6 +1356,76 @@ export class TreasuryService {
}; };
} }
/** مبلغ قطعی را از ماندهٔ باز همان طرف کم می‌کند؛ اول سندهای هم‌نوع، بعد طرف مقابل. روی موجودی حساب اثر ندارد. */
private async allocatePartyBalance(
tx: Prisma.TransactionClient,
actor: Actor,
input: {
party: { partyKind: string; personId: string | null; companyId: string | null; userId: string | null };
sourceId: string;
kind: 'income' | 'expense';
amount: Prisma.Decimal;
accountId: string;
occurredAt: Date;
memo: string;
},
): Promise<Prisma.Decimal> {
const scope = await this.accountScope(actor);
const openDocs = await tx.financialDocument.findMany({
where: {
organizationId: actor.organizationId,
id: { not: input.sourceId },
voidedAt: null,
status: { in: ['open', 'partial'] },
partyKind: input.party.partyKind,
...(input.party.personId ? { personId: input.party.personId } : {}),
...(input.party.companyId ? { companyId: input.party.companyId } : {}),
...(input.party.userId ? { userId: input.party.userId } : {}),
...(scope ? { OR: [{ accountId: null }, { accountId: { in: scope } }] } : {}),
},
include: { movements: { select: { amount: true, voidedAt: true } } },
orderBy: [{ bookedAt: 'asc' }, { createdAt: 'asc' }],
});
const ordered = [
...openDocs.filter((row) => row.kind === input.kind),
...openDocs.filter((row) => row.kind !== input.kind),
];
let left = input.amount;
for (const row of ordered) {
if (!left.greaterThan(0)) break;
const remaining = row.amount.minus(this.settledOf(row.movements));
if (!remaining.greaterThan(0)) continue;
const slice = left.lessThan(remaining) ? left : remaining;
const status = slice.equals(remaining) ? 'settled' : 'partial';
await tx.financialMovement.create({
data: {
organizationId: actor.organizationId,
accountId: input.accountId,
documentId: row.id,
direction: row.kind === 'income' ? 'in' : 'out',
amount: slice,
kind: 'allocation',
occurredAt: input.occurredAt,
memo: input.memo,
reference: `alloc:${input.sourceId}`,
createdById: actor.userId,
},
});
await tx.financialDocument.update({
where: { id: row.id },
data: { status },
});
if (status === 'settled') {
await tx.financialRecurringOccurrence.updateMany({
where: { documentId: row.id, status: 'pending' },
data: { status: 'confirmed' },
});
}
left = left.minus(slice);
}
return left;
}
private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) { private settledOf(movements: Array<{ amount: Prisma.Decimal; voidedAt: Date | null }>) {
return movements return movements
.filter((row) => !row.voidedAt) .filter((row) => !row.voidedAt)
@@ -1119,7 +1434,7 @@ export class TreasuryService {
private async accountBalance(accountId: string) { private async accountBalance(accountId: string) {
const rows = await this.prisma.financialMovement.findMany({ const rows = await this.prisma.financialMovement.findMany({
where: { accountId, voidedAt: null }, where: { accountId, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true }, select: { direction: true, amount: true },
}); });
return rows.reduce( return rows.reduce(
@@ -1194,7 +1509,7 @@ export class TreasuryService {
const from = startOfDay(range?.from); const from = startOfDay(range?.from);
const to = startOfDay(range?.to); const to = startOfDay(range?.to);
const movements = await this.prisma.financialMovement.findMany({ const movements = await this.prisma.financialMovement.findMany({
where: { accountId: account.id, voidedAt: null }, where: { accountId: account.id, voidedAt: null, kind: { not: 'allocation' } },
select: { direction: true, amount: true, kind: true, occurredAt: true }, select: { direction: true, amount: true, kind: true, occurredAt: true },
}); });
let balance = new Prisma.Decimal(0); let balance = new Prisma.Decimal(0);
+15 -3
View File
@@ -104,8 +104,10 @@ export class UsersService {
orgRoleId: dto.orgRoleId, orgRoleId: dto.orgRoleId,
role: dto.role, role: dto.role,
}); });
if (resolved.role === 'owner' && actorRole !== 'owner') { if (resolved.role === 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند'); throw new BadRequestException(
'هر محیط فقط یک مالک دارد و مالک همان کسی است که ثبت‌نام کرده. برای دسترسی کامل نقش مدیر را انتخاب کنید.',
);
} }
const existingUser = await this.prisma.user.findFirst({ where: { mobile } }); const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
@@ -449,6 +451,10 @@ export class UsersService {
} }
} }
if (nextRole === 'owner' && target.role !== 'owner') {
await this.memberships.assertSoleOwner(organizationId, target.userId);
}
if (target.role === 'owner' && nextRole !== 'owner') { if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.memberships.countActiveOwners( const owners = await this.memberships.countActiveOwners(
organizationId, organizationId,
@@ -456,7 +462,7 @@ export class UsersService {
); );
if (owners < 1) { if (owners < 1) {
throw new BadRequestException( throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید', 'مالک محیط همان کسی است که ثبت‌نام کرده و نمی‌توان نقش او را عوض کرد',
); );
} }
} }
@@ -528,6 +534,9 @@ export class UsersService {
} }
} }
if (isActive && !target.isActive) { if (isActive && !target.isActive) {
if (target.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, targetId);
}
await this.billing.assertSeatAvailable(organizationId, targetId); await this.billing.assertSeatAvailable(organizationId, targetId);
} }
await this.prisma.membership.update({ await this.prisma.membership.update({
@@ -558,6 +567,9 @@ export class UsersService {
resolved: { role: UserRole; orgRoleId: string }, resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> { ): Promise<OrgUserDto> {
await this.billing.assertSeatAvailable(organizationId, userId); await this.billing.assertSeatAvailable(organizationId, userId);
if (resolved.role === 'owner') {
await this.memberships.assertSoleOwner(organizationId, userId);
}
const existing = await this.prisma.membership.findFirst({ const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
}); });
+52 -1
View File
@@ -1,6 +1,11 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { leadsDeepWhere, resolveSectionAccess } from '../src/common/data-access'; import {
customersCompanyWhere,
customersPersonWhere,
leadsDeepWhere,
resolveSectionAccess,
} from '../src/common/data-access';
import { import {
noteAccessWhere, noteAccessWhere,
normalizeDataVisibility, normalizeDataVisibility,
@@ -122,3 +127,49 @@ describe('عمق داده وقتی نقش فقط بعضی بخش‌ها را ذ
assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] }); assert.deepEqual(leads.section, { mode: 'rules', rules: [{ owners: ['self'] }] });
}); });
}); });
describe('مسئول گروه مشتری روی عمق داده', () => {
const selfAccess = { mode: 'rules' as const, rules: [{ owners: ['self' as const] }] };
it('محدودیت self با OR مسئول گروه برای شخص', () => {
const where = customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('محدودیت self با OR مسئول گروه برای شرکت', () => {
const where = customersCompanyWhere({
userId: 'u1',
role: 'sales',
customersAccess: selfAccess,
teamPeerIds: null,
});
assert.deepEqual(where, {
OR: [
{ ownerId: 'u1' },
{ groupLinks: { some: { group: { assignees: { some: { id: 'u1' } } } } } },
],
});
});
it('بدون محدودیت عمقی، فیلتر اضافه نمی‌شود', () => {
assert.deepEqual(
customersPersonWhere({
userId: 'u1',
role: 'sales',
customersAccess: { mode: 'all', rules: [] },
teamPeerIds: null,
}),
{},
);
});
});
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title> <title>منتکت | Montact CRM</title>
<link rel="icon" type="image/svg+xml" href="/brand/logo.svg" /> <link rel="icon" type="image/png" href="/brand/logo.png" />
<style> <style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */ /* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame { .app-frame {
-2
View File
@@ -22,9 +22,7 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^6.2.2", "vite": "^6.2.2",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.2.8"
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 197.7 151.02">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M43.57,77.15v64.37c0,4.79-3.88,8.67-8.67,8.67H8.67c-4.79,0-8.67-3.88-8.67-8.67V51.62c0-2.96,1.51-5.71,3.99-7.3l41.67-26.69c3.67-2.35,8.52-1.61,11.32,1.73l35.74,42.59c.57.68,1.6.72,2.22.09l26.07-26.6c3.38-3.45,8.92-3.47,12.33-.06l19.42,19.42c3.39,3.39,3.39,8.88,0,12.26l-55.06,55.06c-3.58,3.58-9.45,3.35-12.73-.5l-38.76-45.42c-.9-1.06-2.64-.42-2.64.97Z"/>
<circle class="cls-3" cx="167.06" cy="21.02" r="21.02"/>
<rect class="cls-3" x="172.68" y="52.7" width="25.02" height="98.32" rx="12.51" ry="12.51"/>
<rect class="cls-2" x="142.38" y="83.17" width="25.02" height="67.85" rx="12.51" ry="12.51"/>
<rect class="cls-1" x="111.4" y="114.24" width="25.02" height="36.78" rx="12.51" ry="12.51"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 246.7 46.59">
<defs>
<style>
.cls-1 {
fill: #10897d;
}
.cls-2 {
fill: #16c1a8;
}
.cls-3 {
fill: #43f7dd;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M199.15,23.8v19.86c0,1.48-1.2,2.67-2.67,2.67h-8.09c-1.48,0-2.67-1.2-2.67-2.67V15.92c0-.91.47-1.76,1.23-2.25l12.85-8.23c1.13-.72,2.63-.5,3.49.53l11.02,13.14c.18.21.49.22.68.03l8.04-8.21c1.04-1.06,2.75-1.07,3.8-.02l5.99,5.99c1.05,1.05,1.05,2.74,0,3.78l-16.98,16.98c-1.1,1.1-2.92,1.03-3.93-.15l-11.96-14.01c-.28-.33-.81-.13-.81.3h0Z"/>
<circle class="cls-3" cx="237.24" cy="6.48" r="6.48"/>
<path class="cls-3" d="M242.84,16.26h0c2.13,0,3.86,1.73,3.86,3.86v22.61c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-22.61c0-2.13,1.73-3.86,3.86-3.86Z"/>
<path class="cls-2" d="M233.49,25.66h0c2.13,0,3.86,1.73,3.86,3.86v13.21c0,2.13-1.73,3.86-3.86,3.86h0c-2.13,0-3.86-1.73-3.86-3.86v-13.21c0-2.13,1.73-3.86,3.86-3.86Z"/>
<rect class="cls-1" x="220.08" y="35.24" width="7.72" height="11.35" rx="3.86" ry="3.86"/>
</g>
<g>
<path class="cls-1" d="M132.19,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h2.66c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-2.66c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M102.53,17.19v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<path class="cls-1" d="M13.73,20.2v2.66c0,1.27,1.03,2.29,2.29,2.29h12.91c1.27,0,2.29-1.03,2.29-2.29v-2.66c0-1.27-1.03-2.29-2.29-2.29h-12.91c-1.27,0-2.29,1.03-2.29,2.29Z"/>
<g>
<path class="cls-1" d="M173.05,30.25c-.52-1.2-1.24-2.25-2.14-3.16-.91-.9-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-3.25c-1.12,0-2.2.19-3.24.58-1.04.39-1.98.92-2.81,1.59-.83.68-1.54,1.49-2.11,2.43-.58.95-.97,1.98-1.16,3.1l-1.16,5.97c-.16.66-.47,1.18-.96,1.56-.48.39-1.05.58-1.71.58h-6.49c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-10.08c-.74,0-1.31-.26-1.74-.78-.43-.52-.56-1.15-.41-1.88l1.35-6.87c.25-1.25-.58-2.47-1.84-2.69l-2.52-.46c-1.22-.22-2.4.57-2.65,1.79l-1.81,8.7c-.12.66-.43,1.19-.93,1.59-.5.4-1.08.61-1.74.61h-9.5c-.66,0-1.22-.22-1.68-.67-.46-.44-.69-.99-.69-1.65v-3.53c0-1.39-.26-2.69-.78-3.91-.52-1.22-1.24-2.28-2.14-3.19-.91-.91-1.96-1.62-3.16-2.14-1.2-.52-2.49-.78-3.88-.78h-21.78l9.58-11.3c.79-.94.71-2.33-.19-3.17l-1.79-1.65c-.96-.88-2.45-.79-3.29.2l-14.66,17.17c-.79.92-.72,2.3.16,3.14l3.59,3.43h29.14c.54,0,1.02.2,1.45.61.43.4.64.9.64,1.48v4.11c0,.54-.19.99-.58,1.33-.39.35-.79.52-1.22.52h-38.23c-.66,0-1.21-.22-1.65-.67-.44-.44-.67-.99-.67-1.65v-8.54c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v8.6c0,.66-.22,1.2-.67,1.62-.44.43-.99.64-1.65.64H11.53c-1.2,0-2.22-.4-3.07-1.21-.85-.81-1.27-1.83-1.27-3.07v-8.25c0-1.27-1.03-2.29-2.29-2.29h-2.6c-1.27,0-2.29,1.03-2.29,2.29v6.47c0,1.66.32,3.24.96,4.72.64,1.49,1.51,2.79,2.61,3.91,1.1,1.12,2.39,2,3.88,2.63,1.49.64,3.06.96,4.72.96h22.01c1.39,0,2.69-.25,3.91-.75,1.22-.5,2.33-1.33,3.33-2.49,1.93,2.16,4.29,3.24,7.07,3.24h35.8c1.24,0,2.44-.23,3.62-.69,1.18-.46,2.25-1.08,3.22-1.85.93.77,1.98,1.39,3.16,1.85,1.18.46,2.44.69,3.79.69h7.59c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.36.87,3.56.87h9.44c1.23,0,2.48-.29,3.74-.87,1.25-.58,2.25-1.37,2.98-2.38.58,1,1.46,1.8,2.64,2.38,1.18.58,2.37.87,3.56.87h6.03c1.24,0,2.39-.23,3.47-.69,1.08-.46,2.07-1.29,2.95-2.49.89,1.24,1.86,2.08,2.93,2.52,1.06.44,2.15.67,3.27.67h6.6c1.04,0,2.03-.2,2.95-.61.93-.4,1.74-.96,2.43-1.65s1.24-1.51,1.65-2.43c.4-.93.61-1.91.61-2.95v-4.36c0-1.39-.26-2.68-.78-3.88ZM166.65,39.98h-7.99c-.43,0-.77-.15-1.04-.46-.27-.31-.37-.67-.29-1.1l.93-4.64c.08-.42.27-.77.58-1.04.31-.27.67-.4,1.1-.4h4.98c.5,0,.92.17,1.24.52.33.35.49.75.49,1.22v5.91Z"/>
<path class="cls-3" d="M166.7,16.1c-.65.12-1.24.25-1.77.37-.53.12-1.12.26-1.79.4-.67.14-1.34.29-2.02.44-1.3.29-2.73.61-4.28.94-.68.15-1.34-.31-1.45-1l-.24-1.57c-.1-.63.31-1.23.94-1.36l4.01-.88c-.09-.34-.16-.61-.21-.81-.05-.2-.09-.38-.12-.54-.03-.15-.07-.33-.12-.54-.05-.2-.09-.47-.12-.81-.06-.59,0-1.13.21-1.63.2-.5.48-.95.84-1.35.36-.4.78-.73,1.26-.98.48-.25.95-.4,1.42-.47l2.42-.42c.56-.09,1.11-.05,1.65.14.54.19,1.02.46,1.44.81s.78.78,1.07,1.28c.29.5.49,1.01.58,1.54l.23,1.4c.12.62.09,1.21-.09,1.77-.19.56-.47,1.05-.84,1.49-.37.43-.82.81-1.35,1.12-.53.31-1.09.53-1.68.65ZM166.79,11.54s-.02-.1-.05-.21c-.03-.11-.06-.24-.09-.4-.06-.16-.12-.34-.19-.56-.16-.31-.39-.42-.7-.33l-1.44.37c-.37.06-.53.26-.47.6l.51,1.63,1.86-.47c.5-.09.68-.31.56-.65Z"/>
<path class="cls-3" d="M105.49,6.57l11.59-2.48c.64-.14,1.28.27,1.43.91l.27,1.15c.15.66-.26,1.31-.93,1.45l-11.66,2.46c-.67.14-1.32-.3-1.44-.97l-.2-1.13c-.11-.64.3-1.25.94-1.39Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+113 -51
View File
@@ -4,20 +4,8 @@ import { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali'; import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared'; import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { ThunderboltOutlined } from '@/icons/ant-bridge';
const { Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{ const props = defineProps<{
personId?: string; personId?: string;
@@ -77,53 +65,127 @@ function insertAnswer() {
</script> </script>
<template> <template>
<a-flex vertical :gap="8" :style="{ minWidth: 0 }"> <section class="ask">
<a-flex :gap="8" align="center" :style="{ width: '100%' }"> <form @submit.prevent="submit">
<a-input <UiIcon name="sparkle" :size="16" class="ico" />
v-model:value="question" <input
allow-clear v-model="question"
type="search"
:placeholder="placeholder || brand.askPlaceholderFa" :placeholder="placeholder || brand.askPlaceholderFa"
:style="{ flex: 1, minWidth: 0 }" />
@pressEnter="submit" <button type="submit" :disabled="asking || question.trim().length < 2">
> {{ asking ? '…' : 'بپرس' }}
<template #prefix> </button>
<ThunderboltOutlined /> </form>
</template> <p v-if="error" class="error">{{ error }}</p>
</a-input> <div v-else-if="result" class="answer">
<a-button <p>{{ result.answer }}</p>
type="primary" <div v-if="result.sources.length" class="sources">
:disabled="asking || question.trim().length < 2"
:loading="asking"
@click="submit"
>
بپرس
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<a-card v-else-if="result" :bordered="false">
<a-typography-paragraph :style="{ marginBottom: 0 }">
{{ result.answer }}
</a-typography-paragraph>
<a-space v-if="result.sources.length" wrap :size="6" :style="{ marginTop: '8px' }">
<RouterLink <RouterLink
v-for="source in result.sources" v-for="source in result.sources"
:key="`${source.type}-${source.id}`" :key="`${source.type}-${source.id}`"
:to="sourceTo(source)" :to="sourceTo(source)"
> >
<a-tag color="cyan" :bordered="false">{{ source.label }}</a-tag> {{ source.label }}
</RouterLink> </RouterLink>
</a-space> </div>
<a-button <button
v-if="allowInsert && result.answer.trim()" v-if="allowInsert && result.answer.trim()"
type="primary" type="button"
block class="insert-btn"
:style="{ marginTop: '8px' }"
@click="insertAnswer" @click="insertAnswer"
> >
درج در پیام درج در پیام
</a-button> </button>
</a-card> </div>
</a-flex> </section>
</template> </template>
<style scoped>
.ask {
display: grid;
gap: 0.55rem;
min-width: 0;
}
form {
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
.ico {
color: #0f766e;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.45rem 0;
}
button {
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
padding: 0.45rem 0.8rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.answer {
background: #f0fdfa;
border-radius: var(--radius);
padding: 0.7rem 0.85rem;
}
.answer p {
margin: 0;
color: #134e4a;
font-size: 0.88rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.55rem;
}
.sources a {
font-size: 0.74rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.18rem 0.55rem;
}
.insert-btn {
margin-top: 0.65rem;
width: 100%;
background: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
@@ -126,6 +126,9 @@ function apply() {
<strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong> <strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button> <button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header> </header>
<p v-if="isPeople" class="facet-hint">
گروه، منبع و وضعیت با هم جمع می‌شوند؛ هر کدام کافی است (بدون تکرار).
</p>
<div class="panel-body"> <div class="panel-body">
<section v-if="hasSort" class="sec"> <section v-if="hasSort" class="sec">
@@ -264,6 +267,15 @@ function apply() {
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.facet-hint {
margin: 0;
padding: 0.35rem 0.9rem 0.55rem;
font-size: var(--fs-sm);
color: #64748b;
line-height: 1.45;
border-bottom: 1px solid var(--line);
}
.panel-foot { .panel-foot {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
justify-content: flex-end; justify-content: flex-end;
@@ -8,11 +8,11 @@ import {
InputNumber as AInputNumber, InputNumber as AInputNumber,
Row as ARow, Row as ARow,
Select as ASelect, Select as ASelect,
Spin as ASpin,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
import type { import type {
CustomFieldDefinitionDto, CustomFieldDefinitionDto,
CustomFieldEntity, CustomFieldEntity,
@@ -73,8 +73,7 @@ onMounted(load);
</script> </script>
<template> <template>
<CrmSkeleton :loading="loading"> <a-spin v-if="loading" />
<CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
<template v-else-if="fields.length"> <template v-else-if="fields.length">
<a-typography-title :level="5" :style="{ marginTop: '16px' }"> <a-typography-title :level="5" :style="{ marginTop: '16px' }">
فیلدهای سفارشی فیلدهای سفارشی
@@ -131,5 +130,4 @@ onMounted(load);
</a-col> </a-col>
</a-row> </a-row>
</template> </template>
</CrmSkeleton>
</template> </template>
+220 -15
View File
@@ -1,29 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared'; import type { CustomerGroupColor, CustomerGroupDto, OrgUserDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared'; import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import { import {
CUSTOMER_GROUP_COLOR_LABELS, CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch, groupSwatch,
useCustomerGroups, useCustomerGroups,
} from '@/lib/customer-groups'; } from '@/lib/customer-groups';
import { useAuthStore } from '@/stores/auth';
import { UiButton, UiInput, UiModal } from '@/components/ui'; import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
const props = defineProps<{ open: boolean }>(); const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>(); const emit = defineEmits<{ close: []; changed: [] }>();
const auth = useAuthStore();
const canManage = computed(
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
);
const { groups, load } = useCustomerGroups(); const { groups, load } = useCustomerGroups();
const users = ref<OrgUserDto[]>([]);
const newName = ref(''); const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal'); const newColor = ref<CustomerGroupColor>('teal');
const newAssigneeIds = ref<string[]>([]);
const editingId = ref<string | null>(null); const editingId = ref<string | null>(null);
const editName = ref(''); const editName = ref('');
const assigneeDraft = ref<Record<string, string[]>>({});
const listQuery = ref('');
const busy = ref(false); const busy = ref(false);
const error = ref(''); const error = ref('');
const filteredGroups = computed(() => {
const q = normalizeSearchText(listQuery.value);
if (!q) return groups.value;
return groups.value.filter((g) => {
const assigneeNames = (g.assignees ?? []).map((a) => a.fullName).join(' ');
return (
normalizeSearchText(g.name).includes(q) ||
normalizeSearchText(g.parentName ?? '').includes(q) ||
normalizeSearchText(assigneeNames).includes(q)
);
});
});
function errorText(e: unknown, fallback: string) { function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message; const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
return fallback; return fallback;
} }
async function loadUsers() {
try {
const { data } = await api.get<OrgUserDto[]>('/users');
users.value = data.filter((u) => u.isActive !== false);
} catch {
users.value = [];
}
}
function syncAssigneeDraft() {
const next: Record<string, string[]> = {};
for (const g of groups.value) {
next[g.id] = (g.assignees ?? []).map((a) => a.id);
}
assigneeDraft.value = next;
}
async function run(task: () => Promise<unknown>, fallback: string) { async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true; busy.value = true;
error.value = ''; error.value = '';
try { try {
await task(); await task();
await load(true); await load(true);
syncAssigneeDraft();
emit('changed'); emit('changed');
return true; return true;
} catch (e) { } catch (e) {
@@ -53,10 +96,18 @@ async function create() {
const name = newName.value.trim(); const name = newName.value.trim();
if (!name) return; if (!name) return;
const ok = await run( const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }), () =>
api.post('/customer-groups', {
name,
color: newColor.value,
assigneeIds: newAssigneeIds.value,
}),
'ساخت گروه ناموفق بود.', 'ساخت گروه ناموفق بود.',
); );
if (ok) newName.value = ''; if (ok) {
newName.value = '';
newAssigneeIds.value = [];
}
} }
function startEdit(g: CustomerGroupDto) { function startEdit(g: CustomerGroupDto) {
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.'); await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
} }
async function onAssigneesChange(g: CustomerGroupDto, ids: string[]) {
assigneeDraft.value = { ...assigneeDraft.value, [g.id]: ids };
const prev = (g.assignees ?? []).map((a) => a.id).sort().join(',');
const next = [...ids].sort().join(',');
if (prev === next) return;
await run(
() => api.patch(`/customer-groups/${g.id}`, { assigneeIds: ids }),
'ذخیره مسئولان گروه ناموفق بود.',
);
}
function assigneeLabel(g: CustomerGroupDto) {
const names = (g.assignees ?? []).map((a) => a.fullName).filter(Boolean);
if (!names.length) return 'بدون مسئول';
if (names.length === 1) return names[0]!;
return `${names[0]} و ${toFaDigits(names.length - 1)} نفر دیگر`;
}
async function remove(g: CustomerGroupDto) { async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount; const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : ''; const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
@@ -95,7 +164,10 @@ watch(
if (!open) return; if (!open) return;
error.value = ''; error.value = '';
editingId.value = null; editingId.value = null;
void load(true).catch(() => { listQuery.value = '';
void Promise.all([load(true), loadUsers()])
.then(() => syncAssigneeDraft())
.catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.'; error.value = 'بارگذاری گروه‌ها ناموفق بود.';
}); });
}, },
@@ -107,11 +179,16 @@ watch(
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')"> <UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager"> <div class="manager">
<p class="lead"> <p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب هر شخص یا شرکت می‌تواند چند گروه داشته باشد. مسئولان گروه (اختیاری) اعضای آن گروه را مثل
«ته‌حساب» مشخص شده‌اند. مشتریان خود می‌بینند. گروه‌های ته‌حساب با برچسب «ته‌حساب» مشخص شده‌اند.
</p> </p>
<form class="new-row" @submit.prevent="create"> <form v-if="canManage" class="create-panel" @submit.prevent="create">
<header class="create-head">
<strong>گروه جدید</strong>
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
</header>
<div class="new-row">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" /> <UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches"> <div class="swatches">
<button <button
@@ -129,12 +206,33 @@ watch(
<UiIcon name="plus" :size="15" /> <UiIcon name="plus" :size="15" />
افزودن افزودن
</UiButton> </UiButton>
</div>
<div v-if="users.length" class="assignee-field create-assignees">
<span class="assignees-label">مسئولان (اختیاری)</span>
<OrgUserPersonPick
v-model="newAssigneeIds"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
/>
</div>
</form> </form>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list"> <div v-if="canManage || groups.length" class="list-head">
<li v-for="g in groups" :key="g.id" class="row"> <h3 class="list-title">گروه‌های موجود</h3>
<label v-if="groups.length" class="list-search">
<UiIcon name="search" :size="15" />
<input
v-model="listQuery"
type="search"
placeholder="جستجوی گروه یا مسئول…"
/>
</label>
</div>
<ul v-if="filteredGroups.length" class="list">
<li v-for="g in filteredGroups" :key="g.id" class="row">
<div class="name-cell"> <div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)"> <form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" /> <UiInput v-model="editName" :maxlength="80" :disabled="busy" />
@@ -152,9 +250,10 @@ watch(
<span class="count"> <span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت {{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span> </span>
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
</template> </template>
</div> </div>
<div v-if="editingId !== g.id" class="row-actions"> <div v-if="editingId !== g.id && canManage" class="row-actions">
<div class="swatches"> <div class="swatches">
<button <button
v-for="c in CUSTOMER_GROUP_COLORS" v-for="c in CUSTOMER_GROUP_COLORS"
@@ -173,8 +272,20 @@ watch(
<UiIcon name="trash" :size="14" /> <UiIcon name="trash" :size="14" />
</UiButton> </UiButton>
</div> </div>
<div v-if="canManage && users.length" class="assignee-field">
<span class="assignees-label">مسئولان</span>
<OrgUserPersonPick
:model-value="assigneeDraft[g.id] ?? []"
:users="users"
:disabled="busy"
empty-label="انتخاب مسئول"
@update:model-value="(ids) => (assigneeDraft[g.id] = ids)"
@change="(ids) => onAssigneesChange(g, ids)"
/>
</div>
</li> </li>
</ul> </ul>
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p> <p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div> </div>
</UiModal> </UiModal>
@@ -200,6 +311,78 @@ watch(
font-size: 0.85rem; font-size: 0.85rem;
} }
.create-panel {
display: grid;
gap: 0.75rem;
padding: 0.85rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--accent, #0d9488) 28%, #e2e8f0);
border-radius: 12px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, #fff) 0%, #fff 72%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
.create-head {
display: grid;
gap: 0.15rem;
}
.create-head strong {
color: var(--accent-deep, #0f766e);
font-size: 0.9rem;
font-weight: 700;
}
.create-head span {
color: #64748b;
font-size: 0.78rem;
}
.list-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.list-title {
margin: 0;
color: #475569;
font-size: 0.8rem;
font-weight: 700;
}
.list-search {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: min(16rem, 100%);
flex: 1 1 12rem;
max-width: 18rem;
height: 2.25rem;
padding: 0 0.65rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
color: #94a3b8;
}
.list-search:focus-within {
border-color: #5eead4;
}
.list-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.85rem;
color: #0f172a;
}
.new-row, .new-row,
.edit { .edit {
display: flex; display: flex;
@@ -240,11 +423,9 @@ watch(
.list { .list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0 0 0.25rem;
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
} }
.row { .row {
@@ -282,14 +463,38 @@ watch(
padding: 0.05rem 0.4rem; padding: 0.05rem 0.4rem;
} }
.count { .count,
.assignee-summary {
color: #94a3b8; color: #94a3b8;
font-size: 0.78rem; font-size: 0.78rem;
} }
.assignee-summary {
flex: 1 1 100%;
}
.row-actions { .row-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
} }
.assignee-field {
flex: 1 1 100%;
display: grid;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed #e2e8f0;
}
.create-assignees {
border-top: 1px dashed color-mix(in srgb, var(--accent, #0d9488) 22%, #e2e8f0);
padding-top: 0.65rem;
}
.assignees-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 600;
}
</style> </style>
+131 -86
View File
@@ -1,17 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { import { Select as ASelect } from 'ant-design-vue';
Alert as AAlert,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
FormItem as AFormItem,
Radio as ARadio,
RadioGroup as ARadioGroup,
Select as ASelect,
Space as ASpace,
Typography,
} from 'ant-design-vue';
import type { import type {
DataAccessOwnerKind, DataAccessOwnerKind,
DataAccessRule, DataAccessRule,
@@ -26,17 +15,13 @@ import {
PERSON_MOODS, PERSON_MOODS,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
export type IdOption = { id: string; name: string }; export type IdOption = { id: string; name: string };
type Choice = { value: string; label: string }; type Choice = { value: string; label: string };
const { Text: ATypographyText } = Typography;
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: SectionDataAccess; modelValue: SectionDataAccess;
section?: DataAccessSection; section?: DataAccessSection;
groups?: IdOption[];
stages?: IdOption[]; stages?: IdOption[];
pipelines?: IdOption[]; pipelines?: IdOption[];
boards?: IdOption[]; boards?: IdOption[];
@@ -50,7 +35,6 @@ const props = withDefaults(
}>(), }>(),
{ {
section: 'customers', section: 'customers',
groups: () => [],
stages: () => [], stages: () => [],
pipelines: () => [], pipelines: () => [],
boards: () => [], boards: () => [],
@@ -67,9 +51,9 @@ const emit = defineEmits<{
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]); const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
const showGroups = computed(() => props.section === 'customers');
const showMoods = computed(() => props.section === 'customers'); const showMoods = computed(() => props.section === 'customers');
const showImportSources = computed(() => props.section === 'customers'); const showImportSources = computed(() => props.section === 'customers');
const isCustomers = computed(() => props.section === 'customers');
const showLeadSources = computed(() => props.section === 'leads'); const showLeadSources = computed(() => props.section === 'leads');
const showNoteTypes = computed(() => props.section === 'notes'); const showNoteTypes = computed(() => props.section === 'notes');
const showTicketStatuses = computed(() => props.section === 'tickets'); const showTicketStatuses = computed(() => props.section === 'tickets');
@@ -133,9 +117,8 @@ const boardsLegend = computed(() =>
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */ /** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule { function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {}; if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! }; if (rules.length === 1) return stripCustomerGroupIds({ ...rules[0]! });
const owners = new Set<DataAccessOwnerKind>(); const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>(); const sources = new Set<string>();
const moods = new Set<PersonMood>(); const moods = new Set<PersonMood>();
const stageIds = new Set<string>(); const stageIds = new Set<string>();
@@ -144,7 +127,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
const typeIds = new Set<string>(); const typeIds = new Set<string>();
for (const r of rules) { for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o); for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s); for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m); for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id); for (const id of r.stageIds ?? []) stageIds.add(id);
@@ -154,7 +136,6 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
} }
const out: DataAccessRule = {}; const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners]; if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources]; if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods]; if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds]; if (stageIds.size) out.stageIds = [...stageIds];
@@ -164,10 +145,17 @@ function collapseRules(rules: DataAccessRule[]): DataAccessRule {
return out; return out;
} }
/** دسترسی گروه فقط از مسئول‌گروه؛ groupIds دیگر در فیلتر مشتریان ذخیره/نمایش نمی‌شود */
function stripCustomerGroupIds(rule: DataAccessRule): DataAccessRule {
if (!isCustomers.value || !rule.groupIds?.length) return rule;
const { groupIds: _drop, ...rest } = rule;
return rest;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean { function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean( return Boolean(
rule.owners?.length || rule.owners?.length ||
rule.groupIds?.length || (!isCustomers.value && rule.groupIds?.length) ||
rule.sources?.length || rule.sources?.length ||
rule.moods?.length || rule.moods?.length ||
rule.stageIds?.length || rule.stageIds?.length ||
@@ -198,10 +186,26 @@ const mode = computed({
const filter = computed(() => collapseRules(props.modelValue?.rules ?? [])); const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
/** پاک‌سازی groupIds قدیمی مشتریان هنگام باز شدن ویرایشگر */
watch(
() => [props.section, props.modelValue] as const,
() => {
if (!isCustomers.value || props.modelValue?.mode !== 'rules') return;
const rules = props.modelValue.rules ?? [];
if (!rules.some((r) => r.groupIds?.length)) return;
const collapsed = collapseRules(rules);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
{ immediate: true },
);
function emitFilter(next: DataAccessRule) { function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {}; const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners]; if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds]; if (!isCustomers.value && next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources]; if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods]; if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds]; if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
@@ -224,7 +228,6 @@ function toggleOwner(kind: DataAccessOwnerKind) {
} }
type ListKey = type ListKey =
| 'groupIds'
| 'sources' | 'sources'
| 'moods' | 'moods'
| 'stageIds' | 'stageIds'
@@ -307,16 +310,6 @@ const moodOptions: Choice[] = PERSON_MOODS.map((mood) => ({
const pickers = computed((): Picker[] => { const pickers = computed((): Picker[] => {
const rule = filter.value; const rule = filter.value;
const items: Picker[] = []; const items: Picker[] = [];
if (showGroups.value) {
items.push({
key: 'groupIds',
legend: 'گروه‌ها',
mode: 'multiple',
placeholder: props.groups.length ? 'جستجو و انتخاب گروه…' : 'گروهی تعریف نشده',
notFound: props.groups.length ? 'موردی پیدا نشد' : 'گروهی تعریف نشده',
options: withSelected(namedOptions(props.groups), rule.groupIds),
});
}
if (showImportSources.value) { if (showImportSources.value) {
const ready = props.importSourcesReady; const ready = props.importSourcesReady;
const failed = props.importSourcesFailed; const failed = props.importSourcesFailed;
@@ -431,7 +424,6 @@ const filterSummary = computed(() => {
const parts: string[] = []; const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value); if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو'); if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`); if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`); if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`); if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
@@ -443,59 +435,56 @@ const filterSummary = computed(() => {
</script> </script>
<template> <template>
<a-flex vertical :gap="compact ? 8 : 12"> <div class="dae" :class="{ compact }">
<a-radio-group v-model:value="mode" :disabled="disabled"> <div class="dae-mode">
<a-flex vertical :gap="4"> <label>
<a-radio value="all">همهٔ {{ sectionLabel }}</a-radio> <input v-model="mode" type="radio" value="all" :disabled="disabled" />
<a-radio value="rules">محدود با فیلتر</a-radio> همهٔ {{ sectionLabel }}
</a-flex> </label>
</a-radio-group> <label>
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'"> <template v-if="mode === 'rules'">
<a-typography-text type="secondary"> <p class="hint">
فقط رکوردهایی دیده می‌شوند که با فیلترهای پرشده با هم
<strong>همهٔ فیلترهای پرشده</strong> <strong>جمع می‌شوند</strong>
جور باشند. در هر فهرست، چند انتخاب یعنی هر کدام کافی است. خالی یعنی بدون محدودیت. ؛ هر بُعد کافی است (بدون تکرار). در هر فهرست هم چند انتخاب یعنی اتحاد. خالی یعنی بدون محدودیت آن بُعد.
</a-typography-text> <template v-if="isCustomers">
<a-alert اعضای گروه‌هایی که این کاربر مسئولشان است جداگانه به همین محدوده اضافه می‌شوند.
v-if="hadMultipleRules" </template>
type="warning" </p>
show-icon <p v-if="hadMultipleRules" class="hint warn">
message="تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود." تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
/> </p>
<a-typography-text strong>{{ filterSummary }}</a-typography-text> <p class="summary">{{ filterSummary }}</p>
<a-card size="small" title="مالک / مسئول" :bordered="true"> <fieldset :disabled="disabled">
<a-flex vertical :gap="8"> <legend>مالک / مسئول</legend>
<a-typography-text type="secondary">خالی = بدون محدودیت مالک</a-typography-text> <p class="muted">خالی = بدون محدودیت مالک</p>
<a-space wrap :size="8"> <label class="chip">
<a-checkbox <input
type="checkbox"
:checked="filter.owners?.includes('self')" :checked="filter.owners?.includes('self')"
:disabled="disabled"
@change="toggleOwner('self')" @change="toggleOwner('self')"
> />
{{ ownerSelfLabel }} {{ ownerSelfLabel }}
</a-checkbox> </label>
<a-checkbox <label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')" :checked="filter.owners?.includes('team')"
:disabled="disabled"
@change="toggleOwner('team')" @change="toggleOwner('team')"
> />
تیم‌های عضو تیم‌های عضو
</a-checkbox> </label>
</a-space> </fieldset>
</a-flex>
</a-card>
<a-form-item <label v-for="picker in pickers" :key="picker.legend" class="pick">
v-for="picker in pickers" <span>{{ picker.legend }}</span>
:key="picker.legend" <p v-if="picker.loading" class="muted">در حال بارگذاری منبع‌ها…</p>
:label="picker.legend"
:style="{ marginBottom: 0 }"
>
<a-typography-text v-if="picker.loading" type="secondary">
در حال بارگذاری منبع‌ها…
</a-typography-text>
<a-select <a-select
v-else v-else
:value="pickerValue(picker)" :value="pickerValue(picker)"
@@ -510,14 +499,70 @@ const filterSummary = computed(() => {
style="width: 100%" style="width: 100%"
@update:value="setList(picker.key, $event)" @update:value="setList(picker.key, $event)"
/> />
<a-typography-text v-if="picker.hint" type="secondary"> <p v-if="picker.hint" class="muted">{{ picker.hint }}</p>
{{ picker.hint }} </label>
</a-typography-text>
</a-form-item>
</template> </template>
<a-typography-text v-else-if="!hasLimits" type="secondary"> <p v-else-if="!hasLimits" class="hint">
بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند. بدون محدودیت — همهٔ {{ sectionLabel }} قابل مشاهده‌اند.
</a-typography-text> </p>
</a-flex> </div>
</template> </template>
<style scoped>
.dae {
display: grid;
gap: 0.75rem;
}
.dae-mode {
display: grid;
gap: 0.35rem;
}
.dae-mode label,
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
margin-inline-end: 0.55rem;
margin-bottom: 0.25rem;
}
.hint,
.muted,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
fieldset {
border: 1px dashed #e2e8f0;
border-radius: 8px;
padding: 0.5rem 0.65rem;
margin: 0;
}
legend {
padding-inline: 0.25rem;
font-size: 0.8rem;
color: #475569;
font-weight: 600;
}
.pick {
display: grid;
gap: 0.35rem;
}
.pick > span {
font-size: 0.8rem;
font-weight: 600;
color: #475569;
}
.compact fieldset {
padding: 0.45rem 0.55rem;
}
</style>
+173 -103
View File
@@ -1,15 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import {
Tabs as ATabs,
TabPane as ATabPane,
Typography,
Radio as ARadio,
RadioGroup as ARadioGroup,
Checkbox as ACheckbox,
Space as ASpace,
Input as AInput,
} from 'ant-design-vue';
import type { import type {
DataAccessPolicy, DataAccessPolicy,
DataAccessSection, DataAccessSection,
@@ -26,11 +16,8 @@ import {
emptySectionDataAccess, emptySectionDataAccess,
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useCustomerGroups } from '@/lib/customer-groups';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue'; import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ const props = defineProps<{
modelValue: DataAccessPolicy; modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */ /** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
@@ -97,7 +84,6 @@ function setSection(key: DataAccessSection, next: SectionDataAccess) {
}); });
} }
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
const importSources = ref<Array<{ value: string; label: string }>>([]); const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false); const importSourcesReady = ref(false);
const importSourcesFailed = ref(false); const importSourcesFailed = ref(false);
@@ -127,10 +113,6 @@ function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next); emit('update:treasuryAccess', next);
} }
function onTreasuryModeChange(e: { target: { value?: unknown } }) {
patchTreasury(e.target.value === 'role' ? null : (props.treasuryAccess ?? emptyTreasuryAccess()));
}
function toggleTreasurySection(key: RolePermissionKey) { function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess(); const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key) const sections = current.sections.includes(key)
@@ -158,7 +140,6 @@ async function loadTreasuryAccounts() {
} }
async function loadOptions() { async function loadOptions() {
await loadCustomerGroups().catch(() => undefined);
try { try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats'); const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label })); importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
@@ -245,105 +226,194 @@ onMounted(() => {
void loadOptions(); void loadOptions();
void loadTreasuryAccounts(); void loadTreasuryAccounts();
}); });
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
const stagesForActive = computed(() => {
if (active.value === 'leads') return leadStages.value;
if (active.value === 'sales') return dealStages.value;
if (active.value === 'activities') return activityStages.value;
if (active.value === 'projects') return projectStages.value;
return [];
});
const boardsForActive = computed(() => {
if (active.value === 'activities') return activityBoards.value;
if (active.value === 'projects') return projectBoards.value;
return [];
});
const typesForActive = computed(() => {
if (active.value === 'activities') return activityTypes.value;
if (active.value === 'routines') return routineCategories.value;
return [];
});
</script> </script>
<template> <template>
<div> <div class="dap">
<a-typography-text v-if="!visibleSections.length" type="secondary"> <p v-if="!visibleSections.length" class="muted">
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید. برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
</a-typography-text> </p>
<a-tabs v-else v-model:activeKey="active"> <template v-else>
<a-tab-pane <div class="tabs" role="tablist">
<button
v-for="s in visibleSections" v-for="s in visibleSections"
:key="s" :key="s"
:tab="DATA_ACCESS_SECTION_LABELS[s]" type="button"
class="tab"
:class="{ on: active === s }"
role="tab"
:aria-selected="active === s"
@click="active = s"
> >
{{ DATA_ACCESS_SECTION_LABELS[s] }}
</button>
<button
v-if="showTreasury"
type="button"
class="tab"
:class="{ on: active === 'treasury' }"
role="tab"
:aria-selected="active === 'treasury'"
@click="active = 'treasury'"
>
مدیریت مالی
</button>
</div>
<div v-if="active === 'treasury'" class="treasury">
<p class="muted">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled"
@change="patchTreasury(null)"
/>
مثل نقش
</label>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess != null"
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/>
دسترسی به مدیریت مالی
</label>
<template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
<input
type="checkbox"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
/>
{{ perm.label }}
</label>
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="muted">حساب‌های دیگر</p>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
<input
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
</template>
</template>
</div>
<DataAccessEditor <DataAccessEditor
:section="s" v-else
:model-value="sectionValue(s)" :key="active"
:groups="customerGroups" :section="editorSection"
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []" :model-value="sectionValue(editorSection)"
:stages="stagesForActive"
:pipelines="pipelines" :pipelines="pipelines"
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []" :boards="boardsForActive"
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []" :activity-types="typesForActive"
:import-sources="importSources" :import-sources="importSources"
:import-sources-ready="importSourcesReady" :import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed" :import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked" :disabled="disabled || dataLocked"
:compact="compact" :compact="compact"
@update:model-value="setSection(s, $event)" @update:model-value="setSection(editorSection, $event)"
/> />
</a-tab-pane>
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
<a-space direction="vertical">
<a-typography-text type="secondary">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</a-typography-text>
<a-radio-group
:value="treasuryAccess == null ? 'role' : 'custom'"
:disabled="disabled"
@change="onTreasuryModeChange"
>
<a-space direction="vertical">
<a-radio value="role">مثل نقش</a-radio>
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
</a-space>
</a-radio-group>
<template v-if="treasuryAccess">
<a-checkbox
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
>
دسترسی به مدیریت مالی
</a-checkbox>
<template v-if="treasuryAccess.enabled">
<a-space direction="vertical">
<a-checkbox
v-for="perm in treasurySections"
:key="perm.key"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
>
{{ perm.label }}
</a-checkbox>
</a-space>
<a-checkbox
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
>
حساب‌هایی که خودش مسئول آن‌هاست
</a-checkbox>
<a-typography-text type="secondary">حساب‌های دیگر</a-typography-text>
<a-input
v-model:value="treasuryAccountQuery"
placeholder="جستجوی حساب"
:disabled="disabled"
allow-clear
/>
<a-space direction="vertical">
<a-checkbox
v-for="account in filteredTreasuryAccounts"
:key="account.id"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</a-checkbox>
</a-space>
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
حسابی برای انتخاب نیست.
</a-typography-text>
</template> </template>
</template>
</a-space>
</a-tab-pane>
</a-tabs>
</div> </div>
</template> </template>
<style scoped>
.dap {
display: grid;
gap: 0.75rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tab {
font: inherit;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: #475569;
}
.tab.on {
border-color: #0f766e;
background: #f0fdfa;
color: #0f766e;
font-weight: 600;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
.treasury {
display: grid;
gap: 0.45rem;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem;
align-items: center;
}
.choice.sub {
padding-inline-start: 1.2rem;
}
</style>
+3 -5
View File
@@ -10,11 +10,11 @@ import {
Input as AInput, Input as AInput,
List as AList, List as AList,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Tag as ATag, Tag as ATag,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
const ASpaceCompact = ASpace.Compact; const ASpaceCompact = ASpace.Compact;
const AListItem = AList.Item; const AListItem = AList.Item;
@@ -259,8 +259,7 @@ onMounted(load);
:style="{ marginTop: '12px' }" :style="{ marginTop: '12px' }"
/> />
<CrmSkeleton :loading="loading"> <a-spin :spinning="loading">
<CrmSkeletonList :rows="5">
<a-list <a-list
v-if="items.length" v-if="items.length"
item-layout="horizontal" item-layout="horizontal"
@@ -305,7 +304,6 @@ onMounted(load);
description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید." description="هنوز چیزی در تایم‌لاین نیست. یک یادداشت یا فعالیت ثبت کنید."
:style="{ marginTop: '16px' }" :style="{ marginTop: '16px' }"
/> />
</CrmSkeletonList> </a-spin>
</CrmSkeleton>
</a-card> </a-card>
</template> </template>
@@ -1,568 +0,0 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ExcelFieldOption,
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import {
previewExcelClient,
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
previewPath: string;
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
/** تشخیص خروجی بدون‌هدر (مثل فعالیت دیدار) */
clientReadOptions?: ClientExcelReadOptions;
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
fail: [message: string];
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>>({});
const submitting = ref(false);
const phase = ref<'idle' | 'preview' | 'import'>('idle');
const progressLabel = ref('');
/** درصد ۰–۱۰۰؛ در مرحلهٔ خواندن فایل null */
const progressPct = ref<number | null>(null);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
const busyMessage = computed(() => {
if (phase.value === 'import') {
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
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;
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
progressPct.value = null;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
if (submitting.value) return;
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
function mergeImportResults(
acc: ImportContactsResult,
part: ImportContactsResult,
): ImportContactsResult {
const errors = [...acc.errors, ...(part.errors ?? [])];
return {
created: acc.created + (part.created ?? 0),
skipped: acc.skipped + (part.skipped ?? 0),
errors: errors.slice(0, 200),
boardId: part.boardId ?? acc.boardId,
boards: part.boards?.length ? part.boards : acc.boards,
sessionId: part.sessionId,
total: part.total ?? acc.total,
processed: part.processed ?? acc.processed,
hasMore: part.hasMore,
nextOffset: part.nextOffset,
};
}
async function postImportChunk(opts: {
file?: File;
map?: Record<string, string>;
sessionId?: string;
offset?: number;
}) {
const form = new FormData();
if (opts.file) form.append('file', opts.file);
if (opts.map && Object.values(opts.map).some(Boolean)) {
form.append('mapping', JSON.stringify(opts.map));
}
if (opts.sessionId) form.append('sessionId', opts.sessionId);
if (opts.offset != null) form.append('offset', String(opts.offset));
form.append('limit', '250');
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
// خواندن فایل‌های خیلی بزرگ در مرحلهٔ اول ممکن است طول بکشد
timeout: opts.file ? 10 * 60 * 1000 : 3 * 60 * 1000,
});
return data;
}
function updateImportProgress(processed: number, total: number) {
if (total <= 0) {
progressPct.value = null;
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
return;
}
const pct = Math.max(0, Math.min(100, Math.round((processed / total) * 100)));
progressPct.value = pct;
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function postPrepare(file: File, map?: Record<string, string>) {
if (!props.preparePath) {
throw new Error('preparePath تنظیم نشده');
}
const form = new FormData();
form.append('file', file);
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایم‌اوت کوتاه کافی است
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
headers: { 'Content-Type': undefined },
timeout: 3 * 60 * 1000,
});
return data;
}
async function waitPrepareReady(sessionId: string) {
if (!props.preparePath) throw new Error('preparePath تنظیم نشده');
const statusUrl = `${props.preparePath}/${sessionId}`;
const started = Date.now();
const maxMs = 15 * 60 * 1000;
while (Date.now() - started < maxMs) {
const { data } = await api.get<ExcelImportPrepareStatus>(statusUrl, {
timeout: 30_000,
});
if (data.status === 'ready') {
progressLabel.value = `فایل آماده شد — ${data.total.toLocaleString('fa-IR')} ردیف`;
return data;
}
if (data.status === 'error') {
throw new Error(data.error || 'آماده‌سازی فایل اکسل ناموفق بود');
}
const parsed = data.parsed || 0;
progressPct.value = null;
progressLabel.value = parsed
? `در حال خواندن فایل روی سرور… ${parsed.toLocaleString('fa-IR')} ردیف`
: 'در حال خواندن فایل روی سرور… لطفاً صبر کنید';
await sleep(1200);
}
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
async function runClientRowsImport(file: File, map?: Record<string, string>) {
if (!props.rowsImportPath || !props.clientAliases) {
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
}
phase.value = 'import';
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
progressPct.value = 0;
const rows = await readMappedRowsClient(
file,
props.clientAliases,
map,
props.clientReadOptions,
);
const total = rows.length;
if (total > 100_000) {
return {
created: 0,
skipped: 0,
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
total,
processed: 0,
hasMore: false,
};
}
updateImportProgress(0, total);
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
const chunkSize = 60;
for (let offset = 0; offset < total; offset += chunkSize) {
const slice = rows.slice(offset, offset + chunkSize);
const { data: part } = await api.post<ImportContactsResult>(
props.rowsImportPath,
{
rows: slice,
offset,
total,
sessionId,
},
{ timeout: 120_000 },
);
acc = mergeImportResults(acc, part);
updateImportProgress(part.processed ?? offset + slice.length, total);
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
break;
}
sessionId = part.sessionId;
if (!part.hasMore) break;
}
updateImportProgress(total, total);
return acc;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
if (useClientImport.value) {
return runClientRowsImport(file, map);
}
phase.value = 'import';
progressLabel.value = 'در حال ارسال فایل…';
progressPct.value = 0;
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
let offset = 0;
let guard = 0;
const maxChunks = 2000;
if (props.preparePath) {
const prep = await postPrepare(file, map);
if (!prep.sessionId) {
throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود');
}
sessionId = prep.sessionId;
progressLabel.value = 'در حال خواندن فایل روی سرور…';
const ready = await waitPrepareReady(sessionId);
if (!ready.total) {
return {
...acc,
total: 0,
processed: 0,
hasMore: false,
errors: ready.error
? [{ row: 1, message: ready.error }]
: [{ row: 1, message: 'هیچ ردیف داده‌ای در فایل نیست' }],
};
}
updateImportProgress(0, ready.total);
}
while (guard < maxChunks) {
guard += 1;
const part = await postImportChunk({
file: sessionId ? undefined : file,
map: sessionId ? undefined : map,
sessionId,
offset: sessionId ? offset : undefined,
});
acc = mergeImportResults(acc, part);
const total = part.total ?? 0;
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
break;
}
if (!part.hasMore || !part.sessionId) {
if (total > 0) updateImportProgress(total, total);
break;
}
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
if (guard >= maxChunks && acc.hasMore) {
acc.errors = [
...acc.errors,
{
row: 1,
message: 'ورود به‌خاطر تعداد مراحل زیاد متوقف شد. از جایی که مانده ادامه دهید یا فایل را تقسیم کنید.',
},
];
}
return acc;
}
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) {
return e instanceof Error && e.message ? e.message : fallback;
}
if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message)) {
return 'مهلت درخواست تمام شد — فایل خیلی بزرگ است یا سرور دیر جواب داد. دوباره تلاش کنید.';
}
if (!e.response) {
return 'ارتباط با سرور برقرار نشد. اتصال را بررسی کنید و دوباره تلاش کنید.';
}
const status = e.response.status;
const data = e.response.data as { message?: string | string[] } | string | undefined;
let msg: string | undefined;
if (typeof data === 'string' && data.trim() && !data.trim().startsWith('<')) {
msg = data.trim().slice(0, 280);
} else if (data && typeof data === 'object') {
const m = data.message;
msg = Array.isArray(m) ? m.join('، ') : m;
}
if (msg) return msg;
if (status === 413) return 'حجم فایل بیش از حد مجاز سرور است.';
if (status === 502 || status === 504) {
return 'سرور میانی قطع شد (معمولاً به‌خاطر حجم/زمان فایل). چند دقیقه بعد دوباره تلاش کنید.';
}
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
try {
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(
file,
props.clientAliases,
props.clientFields,
props.clientReadOptions,
);
} else {
const form = new FormData();
form.append('file', file);
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
data = res.data;
}
if (!data.needsMapping) {
try {
emit('done', await runChunkedImport(file));
reset();
emit('settled');
} catch (impErr) {
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
reset();
emit('settled');
}
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'import';
try {
emit('done', await runChunkedImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
phase.value = 'idle';
submitting.value = false;
progressLabel.value = '';
progressPct.value = null;
emit('settled');
}
}
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<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>
<UiBusyOverlay
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
فایل بزرگ است ({{ (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>
</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>
@@ -8,7 +8,7 @@ import {
Space as ASpace, Space as ASpace,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { PrinterOutlined } from '@/icons/ant-bridge'; import { PrinterOutlined } from '@ant-design/icons-vue';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney, currencySuffix } from '@/lib/money'; import { formatMoney, currencySuffix } from '@/lib/money';
+117 -61
View File
@@ -6,7 +6,7 @@ import {
ClockCircleOutlined, ClockCircleOutlined,
LeftOutlined, LeftOutlined,
RightOutlined, RightOutlined,
} from '@/icons/ant-bridge'; } from '@ant-design/icons-vue';
import { import {
addJalaliMonths, addJalaliMonths,
dateToJalali, dateToJalali,
@@ -30,6 +30,10 @@ const props = withDefaults(
placeholder?: string; placeholder?: string;
clearable?: boolean; clearable?: boolean;
icon?: 'calendar' | 'clock' | 'bell'; icon?: 'calendar' | 'clock' | 'bell';
/** اگر داده شود، متن باکس به‌جای تاریخ خام همین مقدار است. */
displayText?: string;
/** نمایی که با باز شدن پنل دیده می‌شود. */
startView?: 'days' | 'months' | 'years';
}>(), }>(),
{ {
mode: 'date', mode: 'date',
@@ -37,6 +41,8 @@ const props = withDefaults(
disabled: false, disabled: false,
clearable: true, clearable: true,
icon: undefined, icon: undefined,
displayText: '',
startView: 'days',
}, },
); );
@@ -52,6 +58,8 @@ const hourColRef = ref<HTMLElement | null>(null);
const minuteColRef = ref<HTMLElement | null>(null); const minuteColRef = ref<HTMLElement | null>(null);
const top = ref(0); const top = ref(0);
const left = ref(0); const left = ref(0);
/** بالای مودال Ant (حدود ۱۰۰۰) تا تقویم پشت پنجره نماند */
const popupZ = ref(120);
const todayJ = dateToJalali(new Date()); const todayJ = dateToJalali(new Date());
const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm }); const cursor = ref<{ jy: number; jm: number }>({ jy: todayJ.jy, jm: todayJ.jm });
const draftHour = ref(9); const draftHour = ref(9);
@@ -85,6 +93,7 @@ const triggerIcon = computed(() => {
}); });
const display = computed(() => { const display = computed(() => {
if (props.displayText) return props.displayText;
if (props.mode === 'time') { if (props.mode === 'time') {
const match = /^(\d{1,2}):(\d{2})$/.exec(model.value); const match = /^(\d{1,2}):(\d{2})$/.exec(model.value);
if (!match) return ''; if (!match) return '';
@@ -318,21 +327,46 @@ function place() {
const panel = panelRef.value; const panel = panelRef.value;
if (!trigger) return; if (!trigger) return;
const rect = trigger.getBoundingClientRect(); const rect = trigger.getBoundingClientRect();
const width = panel?.offsetWidth || (props.mode === 'time' ? (isMobile.value ? 340 : 280) : 320); const width =
const height = panel?.offsetHeight || (props.mode === 'time' ? (isMobile.value ? 460 : 320) : 360); panel?.offsetWidth ||
(props.mode === 'time' ? (isMobile.value ? 340 : 280) : 328);
const height =
panel?.offsetHeight ||
(props.mode === 'time' ? (isMobile.value ? 460 : 320) : 340);
const margin = 8;
let nextLeft = rect.left; let nextLeft = rect.left;
if (getComputedStyle(document.documentElement).direction === 'rtl') { if (getComputedStyle(document.documentElement).direction === 'rtl') {
nextLeft = rect.right - width; nextLeft = rect.right - width;
} }
nextLeft = Math.min(Math.max(8, nextLeft), window.innerWidth - width - 8); nextLeft = Math.min(
let nextTop = rect.bottom + 8; Math.max(margin, nextLeft),
if (nextTop + height > window.innerHeight - 8) { Math.max(margin, window.innerWidth - width - margin),
nextTop = Math.max(8, rect.top - height - 8); );
const spaceBelow = window.innerHeight - rect.bottom - margin * 2;
const spaceAbove = rect.top - margin * 2;
let nextTop: number;
if (height <= spaceBelow) {
nextTop = rect.bottom + margin;
} else if (height <= spaceAbove) {
nextTop = rect.top - height - margin;
} else if (spaceBelow >= spaceAbove) {
nextTop = Math.max(margin, window.innerHeight - height - margin);
} else {
nextTop = margin;
} }
left.value = nextLeft; left.value = nextLeft;
top.value = nextTop; top.value = nextTop;
} }
function liftAboveOverlays() {
let z = 120;
for (const el of document.querySelectorAll('.ant-modal-wrap, .ant-drawer, .ant-popover, [role="dialog"], [data-lead-filter]')) {
const zi = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
if (Number.isFinite(zi)) z = Math.max(z, zi + 20);
}
popupZ.value = z;
}
function onWindowChange() { function onWindowChange() {
syncViewport(); syncViewport();
if (open.value) place(); if (open.value) place();
@@ -341,9 +375,10 @@ function onWindowChange() {
async function show() { async function show() {
if (props.disabled) return; if (props.disabled) return;
syncDraftFrom(parsed.value); syncDraftFrom(parsed.value);
calView.value = 'days'; calView.value = props.startView;
clockFace.value = 'hour'; clockFace.value = 'hour';
syncViewport(); syncViewport();
liftAboveOverlays();
open.value = true; open.value = true;
await nextTick(); await nextTick();
place(); place();
@@ -538,7 +573,7 @@ onBeforeUnmount(() => {
'is-desktop': !isMobile, 'is-desktop': !isMobile,
}" }"
role="dialog" role="dialog"
:style="{ top: `${top}px`, left: `${left}px` }" :style="{ top: `${top}px`, left: `${left}px`, zIndex: popupZ }"
> >
<header v-if="mode !== 'time'" class="nav"> <header v-if="mode !== 'time'" class="nav">
<a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)"> <a-button type="text" class="nav-btn" :aria-label="navLabels.forward" @click="navStep(1)">
@@ -726,7 +761,7 @@ onBeforeUnmount(() => {
> >
پاک کردن پاک کردن
</a-button> </a-button>
<a-button type="primary" class="done" @click="hide">تأیید</a-button> <a-button type="primary" @click="hide">تأیید</a-button>
</footer> </footer>
</div> </div>
</Teleport> </Teleport>
@@ -851,10 +886,11 @@ onBeforeUnmount(() => {
.panel { .panel {
position: fixed; position: fixed;
z-index: 120; z-index: 120;
box-sizing: border-box;
width: min(20.5rem, calc(100vw - 16px)); width: min(20.5rem, calc(100vw - 16px));
max-height: calc(100vh - 16px); /* تقویم باید یکجا دیده شود؛ اسکرول داخل پنل گیج‌کننده است */
overflow-x: hidden; max-height: none;
overflow-y: auto; overflow: hidden;
background: #fff; background: #fff;
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18); box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
@@ -862,6 +898,13 @@ onBeforeUnmount(() => {
font-family: IRANSans, Tahoma, sans-serif; font-family: IRANSans, Tahoma, sans-serif;
} }
@media (max-height: 420px) {
.panel {
max-height: calc(100vh - 16px);
overflow: auto;
}
}
.panel-time { .panel-time {
width: min(22.5rem, calc(100vw - 16px)); width: min(22.5rem, calc(100vw - 16px));
} }
@@ -893,45 +936,40 @@ onBeforeUnmount(() => {
.pick-grid { .pick-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, 1fr);
gap: 0.35rem; gap: 0.35rem;
padding: 0.25rem 0; padding: 0.25rem 0;
} }
.panel :deep(.ant-btn.pick-cell) { .panel :deep(.pick-cell.ant-btn) {
width: 100%; width: 100% !important;
min-width: 0; min-width: 0 !important;
height: 44px; height: 44px !important;
margin: 0; padding-inline: 0 !important;
padding: 0 4px;
border-radius: var(--radius); border-radius: var(--radius);
color: #0f172a; color: #0f172a;
font-size: 0.85rem; font-size: 0.85rem;
} }
.panel :deep(.ant-btn.pick-cell.today) { .panel :deep(.pick-cell.today) {
box-shadow: inset 0 0 0 1px #99f6e4; box-shadow: inset 0 0 0 1px #99f6e4;
} }
.panel :deep(.ant-btn.pick-cell.on), .panel :deep(.pick-cell.on),
.panel :deep(.ant-btn.pick-cell:hover) { .panel :deep(.pick-cell.ant-btn:hover) {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
.panel :deep(.ant-btn.nav-btn) { .nav-btn {
width: 32px; width: 32px;
min-width: 32px;
height: 32px; height: 32px;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: #f1f5f9; background: #f1f5f9;
color: #0f766e; color: #0f766e;
display: inline-flex; display: grid;
align-items: center; place-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
} }
@@ -944,6 +982,7 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr)); grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.15rem; gap: 0.15rem;
width: 100%;
min-width: 0; min-width: 0;
} }
@@ -954,40 +993,43 @@ onBeforeUnmount(() => {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.panel :deep(.ant-btn.day) { .panel :deep(.day.ant-btn) {
display: inline-flex; width: 100% !important;
align-items: center; min-width: 0 !important;
justify-content: center; height: 34px !important;
width: 100%; padding: 0 !important;
min-width: 0;
height: 34px;
margin: 0;
padding: 0;
line-height: 1;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: none; background: none;
color: #0f172a; color: #0f172a;
font: inherit; font: inherit;
font-size: 0.82rem;
line-height: 1;
cursor: pointer; cursor: pointer;
box-shadow: none;
} }
.panel :deep(.ant-btn.day.mute) { .panel :deep(.day.ant-btn > span) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
}
.panel :deep(.day.mute) {
color: #cbd5e1; color: #cbd5e1;
} }
.panel :deep(.ant-btn.day.today) { .panel :deep(.day.today) {
box-shadow: inset 0 0 0 1px #99f6e4; box-shadow: inset 0 0 0 1px #99f6e4;
} }
.panel :deep(.ant-btn.day.on), .panel :deep(.day.on),
.panel :deep(.ant-btn.day:hover) { .panel :deep(.day.ant-btn:hover) {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
.panel :deep(.ant-btn.day.mute:hover) { .panel :deep(.day.mute:hover) {
background: #f1f5f9; background: #f1f5f9;
color: #64748b; color: #64748b;
} }
@@ -1017,11 +1059,9 @@ onBeforeUnmount(() => {
margin-bottom: 0.85rem; margin-bottom: 0.85rem;
} }
.panel :deep(.ant-btn.md-chip) { .md-chip {
min-width: 5.1rem; min-width: 5.1rem;
height: 4.1rem; height: 4.1rem;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: #f1f5f9; background: #f1f5f9;
@@ -1034,7 +1074,7 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
} }
.panel :deep(.ant-btn.md-chip.on) { .md-chip.on {
background: #ccfbf1; background: #ccfbf1;
color: #0f766e; color: #0f766e;
box-shadow: inset 0 0 0 2px #99f6e4; box-shadow: inset 0 0 0 2px #99f6e4;
@@ -1108,11 +1148,10 @@ onBeforeUnmount(() => {
pointer-events: none; pointer-events: none;
} }
.panel :deep(.ant-btn.md-tick) { .md-tick {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
width: 36px; width: 36px;
min-width: 36px;
height: 36px; height: 36px;
margin: 0; margin: 0;
border: 0; border: 0;
@@ -1129,12 +1168,12 @@ onBeforeUnmount(() => {
padding: 0; padding: 0;
} }
.panel :deep(.ant-btn.md-tick.inner) { .md-tick.inner {
font-size: 0.74rem; font-size: 0.74rem;
color: #64748b; color: #64748b;
} }
.panel :deep(.ant-btn.md-tick.on) { .md-tick.on {
background: transparent; background: transparent;
color: #fff; color: #fff;
} }
@@ -1183,12 +1222,9 @@ onBeforeUnmount(() => {
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.panel :deep(.ant-btn.col-item) { .col-item {
width: 100%; width: 100%;
min-width: 0;
height: 36px; height: 36px;
margin: 0;
padding: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
background: transparent; background: transparent;
@@ -1199,11 +1235,11 @@ onBeforeUnmount(() => {
cursor: pointer; cursor: pointer;
} }
.panel :deep(.ant-btn.col-item:hover) { .col-item:hover {
background: #e2e8f0; background: #e2e8f0;
} }
.panel :deep(.ant-btn.col-item.on) { .col-item.on {
background: #0d9488; background: #0d9488;
color: #fff; color: #fff;
} }
@@ -1215,8 +1251,28 @@ onBeforeUnmount(() => {
margin-top: 0.7rem; margin-top: 0.7rem;
} }
.panel :deep(.ant-btn.done) { .link,
.done {
border: 0;
background: none;
font: inherit;
cursor: pointer;
}
.link {
color: #0f766e;
font-size: 0.8rem;
font-weight: 700;
}
.done {
margin-inline-start: auto; margin-inline-start: auto;
background: #14b8a6;
color: #fff;
border-radius: var(--radius);
padding: 0.4rem 0.8rem;
font-weight: 600;
font-size: 0.8rem;
} }
.panel.is-mobile .time-box { .panel.is-mobile .time-box {
+3 -5
View File
@@ -9,10 +9,10 @@ import {
List as AList, List as AList,
Modal, Modal,
Space as ASpace, Space as ASpace,
Spin as ASpin,
Textarea as ATextarea, Textarea as ATextarea,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali'; import { formatJalaliDateTime } from '@/lib/jalali';
@@ -163,8 +163,7 @@ onMounted(load);
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" /> <a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
<CrmSkeleton :loading="loading"> <a-spin :spinning="loading">
<CrmSkeletonList :rows="4">
<a-typography-text v-if="!loading && !hasItems" type="secondary"> <a-typography-text v-if="!loading && !hasItems" type="secondary">
هنوز موردی در حافظه ثبت نشده. هنوز موردی در حافظه ثبت نشده.
</a-typography-text> </a-typography-text>
@@ -202,7 +201,6 @@ onMounted(load);
</a-list-item> </a-list-item>
</template> </template>
</a-list> </a-list>
</CrmSkeletonList> </a-spin>
</CrmSkeleton>
</a-card> </a-card>
</template> </template>
+3
View File
@@ -22,6 +22,7 @@ const props = withDefaults(
/** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */ /** اگر true باشد مقدار عددی emit می‌شود (نه رشته) */
asNumber?: boolean; asNumber?: boolean;
dir?: 'ltr' | 'rtl'; dir?: 'ltr' | 'rtl';
addonAfter?: string;
}>(), }>(),
{ {
required: false, required: false,
@@ -30,6 +31,7 @@ const props = withDefaults(
decimals: 2, decimals: 2,
asNumber: false, asNumber: false,
dir: 'ltr', dir: 'ltr',
addonAfter: '',
}, },
); );
@@ -129,6 +131,7 @@ function normalizeSep(raw: string): string {
:disabled="disabled" :disabled="disabled"
:readonly="readonly" :readonly="readonly"
:value="text" :value="text"
:addon-after="addonAfter || undefined"
style="width: 100%; font-variant-numeric: tabular-nums" style="width: 100%; font-variant-numeric: tabular-nums"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
+165 -94
View File
@@ -5,20 +5,6 @@ import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared'; import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali'; import { rewriteGregorianDates } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Flex as AFlex,
Input as AInput,
Space as ASpace,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } =
Typography;
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@@ -162,108 +148,193 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
</script> </script>
<template> <template>
<a-card :bordered="true" :style="{ marginTop: '12px' }"> <section class="ai-panel">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8"> <header>
<a-typography-text strong>پیشنهاد {{ brand.aiName }}</a-typography-text> <strong>پیشنهاد {{ brand.aiName }}</strong>
<a-button <button
v-if="!isPending" v-if="!isPending"
type="link" type="button"
class="link"
:disabled="loading" :disabled="loading"
@click="analyze" @click="analyze"
> >
{{ loading ? '…' : 'تحلیل مجدد' }} {{ loading ? '…' : 'تحلیل مجدد' }}
</a-button> </button>
<a-button <button
v-else-if="!suggestion" v-else-if="!suggestion"
type="link" type="button"
class="link"
:disabled="loading" :disabled="loading"
@click="analyze" @click="analyze"
> >
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }} {{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
</a-button> </button>
</a-flex> </header>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginTop: '8px' }" /> <p v-if="error" class="error">{{ error }}</p>
<a-typography-text v-else-if="loading && !suggestion" type="secondary"> <p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
در حال تحلیل یادداشت… <p v-else-if="suggestion?.status === 'failed'" class="error">
</a-typography-text> {{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
<a-flex v-else-if="suggestion?.status === 'failed'" vertical :gap="4"> <button type="button" class="link" @click="analyze">تلاش دوباره</button>
<a-alert </p>
type="error" <p v-else-if="!suggestion" class="muted">
show-icon <button type="button" class="link" :disabled="loading" @click="analyze">
:message="suggestion.errorMessage || 'تحلیل ناموفق بود'"
/>
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
</a-flex>
<a-button
v-else-if="!suggestion"
type="link"
:disabled="loading"
@click="analyze"
>
تحلیل این یادداشت با AI تحلیل این یادداشت با AI
</a-button> </button>
</p>
<template v-else-if="isPending && payload"> <template v-else-if="isPending && payload">
<a-typography-paragraph <p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
v-if="formatPayloadSummary(payload)"
type="secondary"
:style="{ marginTop: '8px' }"
>
{{ formatPayloadSummary(payload) }}
</a-typography-paragraph>
<a-flex v-if="payload.nextAction" vertical :gap="8" :style="{ marginTop: '8px' }"> <div v-if="payload.nextAction" class="block">
<a-checkbox v-model:checked="applyNextAction">کار بعدی</a-checkbox> <label class="check">
<a-input v-model:value="nextTitle" placeholder="عنوان کار بعدی" style="width: 100%" /> <input v-model="applyNextAction" type="checkbox" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" style="width: 100%" /> کار بعدی
</a-flex> </label>
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
<JalaliDateInput v-model="nextDueAt" mode="datetime" />
</div>
<a-flex v-if="memoryItems.length" vertical :gap="8" :style="{ marginTop: '12px' }"> <div v-if="memoryItems.length" class="block">
<a-typography-title :level="5" :style="{ margin: 0 }"> <p class="block-title">حافظه مشتری (با تأیید شما ذخیره می‌شود)</p>
حافظه مشتری (با تأیید شما ذخیره می‌شود) <p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
</a-typography-title> <label
<a-alert
v-if="!hasPerson"
type="warning"
show-icon
message="برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد."
/>
<a-flex
v-for="(_, index) in memoryItems" v-for="(_, index) in memoryItems"
:key="index" :key="index"
:gap="8" class="memory-row"
align="flex-start"
> >
<a-checkbox <input
v-model:checked="selectedMemory[index]" v-model="selectedMemory[index]"
type="checkbox"
:disabled="!hasPerson" :disabled="!hasPerson"
/> />
<a-textarea v-model:value="memoryEdits[index]" :rows="2" style="width: 100%" /> <textarea v-model="memoryEdits[index]" rows="2" />
</a-flex> </label>
</a-flex> </div>
<a-space :size="8" wrap :style="{ marginTop: '12px' }"> <div class="actions">
<a-button type="primary" :disabled="applying" :loading="applying" @click="apply"> <button type="button" class="btn" :disabled="applying" @click="apply">
اعمال انتخاب‌شده {{ applying ? '…' : 'اعمال انتخاب‌شده' }}
</a-button> </button>
<a-button :disabled="applying" @click="dismiss">رد کردن</a-button> <button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
</a-space> رد کردن
</button>
</div>
</template> </template>
<a-typography-text <p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
v-else-if="suggestion?.status === 'applied'" <p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
type="success" </section>
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد اعمال شد.
</a-typography-text>
<a-typography-text
v-else-if="suggestion?.status === 'dismissed'"
type="secondary"
:style="{ display: 'block', marginTop: '8px' }"
>
پیشنهاد رد شد.
</a-typography-text>
</a-card>
</template> </template>
<style scoped>
.ai-panel {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border: 1px dashed var(--accent);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.link {
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.block {
display: grid;
gap: 0.45rem;
}
.block-title {
margin: 0;
font-weight: 600;
}
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
.memory-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.45rem;
align-items: start;
}
input[type='text'],
textarea {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 520px) {
.actions {
flex-direction: column;
}
.actions .btn,
.actions .btn-ghost {
width: 100%;
}
}
.summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.muted {
margin: 0;
color: var(--muted);
}
.ok {
color: #067647;
}
.warn {
margin: 0;
color: #b54708;
font-size: 0.88rem;
}
.error {
margin: 0;
color: #b42318;
}
</style>
+133 -290
View File
@@ -3,59 +3,35 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles'; import { USER_ROLE_LABELS } from '@/lib/roles';
import type { OrganizationMembershipDto } from '@mento/shared'; import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
const props = withDefaults( defineProps<{
defineProps<{
compact?: boolean; compact?: boolean;
variant?: 'default' | 'workspace';
}>(),
{
compact: false,
variant: 'default',
},
);
const emit = defineEmits<{
open: [];
close: [];
}>(); }>();
const auth = useAuthStore(); const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]); const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false); const open = ref(false);
const createOpen = ref(false);
const loading = ref(false); const loading = ref(false);
const creating = ref(false);
const error = ref(''); const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null); const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null); const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({}); const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0; let ignoreDocClickUntil = 0;
const currentName = computed( const currentName = computed(
() => () => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
auth.user?.organizationName ||
orgs.value.find((o) => o.id === auth.user?.organizationId)?.name ||
'محیط کاری',
); );
const currentSubtitle = computed(() => {
const current = orgs.value.find((o) => o.id === auth.user?.organizationId);
if (current?.orgRoleName) return current.orgRoleName;
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
return 'محیط کاری';
});
const currentInitial = computed(() => {
const name = currentName.value.trim();
return name ? name.charAt(0) : 'م';
});
const canSwitch = computed(() => orgs.value.length > 1); const canSwitch = computed(() => orgs.value.length > 1);
const isWorkspace = computed(() => props.variant === 'workspace');
/** در حالت workspace منو همیشه باز می‌شود؛ در حالت پیش‌فرض فقط وقتی بیش از یکی باشد */
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
function apiError(e: unknown, fallback: string) { function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
@@ -73,62 +49,34 @@ async function loadOrgs() {
} }
} }
function closeMenu() {
if (!open.value) return;
open.value = false;
emit('close');
}
async function toggle() { async function toggle() {
if (!menuEnabled.value) return; if (!canSwitch.value) return;
error.value = ''; error.value = '';
if (open.value) { if (open.value) {
closeMenu(); open.value = false;
return; return;
} }
ignoreDocClickUntil = Date.now() + 400; ignoreDocClickUntil = Date.now() + 400;
open.value = true; open.value = true;
emit('open');
await nextTick(); await nextTick();
placeMenu(); placeMenu();
await loadOrgs(); await loadOrgs();
await nextTick();
placeMenu();
} }
function placeMenu() { function placeMenu() {
const btn = rootEl.value?.querySelector('button'); const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return; if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect(); const box = btn.getBoundingClientRect();
const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200));
const sidebar = document.getElementById('app-sidebar');
const rail = sidebar?.getBoundingClientRect();
// لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px
const edge = rail?.left ?? box.left;
const beside = {
right: 'auto',
left: `${Math.round(edge - 4)}px`,
transform: 'translateX(-100%)',
minWidth: `${minWidth}px`,
};
if (isWorkspace.value) {
menuStyle.value = { menuStyle.value = {
...beside, bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
top: `${Math.round(box.top)}px`, right: `${Math.round(window.innerWidth - box.right)}px`,
bottom: 'auto', minWidth: `${Math.round(Math.max(box.width, 200))}px`,
};
return;
}
menuStyle.value = {
...beside,
top: 'auto',
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
}; };
} }
async function choose(org: OrganizationMembershipDto) { async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) { if (org.id === auth.user?.organizationId) {
closeMenu(); open.value = false;
return; return;
} }
loading.value = true; loading.value = true;
@@ -142,16 +90,42 @@ async function choose(org: OrganizationMembershipDto) {
} }
} }
function openCreate() {
open.value = false;
newName.value = '';
newField.value = 'personal';
error.value = '';
createOpen.value = true;
}
async function submitCreate() {
const name = newName.value.trim();
if (name.length < 2) {
error.value = 'نام محیط باید حداقل دو حرف باشد.';
return;
}
creating.value = true;
error.value = '';
try {
await auth.createOrganization({
name,
businessField: newField.value,
});
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
creating.value = false;
}
}
function onDocClick(event: MouseEvent) { function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return; if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target; const target = event.target;
if (!(target instanceof Node)) return; if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return; if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
closeMenu(); open.value = false;
} }
defineExpose({ close: closeMenu });
onMounted(() => { onMounted(() => {
void loadOrgs(); void loadOrgs();
document.addEventListener('click', onDocClick); document.addEventListener('click', onDocClick);
@@ -163,46 +137,27 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }"> <div ref="rootEl" class="org-switch">
<button <button
class="org-btn" class="org-btn"
:class="{ :class="{ compact, static: !canSwitch }"
compact,
static: !menuEnabled,
'org-ws': isWorkspace,
open,
}"
type="button" type="button"
:title="currentName" :title="currentName"
:aria-expanded="open" :aria-disabled="!canSwitch"
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
:aria-disabled="!menuEnabled"
@click.stop="toggle" @click.stop="toggle"
> >
<template v-if="isWorkspace">
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
<span class="org-copy">
<strong>{{ currentName }}</strong>
<small>{{ currentSubtitle }}</small>
</span>
<i class="org-chev" :class="{ open }" aria-hidden="true" />
</template>
<template v-else>
<UiIcon name="building" :size="18" /> <UiIcon name="building" :size="18" />
<span>{{ currentName }}</span> <span>{{ currentName }}</span>
</template>
</button> </button>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="open && menuEnabled" v-if="open && canSwitch"
ref="menuEl" ref="menuEl"
class="menu" class="menu"
:class="{ 'org-ws': isWorkspace }"
:style="menuStyle" :style="menuStyle"
role="listbox" role="listbox"
aria-label="محیط‌های کاری"
> >
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p> <p class="hint">ورود به محیط</p>
<button <button
v-for="org in orgs" v-for="org in orgs"
:key="org.id" :key="org.id"
@@ -210,25 +165,41 @@ onUnmounted(() => {
class="org-item" class="org-item"
:class="{ on: org.id === auth.user?.organizationId }" :class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading" :disabled="loading"
role="option"
:aria-selected="org.id === auth.user?.organizationId"
@click="choose(org)" @click="choose(org)"
> >
<span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
<span class="org-item-copy">
<strong>{{ org.name }}</strong> <strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span> <span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</span>
<UiIcon
v-if="org.id === auth.user?.organizationId"
class="org-check"
name="check"
:size="16"
/>
</button> </button>
<p v-if="error" class="err">{{ error }}</p> <button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div> </div>
</Teleport> </Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div> </div>
</template> </template>
@@ -263,7 +234,7 @@ onUnmounted(() => {
margin-top: 0.05rem; margin-top: 0.05rem;
} }
.org-btn > span { .org-btn span {
min-width: 0; min-width: 0;
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -282,7 +253,7 @@ onUnmounted(() => {
color: #94a3b8; color: #94a3b8;
} }
.org-btn.compact:not(.org-ws) { .org-btn.compact {
display: grid; display: grid;
place-items: center; place-items: center;
width: 100%; width: 100%;
@@ -291,113 +262,18 @@ onUnmounted(() => {
border-radius: var(--radius); border-radius: var(--radius);
} }
.org-btn.compact:not(.org-ws) > span { .org-btn.compact span {
display: none; display: none;
} }
.org-btn.compact:not(.org-ws):hover { .org-btn.compact:hover {
background: #f8fafc; background: #f8fafc;
} }
.org-btn.compact.static:not(.org-ws):hover { .org-btn.compact.static:hover {
background: transparent; background: transparent;
} }
.org-btn.org-ws {
align-items: center;
gap: 0.7rem;
padding: 0.45rem 0.55rem;
border-radius: 16px;
color: #0f172a;
text-align: start;
}
.org-btn.org-ws:hover,
.org-btn.org-ws.open {
background: #f8fafc;
color: #0f172a;
}
.org-btn.org-ws.static:hover {
background: #f8fafc;
color: #0f172a;
}
.org-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--accent-soft) 80%, white);
color: var(--accent-deep);
font-size: 0.92rem;
font-weight: 800;
}
.org-copy {
min-width: 0;
flex: 1;
display: grid;
gap: 0.08rem;
}
.org-copy strong {
font-size: 0.92rem;
font-weight: 800;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-copy small {
font-size: 0.72rem;
font-weight: 600;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-chev {
width: 0.42rem;
height: 0.42rem;
border-right: 1.6px solid #94a3b8;
border-bottom: 1.6px solid #94a3b8;
/* RTL: اشاره به بیرون (چپ) */
transform: rotate(135deg);
transition: transform 0.15s ease;
flex-shrink: 0;
margin-inline-start: 0.15rem;
}
.org-chev.open {
transform: rotate(45deg);
}
.org-btn.compact.org-ws {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: 14px;
}
.org-btn.compact.org-ws .org-copy,
.org-btn.compact.org-ws .org-chev {
display: none;
}
.org-btn.compact.org-ws .org-mark {
width: 32px;
height: 32px;
font-size: 0.82rem;
}
.menu { .menu {
position: fixed; position: fixed;
z-index: 90; z-index: 90;
@@ -410,21 +286,11 @@ onUnmounted(() => {
gap: 0.2rem; gap: 0.2rem;
} }
.menu.org-ws {
padding: 0.5rem;
border-radius: 16px;
border: 1px solid #e8eef3;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
gap: 4px;
}
.hint { .hint {
margin: 0; margin: 0;
padding: 0.35rem 0.65rem 0.45rem; padding: 0.25rem 0.45rem;
font-size: 0.7rem; font-size: 0.72rem;
font-weight: 700; color: var(--muted);
letter-spacing: 0.02em;
color: #94a3b8;
} }
.org-item, .org-item,
@@ -439,89 +305,29 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
} }
.menu.org-ws .org-item {
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
border-radius: 12px;
padding: 0.55rem 0.65rem;
color: #0f172a;
}
.org-item-mark {
width: 36px;
height: 36px;
border-radius: 999px;
display: grid;
place-items: center;
background: #eef2f7;
color: #475569;
font-size: 0.85rem;
font-weight: 800;
flex-shrink: 0;
}
.org-item-copy {
min-width: 0;
display: grid;
gap: 0.12rem;
}
.org-item strong { .org-item strong {
font-size: 0.88rem; font-size: 0.82rem;
font-weight: 700;
color: #0f172a;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.org-item span, .org-item span {
.org-item-copy span { font-size: 0.72rem;
font-size: 0.74rem; color: var(--muted);
font-weight: 500;
color: #94a3b8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-check {
color: #0f172a;
flex-shrink: 0;
} }
.org-item:hover, .org-item:hover,
.create:hover { .create:hover {
background: #f3f4f6; background: color-mix(in srgb, var(--accent-soft) 70%, white);
}
.menu.org-ws .org-item:hover {
background: #f3f4f6;
}
.menu.org-ws .org-item:hover .org-item-mark {
background: #e2e8f0;
} }
.org-item.on { .org-item.on {
background: #f3f4f6; background: var(--accent-soft);
} }
.menu.org-ws .org-item.on { .create {
background: #f3f4f6; margin-top: 0.15rem;
} color: var(--accent);
font-weight: 700;
.menu.org-ws .org-item.on .org-item-mark { font-size: 0.82rem;
background: color-mix(in srgb, var(--accent-soft) 85%, white);
color: var(--accent-deep);
}
.menu.org-ws .org-item.on strong {
font-weight: 800;
color: #0f172a;
} }
.err { .err {
@@ -529,4 +335,41 @@ onUnmounted(() => {
color: #b91c1c; color: #b91c1c;
font-size: 0.75rem; font-size: 0.75rem;
} }
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style> </style>
@@ -0,0 +1,367 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import type { OrgUserDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { normalizeSearchText } from '@/lib/search';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
const props = withDefaults(
defineProps<{
modelValue: string[];
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
disabled?: boolean;
emptyLabel?: string;
}>(),
{
disabled: false,
emptyLabel: 'انتخاب مسئول',
},
);
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const searchInput = ref<HTMLInputElement | null>(null);
const selected = computed(() =>
props.modelValue
.map((id) => props.users.find((u) => u.id === id))
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
);
const label = computed(() => {
if (!selected.value.length) return props.emptyLabel;
if (selected.value.length === 1) return selected.value[0]!.fullName;
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
});
const filtered = computed(() => {
const q = normalizeSearchText(query.value);
if (!q) return props.users;
return props.users.filter(
(u) =>
normalizeSearchText(u.fullName).includes(q) ||
normalizeSearchText(u.orgRoleName ?? '').includes(q),
);
});
function toggleMenu() {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
void nextTick(() => searchInput.value?.focus());
}
}
function close() {
open.value = false;
}
function setIds(next: string[]) {
emit('update:modelValue', next);
emit('change', next);
}
function toggle(userId: string) {
if (props.disabled) return;
const set = new Set(props.modelValue);
if (set.has(userId)) set.delete(userId);
else set.add(userId);
setIds([...set]);
}
function remove(userId: string) {
if (props.disabled) return;
if (!props.modelValue.includes(userId)) return;
setIds(props.modelValue.filter((id) => id !== userId));
}
watch(open, (isOpen) => {
if (!isOpen) query.value = '';
});
defineExpose({ close });
</script>
<template>
<div class="wrap" @click.stop>
<button
class="picker person-pick"
type="button"
:disabled="disabled"
@click="toggleMenu"
>
<div class="assignee-avatars">
<UserAvatar
v-for="user in selected.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
</div>
<span :class="{ ghost: !selected.length }">{{ label }}</span>
<UiIcon name="chevron" :size="16" />
</button>
<div v-if="selected.length" class="chips">
<span v-for="user in selected" :key="user.id" class="chip">
{{ user.fullName }}
<button
type="button"
class="chip-x"
:disabled="disabled"
:aria-label="`حذف ${user.fullName}`"
@click="remove(user.id)"
>
×
</button>
</span>
</div>
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
<label class="person-search">
<UiIcon name="search" :size="15" />
<input
ref="searchInput"
v-model="query"
type="search"
placeholder="جستجوی نام یا نقش…"
@keydown.esc="close"
/>
</label>
<ul class="person-list">
<li v-for="user in filtered" :key="user.id">
<button
type="button"
class="multi-option"
:class="{ on: modelValue.includes(user.id) }"
@click="toggle(user.id)"
>
<span class="check" :class="{ on: modelValue.includes(user.id) }">
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<span class="role-name">{{ user.fullName }}</span>
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
</button>
</li>
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
</ul>
</div>
</div>
</template>
<style scoped>
.wrap {
position: relative;
width: 100%;
min-width: 12rem;
}
.picker {
width: 100%;
min-height: var(--control-h, 36px);
height: var(--control-h, 36px);
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
background: #fff;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.7rem;
font: inherit;
text-align: start;
cursor: pointer;
}
.picker:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.picker .ghost {
color: #94a3b8;
font-weight: 500;
}
.picker :deep(.ico:last-child) {
margin-inline-start: auto;
color: #94a3b8;
transform: rotate(90deg);
}
.assignee-avatars {
display: flex;
align-items: center;
}
.assignee-avatars :deep(.avatar),
.assignee-avatars > * {
margin-inline-end: -0.35rem;
box-shadow: 0 0 0 2px #fff;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 100%;
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #99f6e4;
color: #0f766e;
font-size: 0.78rem;
font-weight: 600;
}
.chip-x {
appearance: none;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0 0.2rem;
border-radius: 999px;
}
.chip-x:hover:not(:disabled) {
background: #ccfbf1;
}
.chip-x:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.menu {
position: absolute;
inset-inline: 0;
top: calc(100% + 0.25rem);
z-index: 40;
margin: 0;
background: #fff;
border-radius: var(--radius, 10px);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
}
.person-menu {
max-height: none;
overflow: visible;
display: grid;
gap: 0.35rem;
padding: 0.45rem;
border: 1px solid #e2e8f0;
}
.person-search {
display: flex;
align-items: center;
gap: 0.45rem;
height: 2.4rem;
padding: 0 0.7rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius, 10px);
color: #94a3b8;
}
.person-search:focus-within {
border-color: #5eead4;
}
.person-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: 0.88rem;
color: #0f172a;
}
.person-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 15rem;
overflow-y: auto;
}
.multi-menu .multi-option {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
border: 0;
background: none;
padding: 0.55rem 0.5rem;
border-radius: var(--radius, 10px);
font: inherit;
text-align: start;
cursor: pointer;
}
.multi-menu .multi-option:hover {
background: #f8fafc;
}
.multi-menu .multi-option.on {
background: #f0fdfa;
}
.multi-menu .check {
width: 18px;
height: 18px;
border: 1.5px solid #cbd5e1;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.multi-menu .check.on {
background: #14b8a6;
border-color: #14b8a6;
}
.multi-menu .role-name {
min-width: 0;
}
.multi-menu .role-count {
margin-inline-start: auto;
color: #94a3b8;
font-size: 0.72rem;
}
.menu-empty {
padding: 0.6rem;
color: #94a3b8;
font-size: 0.82rem;
}
</style>
@@ -18,7 +18,7 @@ import {
Upload as AUpload, Upload as AUpload,
} from 'ant-design-vue'; } from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue'; import type { UploadProps } from 'ant-design-vue';
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge'; import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
import { import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
+33 -307
View File
@@ -185,13 +185,6 @@ const fabStyle = computed(() => ({
bottom: 'auto', bottom: 'auto',
})); }));
const panelPlacement = computed(() => {
const edge = 16;
const spaceAbove = fabPos.value.top - edge;
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
return spaceAbove >= spaceBelow ? 'above' : 'below';
});
const panelStyle = computed(() => { const panelStyle = computed(() => {
const gap = 12; const gap = 12;
const edge = 16; const edge = 16;
@@ -200,33 +193,28 @@ const panelStyle = computed(() => {
left = Math.min(left, viewport.value.w - width - edge); left = Math.min(left, viewport.value.w - width - edge);
left = Math.max(edge, left); left = Math.max(edge, left);
const placeAbove = panelPlacement.value === 'above'; const spaceAbove = fabPos.value.top - edge;
const maxHeight = Math.max( const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
0, const placeAbove = spaceAbove >= spaceBelow;
(placeAbove const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap);
? fabPos.value.top - edge
: viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge) - gap,
);
const shared = {
left: `${left}px`,
right: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
transformOrigin: placeAbove ? 'bottom left' : 'top left',
};
if (placeAbove) { if (placeAbove) {
return { return {
...shared, left: `${left}px`,
right: 'auto',
bottom: `${viewport.value.h - fabPos.value.top + gap}px`, bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto', top: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
}; };
} }
return { return {
...shared, left: `${left}px`,
right: 'auto',
top: `${fabPos.value.top + FAB_SIZE + gap}px`, top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto', bottom: 'auto',
width: `${width}px`,
maxHeight: `${maxHeight}px`,
}; };
}); });
@@ -250,6 +238,10 @@ function onKeydown(ev: KeyboardEvent) {
} }
} }
watch(isQuickCreateOpen, (value) => {
document.body.style.overflow = value ? 'hidden' : '';
});
watch(hidden, (value) => { watch(hidden, (value) => {
if (value) close(); if (value) close();
}); });
@@ -272,13 +264,13 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown); window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
}); });
</script> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl"> <div v-if="!hidden" class="qc" dir="rtl">
<Transition name="qc-overlay">
<button <button
v-if="isQuickCreateOpen" v-if="isQuickCreateOpen"
class="overlay" class="overlay"
@@ -286,24 +278,20 @@ onBeforeUnmount(() => {
aria-label="بستن ایجاد سریع" aria-label="بستن ایجاد سریع"
@click="close" @click="close"
/> />
</Transition>
<Transition name="qc-panel">
<section <section
v-if="isQuickCreateOpen" v-if="isQuickCreateOpen"
class="panel" class="panel"
:data-place="panelPlacement"
aria-labelledby="qc-title" aria-labelledby="qc-title"
:style="panelStyle" :style="panelStyle"
> >
<h2 id="qc-title">ایجاد سریع</h2> <h2 id="qc-title">ایجاد سریع</h2>
<div class="items"> <div class="items">
<button <button
v-for="(item, index) in items" v-for="item in items"
:key="item.key" :key="item.key"
class="item" class="item"
type="button" type="button"
:style="{ '--qc-i': index }"
@click="go(item.to)" @click="go(item.to)"
> >
<span class="item-ico" :class="item.tone"> <span class="item-ico" :class="item.tone">
@@ -316,7 +304,6 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
</section> </section>
</Transition>
<button <button
v-if="ready" v-if="ready"
@@ -332,8 +319,6 @@ onBeforeUnmount(() => {
@pointerup="onFabPointerUp" @pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp" @pointercancel="onFabPointerUp"
> >
<span class="fab-pulse" aria-hidden="true" />
<span class="fab-shine" aria-hidden="true" />
<span class="fab-face" aria-hidden="true"> <span class="fab-face" aria-hidden="true">
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" /> <UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
</span> </span>
@@ -353,12 +338,11 @@ onBeforeUnmount(() => {
z-index: 90; z-index: 90;
border: 0; border: 0;
padding: 0; padding: 0;
background: rgba(15, 23, 42, 0.42); background: rgba(0, 0, 0, 0.68);
cursor: pointer; cursor: pointer;
} }
.panel { .panel {
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 92; z-index: 92;
width: 300px; width: 300px;
@@ -368,90 +352,10 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
background: #fff; background: #fff;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 14px; border-radius: var(--radius);
padding: 12px 12px 10px; padding: 12px 12px 10px;
box-shadow: box-shadow: var(--shadow-lg);
0 18px 40px -18px rgba(15, 23, 42, 0.35),
0 8px 18px -10px rgba(13, 148, 136, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
overflow: hidden; overflow: hidden;
will-change: transform, opacity;
}
.qc-overlay-enter-active,
.qc-overlay-leave-active {
transition: opacity 0.16s ease;
}
.qc-overlay-enter-from,
.qc-overlay-leave-to {
opacity: 0;
}
.qc-panel-enter-active {
transition:
opacity 0.18s var(--qc-ease),
transform 0.2s var(--qc-ease);
}
.qc-panel-leave-active {
transition:
opacity 0.14s ease,
transform 0.16s ease;
}
.qc-panel-enter-from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
.panel[data-place='above'].qc-panel-enter-from {
transform: translateY(-8px) scale(0.97);
}
.qc-panel-leave-to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
.panel[data-place='above'].qc-panel-leave-to {
transform: translateY(-4px) scale(0.98);
}
.qc-panel-enter-active .items {
overflow: hidden;
}
.qc-panel-enter-active .item {
animation: qc-item-in 0.2s var(--qc-ease) both;
animation-delay: calc(0.02s + var(--qc-i, 0) * 0.02s);
}
.qc-panel-enter-active h2 {
animation: qc-item-in 0.16s var(--qc-ease) both;
}
@keyframes qc-item-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.qc-overlay-enter-active,
.qc-overlay-leave-active,
.qc-panel-enter-active,
.qc-panel-leave-active,
.qc-panel-enter-active .item,
.qc-panel-enter-active h2 {
transition: none !important;
animation: none !important;
}
} }
.panel h2 { .panel h2 {
@@ -465,20 +369,12 @@ onBeforeUnmount(() => {
.items { .items {
min-height: 0; min-height: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
padding-left: 2px; padding-left: 2px;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-width: none;
}
.items::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.item { .item {
@@ -554,7 +450,6 @@ onBeforeUnmount(() => {
} }
.fab { .fab {
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: fixed; position: fixed;
z-index: 93; z-index: 93;
box-sizing: border-box; box-sizing: border-box;
@@ -564,226 +459,57 @@ onBeforeUnmount(() => {
margin: 0; margin: 0;
border: 0; border: 0;
border-radius: var(--radius); border-radius: var(--radius);
overflow: visible; overflow: hidden;
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%); background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
background-size: 140% 140%;
color: #fff; color: #fff;
display: grid; display: grid;
place-items: center; place-items: center;
line-height: 0; line-height: 0;
direction: ltr; direction: ltr;
isolation: isolate;
box-shadow: box-shadow:
0 4px 14px rgba(13, 148, 136, 0.35), 0 4px 14px rgba(13, 148, 136, 0.35),
0 0 0 0 rgba(45, 212, 191, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.22);
inset 0 1px 0 rgba(255, 255, 255, 0.28);
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
transition: transition:
filter 0.35s var(--fab-ease), filter 0.15s ease,
box-shadow 0.35s var(--fab-ease), box-shadow 0.15s ease,
transform 0.35s var(--fab-ease), transform 0.12s ease;
background 0.4s var(--fab-ease);
animation:
fab-glow 3.4s ease-in-out infinite,
fab-gradient 7s ease-in-out infinite;
}
.fab-pulse,
.fab-shine,
.fab-face {
grid-area: 1 / 1;
}
.fab-pulse {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.42);
animation: fab-ring 2.8s var(--fab-ease) infinite;
}
.fab-shine {
position: absolute;
inset: 0;
border-radius: inherit;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
.fab-shine::after {
content: '';
position: absolute;
top: -20%;
bottom: -20%;
left: -40%;
width: 42%;
background: linear-gradient(
105deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 35%,
rgba(255, 255, 255, 0.42) 50%,
rgba(255, 255, 255, 0.08) 65%,
transparent 100%
);
transform: translateX(-160%) skewX(-18deg);
animation: fab-sheen 4.8s var(--fab-ease) infinite;
} }
.fab-face { .fab-face {
position: relative;
z-index: 2;
display: grid; display: grid;
place-items: center; place-items: center;
place-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
margin: 0;
padding: 0;
pointer-events: none; pointer-events: none;
transition: transform 0.45s var(--fab-ease);
animation: fab-breathe 3.4s ease-in-out infinite;
} }
.fab :deep(.ico), .fab :deep(.ico) {
.fab :deep(svg) {
display: block; display: block;
width: 20px; width: 20px;
height: 20px; height: 20px;
max-width: 20px; max-width: 20px;
max-height: 20px; max-height: 20px;
margin: 0;
stroke-width: 1.7; stroke-width: 1.7;
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.18));
transition: transform 0.45s var(--fab-ease), opacity 0.25s ease;
} }
.fab:hover { .fab:hover {
filter: brightness(1.07) saturate(1.05); filter: brightness(1.06);
transform: translateY(-1px) scale(1.04);
box-shadow: box-shadow:
0 10px 24px rgba(13, 148, 136, 0.42), 0 6px 18px rgba(13, 148, 136, 0.42),
0 0 0 5px rgba(45, 212, 191, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.25);
inset 0 1px 0 rgba(255, 255, 255, 0.32);
}
.fab:hover .fab-face {
animation-play-state: paused;
transform: scale(1.06);
} }
.fab.open { .fab.open {
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%); background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
box-shadow: box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
0 8px 22px rgba(15, 23, 42, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
animation: none;
}
.fab.open .fab-pulse,
.fab.open .fab-shine::after {
animation: none;
opacity: 0;
}
.fab.open .fab-face {
animation: none;
transform: rotate(90deg) scale(1.02);
} }
.fab.dragging { .fab.dragging {
cursor: grabbing; cursor: grabbing;
transform: scale(1.08);
box-shadow:
0 12px 28px rgba(13, 148, 136, 0.48),
0 0 0 6px rgba(45, 212, 191, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
animation: none;
transition-duration: 0.12s;
}
.fab.dragging .fab-pulse,
.fab.dragging .fab-shine::after,
.fab.dragging .fab-face {
animation: none;
}
@keyframes fab-glow {
0%,
100% {
box-shadow:
0 4px 14px rgba(13, 148, 136, 0.32),
0 0 0 0 rgba(45, 212, 191, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
50% {
box-shadow:
0 8px 22px rgba(13, 148, 136, 0.48),
0 0 18px 2px rgba(45, 212, 191, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.34);
}
}
@keyframes fab-gradient {
0%,
100% {
background-position: 0% 40%;
}
50% {
background-position: 100% 60%;
}
}
@keyframes fab-ring {
0% {
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.4);
opacity: 1;
}
70% {
box-shadow: 0 0 0 12px rgba(45, 212, 191, 0);
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes fab-sheen {
0%,
55% {
transform: translateX(-160%) skewX(-18deg);
}
85%,
100% {
transform: translateX(320%) skewX(-18deg);
}
}
@keyframes fab-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05); transform: scale(1.05);
} box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
}
@media (prefers-reduced-motion: reduce) {
.fab,
.fab-pulse,
.fab-shine::after,
.fab-face {
animation: none !important;
}
.fab:hover,
.fab.open .fab-face {
transform: none;
}
} }
</style> </style>
-144
View File
@@ -1,144 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { toFaDigits } from '@/lib/jalali';
import type { OrgRoleDto, RolePermissionKey } from '@mento/shared';
import {
Avatar as AAvatar,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Empty as AEmpty,
Flex as AFlex,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Space as ASpace,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { UserOutlined } from '@/icons/ant-bridge';
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
const props = defineProps<{
role: OrgRoleDto;
childRoles: OrgRoleDto[];
permissionItems: Array<{ key: RolePermissionKey; label: string }>;
avatarOf: (id: string) => string;
tagColor: (color: string | null | undefined) => string;
}>();
const emit = defineEmits<{
'pick-role': [id: string];
}>();
const innerTab = ref('summary');
watch(
() => props.role.id,
() => {
innerTab.value = 'summary';
},
);
const grantedPermissions = computed(() =>
props.permissionItems.filter((item) => props.role.permissions.includes(item.key)),
);
const permSummary = computed(
() =>
`${toFaDigits(grantedPermissions.value.length)} از ${toFaDigits(props.permissionItems.length)}`,
);
</script>
<template>
<a-flex vertical :gap="16">
<a-space direction="vertical" :size="4">
<a-space wrap>
<a-tag v-if="role.systemKey" :bordered="false">سیستمی</a-tag>
<a-typography-text v-if="role.parentName" type="secondary">
زیرمجموعهٔ
<a-typography-link @click="emit('pick-role', role.parentId!)">
{{ role.parentName }}
</a-typography-link>
</a-typography-text>
</a-space>
<a-typography-text v-if="role.description" type="secondary">
{{ role.description }}
</a-typography-text>
</a-space>
<a-tabs v-model:activeKey="innerTab">
<a-tab-pane key="summary" tab="خلاصه">
<a-flex vertical :gap="16">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="کاربران">
{{ toFaDigits(role.userCount) }} نفر
</a-descriptions-item>
<a-descriptions-item label="مجوزها">
{{ permSummary }}
</a-descriptions-item>
<a-descriptions-item label="زیرنقش‌ها">
{{ toFaDigits(childRoles.length) }} مورد
</a-descriptions-item>
</a-descriptions>
<a-flex vertical :gap="8">
<a-typography-text strong>زیرنقش‌ها</a-typography-text>
<a-space v-if="childRoles.length" wrap>
<a-tag
v-for="child in childRoles"
:key="child.id"
:color="tagColor(child.color)"
:bordered="false"
style="cursor: pointer"
@click="emit('pick-role', child.id)"
>
{{ child.name }} · {{ toFaDigits(child.userCount) }}
</a-tag>
</a-space>
<a-empty v-else description="زیرنقشی تعریف نشده" :image="emptySimple" />
</a-flex>
</a-flex>
</a-tab-pane>
<a-tab-pane key="perms" :tab="`مجوزها (${permSummary})`">
<a-flex vertical :gap="12">
<a-typography-text type="secondary">
فقط مجوزهای فعال نمایش داده می‌شود. برای تغییر، ویرایش نقش را بزنید.
</a-typography-text>
<a-space v-if="grantedPermissions.length" wrap>
<a-tag
v-for="perm in grantedPermissions"
:key="perm.key"
color="success"
:bordered="false"
>
{{ perm.label }}
</a-tag>
</a-space>
<a-empty v-else description="هیچ مجوزی فعال نیست" :image="emptySimple" />
</a-flex>
</a-tab-pane>
<a-tab-pane key="members" :tab="`کاربران (${toFaDigits(role.userCount)})`">
<a-list v-if="role.members.length" :data-source="role.members">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="item.fullName">
<template #avatar>
<a-avatar :src="avatarOf(item.id)">
<template #icon><UserOutlined /></template>
</a-avatar>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="کاربری با این نقش نیست" :image="emptySimple" />
</a-tab-pane>
</a-tabs>
</a-flex>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge'; import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
import { import {
Button as AButton, Button as AButton,
Checkbox as ACheckbox, Checkbox as ACheckbox,
+12 -2
View File
@@ -122,17 +122,27 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; return fallback;
} }
async function send() { async function send(force = false) {
if (!props.endpoint || busy.value || done.value) return; if (!props.endpoint || busy.value || done.value) return;
busy.value = true; busy.value = true;
error.value = ''; error.value = '';
try { try {
await api.post(props.endpoint, { text: text.value }); await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
done.value = 'پیامک ارسال شد.'; done.value = 'پیامک ارسال شد.';
emit('sent'); emit('sent');
void loadHistory(); void loadHistory();
} catch (e) { } catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 409) {
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
if (confirm(msg)) {
busy.value = false;
await send(true);
return;
}
error.value = msg;
} else {
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.'); error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
}
} finally { } finally {
busy.value = false; busy.value = false;
} }
+39 -26
View File
@@ -118,17 +118,12 @@ defineExpose({ refresh: load });
<div v-if="visible" class="sms-wallet"> <div v-if="visible" class="sms-wallet">
<div <div
class="chip" class="chip"
:class="[toneClass, (summary?.balanceRials ?? 0) < 0 ? 'is-debt' : '']" :class="toneClass"
role="button"
tabindex="0"
:title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`" :title="loading ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`"
aria-label="شارژ اعتبار پیامک"
@click="chargeOpen = true"
@keydown.enter.prevent="chargeOpen = true"
@keydown.space.prevent="chargeOpen = true"
> >
<span class="label">پیامک</span> <span class="label">پیامک</span>
<span class="amount">{{ balanceLabel }}</span> <span class="amount">{{ balanceLabel }}</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div> </div>
<Teleport to="body"> <Teleport to="body">
@@ -205,15 +200,15 @@ defineExpose({ refresh: load });
flex-shrink: 0; flex-shrink: 0;
} }
/* چیپ جمع‌وجور؛ کلیک کل سطح، پنجرهٔ شارژ را باز می‌کند */ /* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */
.chip { .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
height: var(--control-h); height: 31px;
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0 8px; padding: 0 4px 0 2px;
border: 1px solid #c3ead6; border: 1px solid #c3ead6;
border-radius: 10px; border-radius: 10px;
background: #f2fbf6; background: #f2fbf6;
@@ -222,7 +217,6 @@ defineExpose({ refresh: load });
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
cursor: pointer;
} }
.chip.warning { .chip.warning {
@@ -238,17 +232,6 @@ defineExpose({ refresh: load });
color: #991b1b; color: #991b1b;
} }
.chip.is-debt {
border-color: var(--danger);
background: var(--danger);
color: #fff;
}
.chip.is-debt .label {
color: inherit;
opacity: 0.92;
}
.label { .label {
font-weight: 600; font-weight: 600;
font-size: 0.7rem; font-size: 0.7rem;
@@ -256,11 +239,43 @@ defineExpose({ refresh: load });
} }
.amount { .amount {
font-weight: 700; font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.charge {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
height: 23px;
margin: 0;
padding: 0 6px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.charge:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.charge:active {
background: #f1f5f9;
}
.layer { .layer {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -270,7 +285,6 @@ defineExpose({ refresh: load });
justify-content: center; justify-content: center;
padding: 1rem; padding: 1rem;
overflow: auto; overflow: auto;
box-sizing: border-box;
} }
.backdrop { .backdrop {
@@ -291,7 +305,6 @@ defineExpose({ refresh: load });
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18); box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
box-sizing: border-box;
} }
.modal header { .modal header {
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
import { LeftOutlined } from '@ant-design/icons-vue';
export type StagePathItem = {
id: string;
name: string;
};
const props = withDefaults(
defineProps<{
stages: StagePathItem[];
value?: string | null;
loading?: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(),
{
value: null,
loading: false,
disabled: false,
ariaLabel: 'مراحل',
},
);
const emit = defineEmits<{
'update:value': [id: string];
select: [id: string];
}>();
const { token } = theme.useToken();
const currentIndex = computed(() =>
props.stages.findIndex((stage) => stage.id === props.value),
);
const stripStyle = computed(() => ({
width: '100%',
overflowX: 'auto' as const,
paddingBottom: '2px',
}));
const sepStyle = computed(() => ({
color: token.value.colorTextQuaternary,
fontSize: '10px',
flexShrink: 0,
}));
function onPick(id: string) {
if (props.disabled || props.loading || id === props.value) return;
emit('update:value', id);
emit('select', id);
}
</script>
<template>
<div
role="listbox"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
:style="stripStyle"
>
<a-flex align="center" :gap="6" wrap="nowrap" :style="{ width: 'max-content', minWidth: '100%' }">
<template v-for="(stage, index) in stages" :key="stage.id">
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
<a-button
role="option"
:aria-selected="stage.id === value"
:type="stage.id === value ? 'primary' : 'default'"
:disabled="disabled || loading"
:style="{
borderRadius: '999px',
flexShrink: 0,
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
opacity: index < currentIndex ? 0.85 : 1,
}"
@click="onPick(stage.id)"
>
{{ stage.name }}
</a-button>
</template>
</a-flex>
</div>
</template>
+12 -3
View File
@@ -7,7 +7,7 @@ import {
Popover as APopover, Popover as APopover,
Typography, Typography,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { SettingOutlined } from '@/icons/ant-bridge'; import { SettingOutlined } from '@ant-design/icons-vue';
import { useTableColumns } from '@/lib/tableColumns'; import { useTableColumns } from '@/lib/tableColumns';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
@@ -18,8 +18,10 @@ const props = withDefaults(
tableKey: string; tableKey: string;
label?: string; label?: string;
align?: 'start' | 'end'; align?: 'start' | 'end';
/** فقط آیکون تنظیمات (بدون متن «ستون‌ها») */
iconOnly?: boolean;
}>(), }>(),
{ label: 'ستون‌ها', align: 'start' }, { label: 'ستون‌ها', align: 'start', iconOnly: false },
); );
const open = ref(false); const open = ref(false);
@@ -60,12 +62,19 @@ const placement = computed(() =>
</a-button> </a-button>
</a-flex> </a-flex>
</template> </template>
<a-button :type="hiddenCount > 0 ? 'primary' : 'default'" :ghost="hiddenCount > 0"> <a-button
:type="hiddenCount > 0 ? 'primary' : 'default'"
:ghost="hiddenCount > 0"
:title="iconOnly ? (hiddenCount ? `${label} · ${toFaDigits(hiddenCount)} مخفی` : label) : undefined"
:aria-label="label"
>
<template #icon><SettingOutlined /></template> <template #icon><SettingOutlined /></template>
<template v-if="!iconOnly">
{{ label }} {{ label }}
<template v-if="hiddenCount"> <template v-if="hiddenCount">
· {{ toFaDigits(hiddenCount) }} مخفی · {{ toFaDigits(hiddenCount) }} مخفی
</template> </template>
</template>
</a-button> </a-button>
</a-popover> </a-popover>
</template> </template>
+423 -256
View File
@@ -19,36 +19,10 @@ import {
emptySectionDataAccess, emptySectionDataAccess,
sectionHasDataAccessLimits, sectionHasDataAccessLimits,
} from '@mento/shared'; } from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue'; import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali'; import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { import { matchesSearch } from '@/lib/search';
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Collapse as ACollapse,
CollapsePanel as ACollapsePanel,
Divider as ADivider,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
ListItemMeta as AListItemMeta,
Radio as ARadio,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
const { Text: ATypographyText } = Typography;
const props = defineProps<{ const props = defineProps<{
users: OrgUserDto[]; users: OrgUserDto[];
@@ -64,7 +38,7 @@ const showPriorArchive = computed(
const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false)); const priorUsers = computed(() => props.users.filter((u) => u.isActive !== false));
const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled)); const selectedPriorUsers = computed(() => priorUsers.value.filter((u) => u.priorArchiveEnabled));
const priorSearch = ref(''); const priorSearch = ref('');
const priorPick = ref<string | undefined>(undefined); const priorPick = ref('');
const priorSavingId = ref(''); const priorSavingId = ref('');
const cutoffLocal = ref(''); const cutoffLocal = ref('');
const cutoffSaving = ref(false); const cutoffSaving = ref(false);
@@ -75,16 +49,10 @@ const availablePriorUsers = computed(() => {
return priorUsers.value.filter((u) => { return priorUsers.value.filter((u) => {
if (u.priorArchiveEnabled) return false; if (u.priorArchiveEnabled) return false;
if (!q) return true; if (!q) return true;
return [u.fullName, u.email, u.mobile] return [u.fullName, u.email, u.mobile].filter(Boolean).some((v) => String(v).toLowerCase().includes(q));
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q));
}); });
}); });
const availablePriorOptions = computed(() =>
availablePriorUsers.value.map((u) => ({ value: u.id, label: u.fullName })),
);
const emit = defineEmits<{ const emit = defineEmits<{
refreshUsers: []; refreshUsers: [];
}>(); }>();
@@ -107,14 +75,32 @@ const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'), props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
); );
const memberRoleOptions = [ const teamsQuery = ref('');
{ value: 'member', label: 'عضو' }, const accessQuery = ref('');
{ value: 'manager', label: 'سرپرست' },
]; const filteredTeams = computed(() => {
const q = teamsQuery.value.trim();
if (!q) return teams.value;
return teams.value.filter((team) =>
matchesSearch(q, [
team.name,
team.description,
...team.members.map((m) => m.fullName),
]),
);
});
const filteredAccessUsers = computed(() => {
const q = accessQuery.value.trim();
if (!q) return editableUsers.value;
return editableUsers.value.filter((u) =>
matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName, accessSummary(u)]),
);
});
async function setPriorArchive(userId: string, enabled: boolean) { async function setPriorArchive(userId: string, enabled: boolean) {
priorSavingId.value = userId; priorSavingId.value = userId;
priorPick.value = undefined; priorPick.value = '';
error.value = ''; error.value = '';
ok.value = ''; ok.value = '';
try { try {
@@ -152,10 +138,7 @@ async function loadPriorArchiveCutoff() {
applyCutoffFromOrg(data); applyCutoffFromOrg(data);
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String( ? String((e.response?.data as { message?: string })?.message ?? 'بارگذاری تاریخ بایگانی ناموفق بود.')
(e.response?.data as { message?: string })?.message ??
'بارگذاری تاریخ بایگانی ناموفق بود.',
)
: 'بارگذاری تاریخ بایگانی ناموفق بود.'; : 'بارگذاری تاریخ بایگانی ناموفق بود.';
} finally { } finally {
cutoffLoading.value = false; cutoffLoading.value = false;
@@ -177,10 +160,7 @@ async function onCutoffChange(value: string) {
await auth.hydrate(); await auth.hydrate();
} catch (e) { } catch (e) {
error.value = axios.isAxiosError(e) error.value = axios.isAxiosError(e)
? String( ? String((e.response?.data as { message?: string })?.message ?? 'ذخیره تاریخ بایگانی ناموفق بود.')
(e.response?.data as { message?: string })?.message ??
'ذخیره تاریخ بایگانی ناموفق بود.',
)
: 'ذخیره تاریخ بایگانی ناموفق بود.'; : 'ذخیره تاریخ بایگانی ناموفق بود.';
await loadPriorArchiveCutoff(); await loadPriorArchiveCutoff();
} finally { } finally {
@@ -188,10 +168,9 @@ async function onCutoffChange(value: string) {
} }
} }
function pickPriorUser(userId: unknown) { function pickPriorUser(userId: string) {
const id = userId == null ? '' : String(userId); if (!userId) return;
if (!id) return; void setPriorArchive(userId, true);
void setPriorArchive(id, true);
} }
async function load() { async function load() {
@@ -288,19 +267,9 @@ function toggleMember(userId: string) {
} }
} }
function setRole(userId: string, role: unknown) { function setRole(userId: string, role: TeamMemberRole) {
if (!memberDraft.value[userId]) return; if (!memberDraft.value[userId]) return;
memberDraft.value = { memberDraft.value = { ...memberDraft.value, [userId]: role };
...memberDraft.value,
[userId]: String(role) as TeamMemberRole,
};
}
function teamMembersLabel(team: TeamDto) {
if (!team.members.length) return 'بدون عضو';
return team.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ');
} }
async function saveMembers() { async function saveMembers() {
@@ -420,129 +389,109 @@ onMounted(() => {
</script> </script>
<template> <template>
<a-flex vertical :gap="12"> <div class="teams-panel">
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" /> <p v-if="error" class="error">{{ error }}</p>
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" /> <p v-if="ok" class="ok">{{ ok }}</p>
<a-card :bordered="false"> <section v-if="canEdit" class="card">
<a-form v-if="canEdit" layout="vertical" @finish="createTeam"> <h3>تیم جدید</h3>
<a-divider orientation="right">تیم جدید</a-divider> <div class="row">
<a-row :gutter="[16, 0]" align="bottom"> <UiField label="نام تیم">
<a-col :xs="24" :md="8"> <UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
<a-form-item label="نام تیم"> </UiField>
<a-input v-model:value="newName" placeholder="مثلاً فروش تهران" style="width: 100%" /> <UiField label="توضیح">
</a-form-item> <UiInput v-model="newDesc" placeholder="اختیاری" />
</a-col> </UiField>
<a-col :xs="24" :md="10"> <UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
<a-form-item label="توضیح"> </div>
<a-input v-model:value="newDesc" placeholder="اختیاری" style="width: 100%" /> </section>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" :loading="saving" block>افزودن</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-divider orientation="right">تیم‌ها</a-divider> <section class="card">
<CrmSkeleton :loading="loading"> <header class="card-head">
<CrmSkeletonCard :rows="6"> <h3>تیم‌ها</h3>
<a-empty <UiInput
v-if="!loading && !teams.length" v-if="teams.length"
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند." v-model="teamsQuery"
placeholder="جستجوی تیم یا عضو…"
/> />
<a-list v-else item-layout="vertical" :data-source="teams"> </header>
<template #renderItem="{ item: t }"> <UiEmptyState
<a-list-item> v-if="!loading && !teams.length"
<template v-if="canEdit" #actions> title="تیمی تعریف نشده"
<a-button type="link" @click="startMetaEdit(t)">ویرایش</a-button> description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
<a-button type="link" @click="startEdit(t)">اعضا</a-button> />
<a-button type="link" danger @click="removeTeam(t.id)">حذف</a-button> <p v-else-if="!filteredTeams.length" class="hint">تیمی با این جستجو پیدا نشد.</p>
</template> <div v-else class="team-list">
<a-list-item-meta> <article v-for="t in filteredTeams" :key="t.id" class="team">
<template #title> <header>
<a-space wrap> <div>
<a-typography-text strong>{{ t.name }}</a-typography-text> <strong>{{ t.name }}</strong>
<a-tag :bordered="false">{{ toFaDigits(t.memberCount) }} عضو</a-tag> <p class="muted">
</a-space> {{ t.description || '—' }}
</template> · {{ toFaDigits(t.memberCount) }} عضو
<template #description> </p>
<a-typography-text type="secondary"> </div>
{{ t.description || 'بدون توضیح' }} · {{ teamMembersLabel(t) }} <div v-if="canEdit" class="actions">
</a-typography-text> <UiButton variant="secondary" @click="startMetaEdit(t)">ویرایش</UiButton>
</template> <UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
</a-list-item-meta> <UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
</div>
</header>
<p class="members">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<a-form <div v-if="editingMetaId === t.id" class="editor">
v-if="editingMetaId === t.id" <div class="row">
layout="vertical" <UiField label="نام تیم">
style="width: 100%" <UiInput v-model="editName" />
@finish="saveMeta" </UiField>
> <UiField label="توضیح">
<a-row :gutter="[16, 0]"> <UiInput v-model="editDesc" />
<a-col :xs="24" :md="12"> </UiField>
<a-form-item label="نام تیم"> </div>
<a-input v-model:value="editName" style="width: 100%" /> <div class="actions">
</a-form-item> <UiButton :loading="saving" @click="saveMeta">ذخیره</UiButton>
</a-col> <UiButton variant="secondary" @click="editingMetaId = null">انصراف</UiButton>
<a-col :xs="24" :md="12"> </div>
<a-form-item label="توضیح"> </div>
<a-input v-model:value="editDesc" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-space>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button @click="editingMetaId = null">انصراف</a-button>
</a-space>
</a-form>
<a-flex v-if="editingId === t.id" vertical :gap="8" style="width: 100%"> <div v-if="editingId === t.id" class="editor">
<a-typography-text type="secondary"> <p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است. <label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
</a-typography-text> <input
<a-row :gutter="[12, 8]"> type="checkbox"
<a-col
v-for="u in users.filter((x) => x.isActive !== false)"
:key="u.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-space>
<a-checkbox
:checked="Boolean(memberDraft[u.id])" :checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)" @change="toggleMember(u.id)"
> />
{{ u.fullName }} <span>{{ u.fullName }}</span>
</a-checkbox> <select
<a-select
v-if="memberDraft[u.id]" v-if="memberDraft[u.id]"
:value="memberDraft[u.id]" :value="memberDraft[u.id]"
:options="memberRoleOptions" @change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
style="width: 120px" >
@update:value="setRole(u.id, $event)" <option value="member">عضو</option>
/> <option value="manager">سرپرست</option>
</a-space> </select>
</a-col> </label>
</a-row> <div class="actions">
<a-space> <UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
<a-button type="primary" :loading="saving" @click="saveMembers">ذخیره اعضا</a-button> <UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
<a-button @click="editingId = null">انصراف</a-button> </div>
</a-space> </div>
</a-flex> </article>
</a-list-item> </div>
</template> </section>
</a-list>
</CrmSkeletonCard>
</CrmSkeleton>
<a-collapse :bordered="false" ghost> <section v-if="showPriorArchive" class="card">
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل"> <header class="prior-head">
<a-row :gutter="[16, 12]" align="middle"> <h3>بایگانی اطلاعات قبل</h3>
<a-col :xs="24" :lg="8">
<JalaliDateInput <JalaliDateInput
:model-value="cutoffLocal" :model-value="cutoffLocal"
mode="date" mode="date"
@@ -550,76 +499,81 @@ onMounted(() => {
:disabled="cutoffSaving || cutoffLoading" :disabled="cutoffSaving || cutoffLoading"
@update:model-value="onCutoffChange" @update:model-value="onCutoffChange"
/> />
</a-col> </header>
<a-col :xs="24" :lg="8"> <p class="hint">
<a-input v-model:value="priorSearch" placeholder="جستجوی کاربر…" style="width: 100%" /> فقط کاربرانی که تیک می‌خورند سوئیچ کار جاری و بایگانی را می‌بینند. بقیهٔ محیط مثل قبل همهٔ
</a-col> رکوردها را مطابق سطح دسترسی خودشان می‌بینند.
<a-col :xs="24" :lg="8"> </p>
<a-select <div class="prior-picker">
:value="priorPick" <UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
:options="availablePriorOptions" <select
v-model="priorPick"
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)" :disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
allow-clear @change="pickPriorUser(($event.target as HTMLSelectElement).value)"
show-search
option-filter-prop="label"
:placeholder="
availablePriorUsers.length ? 'فعال‌سازی کاربر' : 'کاربر دیگری پیدا نشد'
"
style="width: 100%"
@update:value="pickPriorUser"
/>
</a-col>
</a-row>
<a-flex vertical :gap="8">
<a-typography-text type="secondary">
فقط این کاربران سوئیچ کار جاری / بایگانی را می‌بینند.
</a-typography-text>
<a-space v-if="selectedPriorUsers.length" wrap>
<a-tag
v-for="u in selectedPriorUsers"
:key="u.id"
closable
:disabled="priorSavingId === u.id"
@close.prevent="setPriorArchive(u.id, false)"
> >
<option value="">
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعال‌سازی' : 'کاربر دیگری پیدا نشد' }}
</option>
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
{{ u.fullName }} {{ u.fullName }}
</a-tag> </option>
</a-space> </select>
<a-typography-text v-else type="secondary"> </div>
هنوز کاربری انتخاب نشده است. <div v-if="selectedPriorUsers.length" class="prior-chips">
</a-typography-text> <span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
</a-flex> {{ u.fullName }}
</a-collapse-panel> <button
type="button"
:disabled="priorSavingId === u.id"
:aria-label="`حذف ${u.fullName}`"
@click="setPriorArchive(u.id, false)"
>
×
</button>
</span>
</div>
<p v-else class="hint">هنوز کاربری برای دیدن سوئیچ بایگانی انتخاب نشده است.</p>
</section>
<a-collapse-panel key="access" header="محدوده داده کاربران"> <section class="card">
<a-flex vertical :gap="8"> <header class="card-head">
<a-typography-text type="secondary"> <h3>محدوده داده</h3>
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر جدا بگذارید. <UiInput
</a-typography-text> v-if="editableUsers.length"
<a-empty v-model="accessQuery"
placeholder="جستجوی کاربر…"
/>
</header>
<p class="hint">
مالک و مدیر همیشه همه را می‌بینند. پیش‌فرض از نقش می‌آید؛ برای کاربر خاص می‌توانید فیلتر
جدا بگذارید.
</p>
<UiEmptyState
v-if="!editableUsers.length" v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند." description="فقط نقش‌های فروش و پشتیبانی محدوده داده جدا دارند."
/> />
<a-list v-else item-layout="vertical" :data-source="editableUsers"> <p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
<template #renderItem="{ item: u }"> <div v-else class="vis-table">
<a-list-item> <div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
<template v-if="canEdit" #actions> <div class="vis-row">
<a-button type="link" @click="openAccessEditor(u)"> <span>
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }} {{ u.fullName }}
</a-button> <small class="vis-badge">{{ accessSummary(u) }}</small>
</template> </span>
<a-list-item-meta :description="accessSummary(u)"> <UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
<template #title> {{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
<a-typography-text strong>{{ u.fullName }}</a-typography-text> </UiButton>
</template> </div>
</a-list-item-meta> <div v-if="expandedAccess === u.id" class="vis-scopes">
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%"> <label class="chip-row">
<a-radio-group v-model:value="accessMode"> <input v-model="accessMode" type="radio" value="inherit" />
<a-space wrap> مثل نقش (بدون override)
<a-radio value="inherit">مثل نقش</a-radio> </label>
<a-radio value="custom">سفارشی</a-radio> <label class="chip-row">
</a-space> <input v-model="accessMode" type="radio" value="custom" />
</a-radio-group> سفارشی برای این کاربر
</label>
<DataAccessPolicyPanel <DataAccessPolicyPanel
v-model="draftAccess" v-model="draftAccess"
v-model:treasury-access="draftTreasury" v-model:treasury-access="draftTreasury"
@@ -627,19 +581,232 @@ onMounted(() => {
:data-locked="accessMode === 'inherit'" :data-locked="accessMode === 'inherit'"
compact compact
/> />
<a-space> <div class="actions">
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)"> <UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
ذخیره <UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
</a-button> </div>
<a-button @click="expandedAccess = null">انصراف</a-button> </div>
</a-space> </div>
</a-flex> </div>
</a-list-item> </section>
</template> </div>
</a-list>
</a-flex>
</a-collapse-panel>
</a-collapse>
</a-card>
</a-flex>
</template> </template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.card-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.75rem;
}
.card-head h3 {
margin: 0;
}
.card-head :deep(.field),
.card-head :deep(input) {
min-width: min(16rem, 100%);
max-width: 18rem;
}
.prior-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.prior-head h3 {
margin: 0;
}
.prior-head :deep(.trigger) {
min-width: 12rem;
}
.prior-picker {
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(13rem, 1fr);
gap: 0.6rem;
margin-top: 0.75rem;
}
.prior-chips {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.75rem;
}
.prior-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #ccfbf1;
border-radius: 999px;
background: #f0fdfa;
color: #0f766e;
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
font-weight: 600;
}
.prior-chip button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
line-height: 1;
padding: 0;
}
.prior-chip button:disabled {
opacity: 0.45;
cursor: wait;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-user {
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.45rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
}
.vis-badge {
margin-inline-start: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.72rem;
}
.vis-scopes {
display: grid;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.chip-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
.vis-row,
.prior-picker {
grid-template-columns: 1fr;
}
.card-head {
flex-direction: column;
align-items: stretch;
}
.card-head :deep(.field),
.card-head :deep(input) {
max-width: none;
min-width: 0;
width: 100%;
}
.team header {
flex-direction: column;
align-items: stretch;
}
.team .actions {
width: 100%;
}
.vis-row {
align-items: start;
}
.vis-badge {
display: inline-block;
margin: 0.25rem 0 0;
}
}
</style>
+346 -7
View File
@@ -1,10 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* آیکون‌های legacy — حالا از پک vuesax-linear رندر می‌شوند.
* API نام‌ها برای سازگاری با صفحات قدیمی حفظ شده.
*/
import VsIcon from '@/components/VsIcon.vue';
defineProps<{ defineProps<{
name: name:
| 'home' | 'home'
@@ -88,7 +82,352 @@ defineProps<{
</script> </script>
<template> <template>
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" /> <svg
class="ico"
:width="size ?? 20"
:height="size ?? 20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="name === 'home'">
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V21h14V9.5" />
<path d="M10 21v-6h4v6" />
</template>
<template v-else-if="name === 'users'">
<circle cx="9" cy="8" r="3.2" />
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
<circle cx="17" cy="9" r="2.4" />
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
</template>
<template v-else-if="name === 'briefcase'">
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
<path d="M3 12h18" />
</template>
<template v-else-if="name === 'check'">
<circle cx="12" cy="12" r="9" />
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
</template>
<template v-else-if="name === 'box'">
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
</template>
<template v-else-if="name === 'chart'">
<path d="M4 19h16" />
<path d="M7 16V10" />
<path d="M12 16V6" />
<path d="M17 16v-4" />
</template>
<template v-else-if="name === 'settings'">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</template>
<template v-else-if="name === 'search'">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</template>
<template v-else-if="name === 'sparkle'">
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
<circle cx="12" cy="12" r="2.2" />
</template>
<template v-else-if="name === 'bell'">
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
<path d="M10 19a2 2 0 0 0 4 0" />
</template>
<template v-else-if="name === 'calendar'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
</template>
<template v-else-if="name === 'calendar-plus'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M12 13v5M9.5 15.5h5" />
</template>
<template v-else-if="name === 'calendar-sun'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<circle cx="12" cy="15.2" r="2.1" />
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
</template>
<template v-else-if="name === 'calendar-edit'">
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
</template>
<template v-else-if="name === 'mail'">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m4 7 8 6 8-6" />
</template>
<template v-else-if="name === 'megaphone'">
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
<path d="M10 15v3a2 2 0 0 1-2 2" />
</template>
<template v-else-if="name === 'send'">
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
</template>
<template v-else-if="name === 'fire'">
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
</template>
<template v-else-if="name === 'warning'">
<path d="m12 4 9 16H3L12 4Z" />
<path d="M12 10v4" />
<path d="M12 17h.01" />
</template>
<template v-else-if="name === 'trend'">
<path d="M4 17 10 11l4 4 6-8" />
<path d="M15 7h5v5" />
</template>
<template v-else-if="name === 'phone'">
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
</template>
<template v-else-if="name === 'trophy'">
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
</template>
<template v-else-if="name === 'chat'">
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
<path d="M8.5 11h7M8.5 14h4.5" />
</template>
<template v-else-if="name === 'dots'">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'dots-v'">
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'document'">
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
<path d="M14.5 4.5V9H19" />
<path d="M9 13h6M9 16.5h4" />
</template>
<template v-else-if="name === 'download'">
<path d="M12 4.5v9" />
<path d="M8.5 10.5 12 14l3.5-3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'upload'">
<path d="M12 14.5v-9" />
<path d="M8.5 8.5 12 5l3.5 3.5" />
<path d="M6 17.5h12" />
</template>
<template v-else-if="name === 'bag'">
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
</template>
<template v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4.2l2.8 1.8" />
</template>
<template v-else-if="name === 'user-plus'">
<circle cx="10" cy="8" r="3" />
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
<path d="M17 8v5M14.5 10.5h5" />
</template>
<template v-else-if="name === 'plus'">
<path d="M12 6v12M6 12h12" />
</template>
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
<path d="m9 6 6 6-6 6" />
</template>
<template v-else-if="name === 'chevron-left'">
<path d="m15 18-6-6 6-6" />
</template>
<template v-else-if="name === 'chevrons-right'">
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
</template>
<template v-else-if="name === 'chevrons-left'">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</template>
<template v-else-if="name === 'arrow-right'">
<path d="M5 12h14M12 5l7 7-7 7" />
</template>
<template v-else-if="name === 'arrow-left'">
<path d="M19 12H5M12 19l-7-7 7-7" />
</template>
<template v-else-if="name === 'star'">
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
</template>
<template v-else-if="name === 'pin'">
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
<path d="M12 20v-5" />
</template>
<template v-else-if="name === 'filter'">
<path d="M4 6h16M7 12h10M10 18h4" />
</template>
<template v-else-if="name === 'funnel'">
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
</template>
<template v-else-if="name === 'lightbulb'">
<path d="M9 18h6" />
<path d="M10 21h4" />
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
</template>
<template v-else-if="name === 'building'">
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
<path d="M4 21h16" />
</template>
<template v-else-if="name === 'user'">
<circle cx="12" cy="8" r="3.2" />
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
</template>
<template v-else-if="name === 'flag'">
<path d="M6 21V4" />
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
</template>
<template v-else-if="name === 'refresh'">
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
<path d="M20 4v5h-5" />
</template>
<template v-else-if="name === 'x'">
<path d="M6 6 18 18M18 6 6 18" />
</template>
<template v-else-if="name === 'x-circle'">
<circle cx="12" cy="12" r="9" />
<path d="m9 9 6 6M15 9l-6 6" />
</template>
<template v-else-if="name === 'mic'">
<rect x="9" y="3.5" width="6" height="10" rx="3" />
<path d="M7 11.5a5 5 0 0 0 10 0" />
<path d="M12 16.5V20M9 20h6" />
</template>
<template v-else-if="name === 'trash'">
<path d="M5 7h14" />
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
</template>
<template v-else-if="name === 'stop'">
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
</template>
<template v-else-if="name === 'grid'">
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
</template>
<template v-else-if="name === 'list'">
<path d="M8 6h12M8 12h12M8 18h12" />
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'eye'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</template>
<template v-else-if="name === 'eye-off'">
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
<path d="M4 20 20 4" />
</template>
<template v-else-if="name === 'lock'">
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</template>
<template v-else-if="name === 'truck'">
<path d="M3 7.5h11V16H3z" />
<path d="M14 10h4.2l2.3 3.2V16H14z" />
<circle cx="7" cy="17.5" r="1.6" />
<circle cx="17" cy="17.5" r="1.6" />
<path d="M3 16h1.2M8.7 16H15" />
</template>
<template v-else-if="name === 'grip'">
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'apps-plus'">
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
</template>
<template v-else-if="name === 'crown'">
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
<path d="M5.2 18.6h13.6" />
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'gift'">
<rect x="4" y="11" width="16" height="9" rx="1.4" />
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
<path d="M12 6.8v13.2" />
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
</template>
<template v-else-if="name === 'medal'">
<circle cx="12" cy="9" r="5.2" />
<circle cx="12" cy="9" r="2.3" />
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
</template>
<template v-else-if="name === 'logout'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</template>
<template v-else-if="name === 'pulse'">
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
</template>
<template v-else-if="name === 'sliders'">
<path d="M4 7h16M4 17h16" />
<path d="M8 4.5v5M16 14.5v5" />
</template>
<template v-else-if="name === 'camera'">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</template>
<template v-else-if="name === 'clip'">
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
</template>
</svg>
</template> </template>
<style scoped> <style scoped>
+204 -11
View File
@@ -1,20 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { Select as ASelect } from 'ant-design-vue'; import { Button as AButton, Select as ASelect } from 'ant-design-vue';
import { toFaDigits } from '@/lib/jalali';
type PickUser = { id: string; fullName: string }; type PickUser = { id: string; fullName: string; orgRoleName?: string | null };
type AssigneeGroup = { id: string; name: string; userIds: string[] };
type GroupKind = 'role' | 'team';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string[]; modelValue: string[];
users: PickUser[]; users: PickUser[];
/** انتخاب یک نقش، همهٔ اعضایش را مسئول می‌کند */
roles?: AssigneeGroup[];
/** انتخاب یک تیم، همهٔ اعضایش را مسئول می‌کند */
teams?: AssigneeGroup[];
disabled?: boolean; disabled?: boolean;
/** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */ /** حداقل تعداد انتخاب (حذف آخرین نفر غیرفعال می‌شود) */
min?: number; min?: number;
addLabel?: string; addLabel?: string;
emptyLabel?: string; emptyLabel?: string;
/** دکمهٔ انتخاب یکجای همهٔ اعضا و جمع‌کردن برچسب‌ها وقتی تعداد زیاد است */
selectAll?: boolean;
}>(), }>(),
{ disabled: false, min: 0, addLabel: 'افزودن', emptyLabel: 'کسی انتخاب نشده' }, {
roles: () => [],
teams: () => [],
disabled: false,
min: 0,
addLabel: 'افزودن',
emptyLabel: 'کسی انتخاب نشده',
selectAll: false,
},
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,32 +39,208 @@ const emit = defineEmits<{
change: [ids: string[]]; change: [ids: string[]];
}>(); }>();
const options = computed(() => const pinned = ref(new Set<string>());
props.users.map((u) => ({ value: u.id, label: u.fullName })),
function uniqueIds(ids: string[]) {
return [...new Set(ids.filter(Boolean))];
}
function normalizeSearch(value: string) {
return value.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/\u200c/g, ' ').trim().toLowerCase();
}
const catalog = computed(() => {
const rows: Array<{ token: string; name: string; userIds: string[]; kind: GroupKind }> = [];
const push = (kind: GroupKind, groups: AssigneeGroup[]) => {
for (const group of groups) {
const userIds = uniqueIds(group.userIds);
if (!userIds.length || !group.id || !group.name) continue;
rows.push({
token: `${kind}:${group.id}`,
name: group.name,
userIds,
kind,
});
}
};
push('role', props.roles);
push('team', props.teams);
return rows;
});
const catalogByToken = computed(() => new Map(catalog.value.map((group) => [group.token, group])));
function covers(userIds: string[], selected: string[]) {
return userIds.length > 0 && userIds.every((id) => selected.includes(id));
}
const shownGroups = computed(() =>
catalog.value.filter((group) => {
if (!covers(group.userIds, props.modelValue)) return false;
if (group.userIds.length >= 2) return true;
return pinned.value.has(group.token);
}),
); );
function onUpdate(value: unknown) { const selectValue = computed(() => {
const ids = Array.isArray(value) ? value.map(String) : []; const covered = new Set(shownGroups.value.flatMap((group) => group.userIds));
return [
...shownGroups.value.map((group) => group.token),
...props.modelValue.filter((id) => !covered.has(id)),
];
});
const options = computed(() => {
const people = props.users.map((user) => ({
value: user.id,
label:
catalog.value.length && user.orgRoleName
? `${user.fullName} — ${user.orgRoleName}`
: user.fullName,
}));
if (!catalog.value.length) return people;
const grouped: Array<{ label: string; options: Array<{ value: string; label: string }> }> = [];
const section = (kind: GroupKind, title: string) => {
const items = catalog.value
.filter((group) => group.kind === kind)
.map((group) => ({
value: group.token,
label: `${group.name} (${toFaDigits(group.userIds.length)} نفر)`,
}));
if (items.length) grouped.push({ label: title, options: items });
};
section('role', 'نقش‌ها');
section('team', 'تیم‌ها');
if (people.length) grouped.push({ label: 'اعضا', options: people });
return grouped;
});
function filterOption(input: string, option?: { label?: unknown }) {
const label = String(option?.label ?? '');
if (!label || !input.trim()) return true;
return normalizeSearch(label).includes(normalizeSearch(input));
}
const allPicked = computed(
() =>
props.users.length > 0 &&
props.users.every((user) => props.modelValue.includes(user.id)),
);
const tagLimit = computed(() => {
if (props.selectAll && allPicked.value && props.users.length > 2) return 0;
return undefined;
});
function tagPlaceholder(omitted: unknown[]) {
if (allPicked.value) return `همهٔ اعضا (${toFaDigits(props.users.length)})`;
const extra = Array.isArray(omitted) ? omitted.length : 0;
if (!extra) return '';
return catalog.value.length ? `${toFaDigits(extra)} مورد دیگر` : `${toFaDigits(extra)} نفر دیگر`;
}
watch(
() => props.modelValue.join('\0'),
() => {
const next = new Set<string>();
for (const token of pinned.value) {
const group = catalogByToken.value.get(token);
if (group && covers(group.userIds, props.modelValue)) next.add(token);
}
pinned.value = next;
},
);
function sameIds(left: string[], right: string[]) {
if (left.length !== right.length) return false;
const have = new Set(left);
return right.every((id) => have.has(id));
}
function commit(ids: string[]) {
if (ids.length < props.min && props.modelValue.length >= props.min) { if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]); emit('update:modelValue', [...props.modelValue]);
return; return;
} }
emit('update:modelValue', ids); emit('update:modelValue', ids);
emit('change', ids); if (!sameIds(ids, props.modelValue)) emit('change', ids);
}
function onUpdate(value: unknown) {
const tokens = Array.isArray(value) ? value.map(String) : [];
const tokenSet = new Set(tokens);
const removedMemberIds = new Set<string>();
for (const token of selectValue.value) {
if (tokenSet.has(token)) continue;
const dropped = catalogByToken.value.get(token);
if (!dropped) continue;
for (const id of dropped.userIds) removedMemberIds.add(id);
}
const ids = new Set<string>();
const pins = new Set<string>();
for (const token of tokens) {
const group = catalogByToken.value.get(token);
if (group) {
pins.add(token);
for (const id of group.userIds) {
if (removedMemberIds.has(id)) continue;
ids.add(id);
}
continue;
}
if (!token.startsWith('role:') && !token.startsWith('team:')) ids.add(token);
}
const next = [...ids];
if (next.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
pinned.value = pins;
commit(next);
}
function pickAll() {
if (props.disabled || allPicked.value) return;
commit(props.users.map((user) => user.id));
} }
</script> </script>
<template> <template>
<div class="user-multi" :class="{ 'with-all': selectAll }">
<a-select <a-select
mode="multiple" mode="multiple"
allow-clear allow-clear
show-search show-search
option-filter-prop="label" :value="selectValue"
:value="modelValue"
:options="options" :options="options"
:disabled="disabled" :disabled="disabled"
:placeholder="emptyLabel" :placeholder="emptyLabel"
:filter-option="filterOption"
:max-tag-count="tagLimit"
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
style="width: 100%" style="width: 100%"
@update:value="onUpdate" @update:value="onUpdate"
/> />
<a-button v-if="selectAll" :disabled="disabled || allPicked || !users.length" @click="pickAll">
همه
</a-button>
</div>
</template> </template>
<style scoped>
.user-multi.with-all {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.user-multi.with-all :deep(.ant-select) {
flex: 1 1 auto;
min-width: 0;
}
.user-multi.with-all :deep(.ant-btn) {
flex: 0 0 auto;
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'; import { computed, onBeforeUnmount, ref } from 'vue';
import { Button as AButton, Typography } from 'ant-design-vue'; import { Button as AButton, Typography } from 'ant-design-vue';
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge'; import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
const { Text: ATypographyText } = Typography; const { Text: ATypographyText } = Typography;
-76
View File
@@ -1,76 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getVuesaxSvg } from '@/icons/vuesax';
import { resolveVuesaxName } from '@/icons/name-map';
const props = withDefaults(
defineProps<{
/** کلید کوتاه vuesax (مثل add) یا نام UiIcon / Ant با map */
name: string;
size?: number | string;
/** منبع نگاشت نام؛ پیش‌فرض raw = کلید مستقیم پک */
map?: 'raw' | 'ui' | 'ant';
spin?: boolean;
title?: string;
}>(),
{
size: 20,
map: 'raw',
spin: false,
},
);
const resolved = computed(() => resolveVuesaxName(props.name, props.map));
const markup = computed(() => {
const svg = getVuesaxSvg(resolved.value);
if (!svg) return '';
// Strip fixed width/height so CSS size controls the box; keep viewBox.
return svg
.replace(/\swidth="[^"]*"/, '')
.replace(/\sheight="[^"]*"/, '')
.replace(
'<svg ',
`<svg width="${props.size}" height="${props.size}" style="width:${typeof props.size === 'number' ? props.size + 'px' : props.size};height:${typeof props.size === 'number' ? props.size + 'px' : props.size}" `,
);
});
</script>
<template>
<span
class="vs-icon"
:class="{ 'vs-icon--spin': spin }"
:style="{
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}"
:title="title"
role="img"
aria-hidden="true"
v-html="markup"
/>
</template>
<style scoped>
.vs-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
flex-shrink: 0;
color: inherit;
}
.vs-icon :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
.vs-icon--spin {
animation: vs-icon-spin 1s linear infinite;
}
@keyframes vs-icon-spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -9,11 +9,10 @@ import type {
} from '@mento/shared'; } from '@mento/shared';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { activityTypeView } from '@/lib/activity-types'; import { activityTypeView } from '@/lib/activity-types';
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali'; import { formatRelativeDayLabel, toFaDigits } from '@/lib/jalali';
import { Flex as AFlex } from 'ant-design-vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue'; import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue'; import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton'; import ActivityQuickView from '@/components/activities/ActivityQuickView.vue';
import { import {
ACTIVITY_BOARD_COPY, ACTIVITY_BOARD_COPY,
initialsFromTitle, initialsFromTitle,
@@ -43,6 +42,7 @@ const emit = defineEmits<{
stagesChanged: []; stagesChanged: [];
add: [stageId: string]; add: [stageId: string];
error: [message: string]; error: [message: string];
notice: [message: string];
menuDeal: [activity: ActivityDto, stageId?: string]; menuDeal: [activity: ActivityDto, stageId?: string];
toggleSelect: [activityId: string]; toggleSelect: [activityId: string];
bulkDelete: []; bulkDelete: [];
@@ -65,9 +65,49 @@ const draggingColumnId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null); const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineAccent>('green'); const overlayAccent = ref<PipelineAccent>('green');
const overlayPos = ref({ x: 0, y: 0 }); const overlayPos = ref({ x: 0, y: 0 });
const quickId = ref<string | null>(null);
const peekId = ref<string | null>(null);
let peekLinger = 0;
const moving = ref(false); const moving = ref(false);
const stageBusy = ref(false); const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null); const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = { const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined, open: undefined,
@@ -90,18 +130,6 @@ function apiMessage(e: unknown, fallback: string) {
return fallback; return fallback;
} }
function dateText(item: ActivityDto) {
if (!item.dueAt) return 'بدون موعد';
const due = new Date(item.dueAt);
const days = calendarDaysBetween(due, new Date());
const day =
days === 0 ? 'امروز' : days === 1 ? 'دیروز' : days === -1 ? 'فردا' : formatJalaliDay(due);
const late =
item.status === 'open' &&
startOfLocalDay(due).getTime() < startOfLocalDay().getTime();
return `${late ? 'عقب‌افتاده · ' : ''}${day} · ${formatClockFa(due)}`;
}
function toCard(item: ActivityDto, stageId: string): PipelineDeal { function toCard(item: ActivityDto, stageId: string): PipelineDeal {
const party = item.company?.name || item.person?.fullName || item.deal?.title || ''; const party = item.company?.name || item.person?.fullName || item.deal?.title || '';
const assigneeNames = item.assignees?.length const assigneeNames = item.assignees?.length
@@ -114,7 +142,13 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
title: item.title, title: item.title,
description: [activityTypeView(item).name, party].filter(Boolean).join(' · '), description: [activityTypeView(item).name, party].filter(Boolean).join(' · '),
amount: null, amount: null,
date: dateText(item), date:
item.status === 'done'
? 'انجام شده'
: item.dueAt
? formatRelativeDayLabel(item.dueAt)
: 'بدون موعد',
dateIso: item.dueAt ?? undefined,
initials: initialsFromTitle(party || item.title), initials: initialsFromTitle(party || item.title),
stageId, stageId,
createdAt: item.createdAt, createdAt: item.createdAt,
@@ -123,6 +157,7 @@ function toCard(item: ActivityDto, stageId: string): PipelineDeal {
ownerId: item.assignee?.id ?? null, ownerId: item.assignee?.id ?? null,
assigneeIds: (item.assignees ?? []).map((a) => a.id), assigneeIds: (item.assignees ?? []).map((a) => a.id),
priority: item.priority, priority: item.priority,
activityStatus: item.status,
}; };
} }
@@ -224,12 +259,12 @@ function onMenuActivity(id: string) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id; draggingColumnId.value = null;
overlayDeal.value = deal; // قفل/صف مانده از درخواست قبلی را باز کن
const sid = findStageOf(deal.id); if (moving.value) resetMoveLock();
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green'; dragSessionId = deal.id;
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 }; // setData / setDragImage باید همزمان با dragstart باشند
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
@@ -237,12 +272,42 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
} }
function onDragEnd() { function onDragEnd() {
const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => {
if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
clearDragState();
}, 0);
}
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
} }
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
@@ -257,17 +322,19 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId];
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId); const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex]; const item = from.items[fromIndex];
if (!item) return; if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!; const toStage = props.stages.find((s) => s.id === toStageId);
if (!toStage) return;
const same = fromStageId === toStageId; const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId); const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return; if (same && index == null) return;
let insertAt = index == null || index < 0 ? 0 : index; // بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1; if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length)); insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return; if (same && insertAt === fromIndex) return;
@@ -289,11 +356,27 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
moving.value = true; moving.value = true;
armMoveWatchdog();
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, stageId: toStageId,
position: insertAt, position: insertAt,
}); });
if (!same && data.status !== toStage.kind) {
const hideFromMine = props.params.desk === 'mine';
from.items = hideFromMine
? prevFrom.filter((row) => row.id !== activityId)
: prevFrom.map((row) => (row.id === activityId ? data : row));
from.total = hideFromMine ? Math.max(0, prevFromTotal - 1) : prevFromTotal;
to.items = prevTo;
to.total = prevToTotal;
emit(
'notice',
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
);
emit('changed');
return;
}
const column = same ? from : to; const column = same ? from : to;
column.items = column.items.map((i) => (i.id === activityId ? data : i)); column.items = column.items.map((i) => (i.id === activityId ? data : i));
emit('changed'); emit('changed');
@@ -306,24 +389,32 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
window.clearTimeout(moveWatchdog);
moving.value = false; moving.value = false;
} }
} }
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const raw = event.dataTransfer?.getData('text/plain') || draggingColumnId.value || draggingId.value || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
dropStageId.value = null; const cardId = dragSessionId || draggingId.value;
draggingId.value = null; const columnId = draggingColumnId.value;
overlayDeal.value = null; clearDragState();
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value; // کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
draggingColumnId.value = null; const activityId =
if (fromId) await reorderColumn(fromId, stageId); cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return; return;
} }
if (!raw || moving.value) return;
await moveCard(raw, stageId, index); const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
} }
async function persistOrder(ids: string[]) { async function persistOrder(ids: string[]) {
@@ -406,7 +497,7 @@ async function onAddStage(name: string) {
} }
} }
function openActivity(id: string) { function activityQuery(id: string) {
const query: Record<string, string> = { const query: Record<string, string> = {
from: 'desk', from: 'desk',
nav: crypto.randomUUID(), nav: crypto.randomUUID(),
@@ -426,36 +517,37 @@ function openActivity(id: string) {
if (source.due) query.due = source.due; if (source.due) query.due = source.due;
const columnId = findStageOf(id); const columnId = findStageOf(id);
if (columnId) query.stage = columnId; if (columnId) query.stage = columnId;
void router.push({ name: 'activity-detail', params: { id }, query }); return query;
}
function openActivity(id: string) {
if (quickId.value === id) {
closeQuick();
return;
}
window.clearTimeout(peekLinger);
quickId.value = id;
peekId.value = id;
}
function closeQuick() {
quickId.value = null;
const id = peekId.value;
window.clearTimeout(peekLinger);
peekLinger = window.setTimeout(() => {
if (!quickId.value && peekId.value === id) peekId.value = null;
}, 650);
}
function openFullActivity(id: string) {
quickId.value = null;
void router.push({ name: 'activity-detail', params: { id }, query: activityQuery(id) });
} }
</script> </script>
<template> <template>
<div <div class="kanban-wrap">
class="kanban-wrap" <p v-if="initialLoading && !Object.keys(columns).length" class="muted">در حال بارگذاری ستون‌ها…</p>
:style="{
flex: '1 1 auto',
minHeight: 0,
height: '100%',
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}"
>
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
<a-flex
v-if="initialLoading && !Object.keys(columns).length"
:gap="12"
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
>
<div
v-for="i in 4"
:key="`sk-col-${i}`"
:style="{ width: '280px', flex: '0 0 280px' }"
>
<CrmSkeletonCard :rows="6" :min-height="360" />
</div>
</a-flex>
<PipelineBoard <PipelineBoard
v-else v-else
:stages="boardStages" :stages="boardStages"
@@ -465,24 +557,32 @@ function openActivity(id: string) {
:copy="ACTIVITY_BOARD_COPY" :copy="ACTIVITY_BOARD_COPY"
:select-mode="selectMode" :select-mode="selectMode"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:peek-id="peekId"
:preview-id="quickId"
@drag-start="onDragStart" @drag-start="onDragStart"
@drag-end="onDragEnd" @drag-end="onDragEnd"
@drag-over="onDragOver" @drag-over="onDragOver"
@drop="onDrop" @drop="onDrop"
@add-deal="emit('add', $event)" @add-deal="emit('add', $event)"
@open-deal="openActivity" @open-deal="openActivity"
@detail-deal="openFullActivity"
@menu-deal="onMenuActivity" @menu-deal="onMenuActivity"
@toggle-select="emit('toggleSelect', $event)" @toggle-select="emit('toggleSelect', $event)"
@bulk-delete="emit('bulkDelete')" @bulk-delete="emit('bulkDelete')"
@rename-stage="onRenameStage" @rename-stage="onRenameStage"
@delete-stage="onDeleteStage" @delete-stage="onDeleteStage"
@move-stage="onMoveStage" @move-stage="onMoveStage"
@column-drag-start="(id: string) => (draggingColumnId = id)" @column-drag-start="onColumnDragStart"
@column-drag-end="draggingColumnId = null; dropStageId = null" @column-drag-end="onColumnDragEnd"
@add-stage="onAddStage" @add-stage="onAddStage"
@load-more="loadMore" @load-more="loadMore"
/> />
</CrmSkeleton> <ActivityQuickView
:activity-id="quickId"
@close="closeQuick"
@changed="loadAll()"
@full="openFullActivity"
/>
<Teleport to="body"> <Teleport to="body">
<DealCardOverlay <DealCardOverlay
v-if="overlayDeal && draggingId" v-if="overlayDeal && draggingId"
@@ -506,4 +606,9 @@ function openActivity(id: string) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.muted {
margin: 0;
color: #64748b;
}
</style> </style>
@@ -17,6 +17,8 @@ const props = defineProps<{
activity: ActivityDto; activity: ActivityDto;
saving?: boolean; saving?: boolean;
save: (payload: { text: string; photoOnly: boolean }) => Promise<void>; save: (payload: { text: string; photoOnly: boolean }) => Promise<void>;
/** بالاتر از مودال خلاصهٔ فعالیت */
overlayZIndex?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -355,7 +357,7 @@ onBeforeUnmount(() => {
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div class="layer" role="presentation"> <div class="layer" role="presentation" :style="overlayZIndex ? { zIndex: overlayZIndex } : undefined">
<div class="backdrop" aria-hidden="true" /> <div class="backdrop" aria-hidden="true" />
<section <section
class="sheet" class="sheet"

Some files were not shown because too many files have changed in this diff Show More