Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1ac677b3a | ||
|
|
b00465439e | ||
|
|
9af0e9e94e | ||
|
|
b745b6d460 | ||
|
|
fec5ece255 | ||
|
|
f4f2311ae4 | ||
|
|
57e0fdd600 | ||
|
|
b583858088 | ||
|
|
453ff1a5f6 | ||
|
|
68fcece743 | ||
|
|
de77066cb0 | ||
|
|
d23dd20237 | ||
|
|
8a4bd70ad4 | ||
|
|
d87321c7bf | ||
|
|
1a05eee46d | ||
|
|
ec9a7cf9c9 | ||
|
|
4f08ffae36 | ||
|
|
d7cb92bf81 | ||
|
|
871d7bba1a | ||
|
|
f13a1249af | ||
|
|
fdf1fbe4aa | ||
|
|
6df198321a | ||
|
|
4c8604d346 | ||
|
|
25a24772fc | ||
|
|
932dc3f1c1 | ||
|
|
5b26b9156d | ||
|
|
d12d7b91cb | ||
|
|
49459707cb | ||
|
|
ba7f942059 | ||
|
|
96105468d4 | ||
|
|
7a8007fff0 | ||
|
|
da851bd9e5 | ||
|
|
dae3ae3f7e | ||
|
|
b03d97dd9e | ||
|
|
8b8d01976d | ||
|
|
4764562bd4 | ||
|
|
0e1bc4a1fe | ||
|
|
3c9b906bd6 | ||
|
|
ef58922e13 | ||
|
|
06036b00d3 | ||
|
|
d33d88e76d | ||
|
|
5ef693990c | ||
|
|
6ebd45f2cb | ||
|
|
33f33f9521 | ||
|
|
97f00d7e6d | ||
|
|
b8c49293f7 | ||
|
|
f36711174e | ||
|
|
abb1a3a18e | ||
|
|
21d2c69227 | ||
|
|
0ca9540524 | ||
|
|
dc691c5192 |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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 |
@@ -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 |
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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 $$;
|
||||||
@@ -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
|
||||||
@@ -295,6 +306,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 +577,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 +897,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 +2467,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 +2976,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 +3005,7 @@ model VoipConnection {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([organizationId, enabled])
|
@@index([organizationId, enabled])
|
||||||
|
@@index([organizationId, provider])
|
||||||
}
|
}
|
||||||
|
|
||||||
model VoipCallLog {
|
model VoipCallLog {
|
||||||
@@ -2986,6 +3020,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 +3046,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])
|
||||||
|
|||||||
@@ -438,8 +438,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')
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dto.status !== undefined) {
|
const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all';
|
||||||
data.status = dto.status;
|
const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any');
|
||||||
data.completedAt =
|
if (modeChanged) data.completionMode = nextMode;
|
||||||
dto.status === 'done' ? new Date() : dto.status === 'open' ? null : undefined;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'done', completedAt: new Date() },
|
||||||
|
include,
|
||||||
|
});
|
||||||
|
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({
|
const row = await this.prisma.activity.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'done', completedAt: new Date() },
|
data: {
|
||||||
|
completedByIds: reconciled.completedByIds,
|
||||||
|
status: reconciled.status,
|
||||||
|
completedAt: reconciled.status === 'done' ? new Date() : null,
|
||||||
|
},
|
||||||
include,
|
include,
|
||||||
});
|
});
|
||||||
if (existing?.status !== 'done') {
|
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 ?? [],
|
||||||
|
|||||||
@@ -384,6 +384,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 +492,12 @@ 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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+138
-109
@@ -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,32 +285,33 @@ 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 } } } },
|
||||||
{ participantIds: { hasSome: ids } },
|
{ participantIds: { hasSome: 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 (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,34 +330,35 @@ 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 } },
|
||||||
{
|
{
|
||||||
deal: {
|
deal: {
|
||||||
OR: [
|
OR: [
|
||||||
{ ownerId: { in: ids } },
|
{ ownerId: { in: ids } },
|
||||||
{ assignees: { some: { id: { in: ids } } } },
|
{ assignees: { some: { id: { in: ids } } } },
|
||||||
],
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{ person: { ownerId: { in: ids } } },
|
||||||
{ person: { ownerId: { in: ids } } },
|
{ company: { ownerId: { in: ids } } },
|
||||||
{ company: { ownerId: { in: ids } } },
|
],
|
||||||
],
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
if (rule.sources?.length) {
|
if (rule.sources?.length) {
|
||||||
and.push({ noteType: { in: rule.sources } });
|
facets.push({ noteType: { in: rule.sources } });
|
||||||
}
|
}
|
||||||
if (!and.length) continue;
|
const w = unionFacets(facets);
|
||||||
parts.push(and.length === 1 ? and[0]! : { AND: and });
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...customersCompanyWhere(actor),
|
{
|
||||||
...(and.length ? { AND: and } : {}),
|
organizationId: actor.organizationId,
|
||||||
|
...customersCompanyWhere(actor),
|
||||||
|
},
|
||||||
|
...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: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...customersPersonWhere(actor),
|
{
|
||||||
...(moods.length ? { mood: { in: moods } } : {}),
|
organizationId: actor.organizationId,
|
||||||
...(sources.length ? { importSource: { in: sources } } : {}),
|
...customersPersonWhere(actor),
|
||||||
...(and.length ? { AND: 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
organizationId,
|
||||||
data: { birthDate },
|
dto.connectionId,
|
||||||
});
|
linkedPersonId,
|
||||||
|
moshtariCode,
|
||||||
|
row,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await this.applyTahesabGroup(
|
|
||||||
organizationId,
|
|
||||||
dto.connectionId,
|
|
||||||
linkedPersonId,
|
|
||||||
rowsByCode.get(moshtariCode),
|
|
||||||
);
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
transcript = await this.stt.downloadAndTranscribe(row.recordingUrl);
|
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);
|
||||||
|
}
|
||||||
await this.prisma.voipCallLog.update({
|
await this.prisma.voipCallLog.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { transcript },
|
data: { transcript },
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +171,77 @@ 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 {
|
||||||
organizationId: actor.organizationId,
|
AND: [
|
||||||
...leadAccessWhere(actor),
|
{
|
||||||
archivedAt: opts.archived ? { not: null } : null,
|
organizationId: actor.organizationId,
|
||||||
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
...leadAccessWhere(actor),
|
||||||
...(opts.assigneeId ? { assigneeId: opts.assigneeId } : {}),
|
archivedAt: opts.archived ? { not: null } : null,
|
||||||
...(opts.unassigned ? { assigneeId: null } : {}),
|
...(opts.converted ? { convertedPersonId: { not: null } } : {}),
|
||||||
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
...(opts.stageId ? { stageId: opts.stageId } : {}),
|
||||||
...(opts.region
|
...(opts.city ? { city: { contains: opts.city, mode: 'insensitive' } } : {}),
|
||||||
? { region: { contains: opts.region, mode: 'insensitive' } }
|
...(opts.region
|
||||||
: {}),
|
? { region: { contains: opts.region, mode: 'insensitive' } }
|
||||||
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
: {}),
|
||||||
...(opts.source
|
...(opts.trade ? { trade: { contains: opts.trade, mode: 'insensitive' } } : {}),
|
||||||
? { source: { contains: opts.source, mode: 'insensitive' } }
|
...(opts.source
|
||||||
: {}),
|
? { 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')
|
||||||
|
|||||||
@@ -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;
|
||||||
by: ['pipelineId'],
|
const lensActive = forLens && (lens === 'current' || lens === 'archive');
|
||||||
where: { organizationId },
|
const access = actor ? dealAccessWhere(actor) : {};
|
||||||
_count: { _all: true },
|
const prior = lensActive
|
||||||
});
|
? dealPriorWhere(actor?.priorLens, actor?.priorCutoff)
|
||||||
|
: {};
|
||||||
|
const [counts, lensCounts] = await Promise.all([
|
||||||
|
this.prisma.deal.groupBy({
|
||||||
|
by: ['pipelineId'],
|
||||||
|
where: lensActive ? { organizationId, AND: [access] } : { organizationId },
|
||||||
|
_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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
'مالک محیط همان کسی است که ثبتنام کرده و نمیتوان نقش او را عوض کرد',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
for (const d of wonDeals) {
|
||||||
const label = normalizeImportSource(p.importSource);
|
const label = normalizeSource(d.source);
|
||||||
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
if (sourceMap.size === 0) {
|
|
||||||
for (const d of wonDeals) {
|
|
||||||
const label = normalizeSource(d.source);
|
|
||||||
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 'بدون دلیل ثبتشده';
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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' {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 |
@@ -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,63 +73,61 @@ 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' }">
|
فیلدهای سفارشی
|
||||||
فیلدهای سفارشی
|
</a-typography-title>
|
||||||
</a-typography-title>
|
<a-row :gutter="[16, 0]">
|
||||||
<a-row :gutter="[16, 0]">
|
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
<a-form-item :label="field.label" :required="field.required">
|
||||||
<a-form-item :label="field.label" :required="field.required">
|
<a-select
|
||||||
<a-select
|
v-if="field.fieldType === 'select'"
|
||||||
v-if="field.fieldType === 'select'"
|
:id="`cf-${field.key}`"
|
||||||
:id="`cf-${field.key}`"
|
allow-clear
|
||||||
allow-clear
|
:value="String(displayValue(field.key)) || undefined"
|
||||||
:value="String(displayValue(field.key)) || undefined"
|
:options="selectOptions(field)"
|
||||||
:options="selectOptions(field)"
|
placeholder="انتخاب کنید"
|
||||||
placeholder="انتخاب کنید"
|
style="width: 100%"
|
||||||
style="width: 100%"
|
@update:value="setValue(field.key, $event ?? '')"
|
||||||
@update:value="setValue(field.key, $event ?? '')"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
v-else-if="field.fieldType === 'boolean'"
|
v-else-if="field.fieldType === 'boolean'"
|
||||||
:id="`cf-${field.key}`"
|
:id="`cf-${field.key}`"
|
||||||
:checked="Boolean(props.modelValue[field.key])"
|
:checked="Boolean(props.modelValue[field.key])"
|
||||||
@update:checked="setValue(field.key, $event)"
|
@update:checked="setValue(field.key, $event)"
|
||||||
>
|
>
|
||||||
فعال
|
فعال
|
||||||
</a-checkbox>
|
</a-checkbox>
|
||||||
|
|
||||||
<a-input-number
|
<a-input-number
|
||||||
v-else-if="field.fieldType === 'number'"
|
v-else-if="field.fieldType === 'number'"
|
||||||
:id="`cf-${field.key}`"
|
:id="`cf-${field.key}`"
|
||||||
:value="
|
:value="
|
||||||
props.modelValue[field.key] != null
|
props.modelValue[field.key] != null
|
||||||
? Number(props.modelValue[field.key])
|
? Number(props.modelValue[field.key])
|
||||||
: undefined
|
: undefined
|
||||||
"
|
"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<JalaliDateInput
|
<JalaliDateInput
|
||||||
v-else-if="field.fieldType === 'date'"
|
v-else-if="field.fieldType === 'date'"
|
||||||
:id="`cf-${field.key}`"
|
:id="`cf-${field.key}`"
|
||||||
:model-value="String(displayValue(field.key))"
|
:model-value="String(displayValue(field.key))"
|
||||||
@update:model-value="setValue(field.key, $event || null)"
|
@update:model-value="setValue(field.key, $event || null)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<a-input
|
<a-input
|
||||||
v-else
|
v-else
|
||||||
:id="`cf-${field.key}`"
|
:id="`cf-${field.key}`"
|
||||||
:value="String(displayValue(field.key))"
|
:value="String(displayValue(field.key))"
|
||||||
@update:value="setValue(field.key, $event)"
|
@update:value="setValue(field.key, $event)"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</template>
|
</template>
|
||||||
</CrmSkeleton>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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,9 +164,12 @@ watch(
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
void load(true).catch(() => {
|
listQuery.value = '';
|
||||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
void Promise.all([load(true), loadUsers()])
|
||||||
});
|
.then(() => syncAssigneeDraft())
|
||||||
|
.catch(() => {
|
||||||
|
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -107,34 +179,60 @@ 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">
|
||||||
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
<header class="create-head">
|
||||||
<div class="swatches">
|
<strong>گروه جدید</strong>
|
||||||
<button
|
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
|
||||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
</header>
|
||||||
:key="c"
|
<div class="new-row">
|
||||||
type="button"
|
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
|
||||||
class="swatch"
|
<div class="swatches">
|
||||||
:class="{ on: newColor === c }"
|
<button
|
||||||
:style="{ background: groupSwatch(c).fg }"
|
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||||
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
|
:key="c"
|
||||||
@click="newColor = c"
|
type="button"
|
||||||
|
class="swatch"
|
||||||
|
:class="{ on: newColor === c }"
|
||||||
|
:style="{ background: groupSwatch(c).fg }"
|
||||||
|
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
|
||||||
|
@click="newColor = c"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
|
||||||
|
<UiIcon name="plus" :size="15" />
|
||||||
|
افزودن
|
||||||
|
</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>
|
</div>
|
||||||
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
|
|
||||||
<UiIcon name="plus" :size="15" />
|
|
||||||
افزودن
|
|
||||||
</UiButton>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
:checked="filter.owners?.includes('self')"
|
type="checkbox"
|
||||||
:disabled="disabled"
|
:checked="filter.owners?.includes('self')"
|
||||||
@change="toggleOwner('self')"
|
@change="toggleOwner('self')"
|
||||||
>
|
/>
|
||||||
{{ ownerSelfLabel }}
|
{{ ownerSelfLabel }}
|
||||||
</a-checkbox>
|
</label>
|
||||||
<a-checkbox
|
<label class="chip">
|
||||||
:checked="filter.owners?.includes('team')"
|
<input
|
||||||
:disabled="disabled"
|
type="checkbox"
|
||||||
@change="toggleOwner('team')"
|
:checked="filter.owners?.includes('team')"
|
||||||
>
|
@change="toggleOwner('team')"
|
||||||
تیمهای عضو
|
/>
|
||||||
</a-checkbox>
|
تیمهای عضو
|
||||||
</a-space>
|
</label>
|
||||||
</a-flex>
|
</fieldset>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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">
|
||||||
v-for="s in visibleSections"
|
<button
|
||||||
:key="s"
|
v-for="s in visibleSections"
|
||||||
:tab="DATA_ACCESS_SECTION_LABELS[s]"
|
:key="s"
|
||||||
>
|
type="button"
|
||||||
<DataAccessEditor
|
class="tab"
|
||||||
:section="s"
|
:class="{ on: active === s }"
|
||||||
:model-value="sectionValue(s)"
|
role="tab"
|
||||||
:groups="customerGroups"
|
:aria-selected="active === s"
|
||||||
:stages="s === 'leads' ? leadStages : s === 'sales' ? dealStages : s === 'activities' ? activityStages : s === 'projects' ? projectStages : []"
|
@click="active = s"
|
||||||
:pipelines="pipelines"
|
>
|
||||||
:boards="s === 'activities' ? activityBoards : s === 'projects' ? projectBoards : []"
|
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||||
:activity-types="s === 'activities' ? activityTypes : s === 'routines' ? routineCategories : []"
|
</button>
|
||||||
:import-sources="importSources"
|
<button
|
||||||
:import-sources-ready="importSourcesReady"
|
v-if="showTreasury"
|
||||||
:import-sources-failed="importSourcesFailed"
|
type="button"
|
||||||
:disabled="disabled || dataLocked"
|
class="tab"
|
||||||
:compact="compact"
|
:class="{ on: active === 'treasury' }"
|
||||||
@update:model-value="setSection(s, $event)"
|
role="tab"
|
||||||
/>
|
:aria-selected="active === 'treasury'"
|
||||||
</a-tab-pane>
|
@click="active = 'treasury'"
|
||||||
<a-tab-pane v-if="showTreasury" key="treasury" tab="مدیریت مالی">
|
>
|
||||||
<a-space direction="vertical">
|
مدیریت مالی
|
||||||
<a-typography-text type="secondary">
|
</button>
|
||||||
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
</div>
|
||||||
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
<div v-if="active === 'treasury'" class="treasury">
|
||||||
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
<p class="muted">
|
||||||
</a-typography-text>
|
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
|
||||||
<a-radio-group
|
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخشها بهجز حقوق باز میمانند و حقوق جداست.
|
||||||
:value="treasuryAccess == null ? 'role' : 'custom'"
|
اگر هیچ حسابی انتخاب نشود همه حسابها باز است.
|
||||||
|
</p>
|
||||||
|
<label class="choice">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="user-treasury"
|
||||||
|
:checked="treasuryAccess == null"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="onTreasuryModeChange"
|
@change="patchTreasury(null)"
|
||||||
>
|
/>
|
||||||
<a-space direction="vertical">
|
مثل نقش
|
||||||
<a-radio value="role">مثل نقش</a-radio>
|
</label>
|
||||||
<a-radio value="custom">سفارشی برای این کاربر</a-radio>
|
<label class="choice">
|
||||||
</a-space>
|
<input
|
||||||
</a-radio-group>
|
type="radio"
|
||||||
<template v-if="treasuryAccess">
|
name="user-treasury"
|
||||||
<a-checkbox
|
:checked="treasuryAccess != null"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
|
||||||
|
/>
|
||||||
|
سفارشی برای این کاربر
|
||||||
|
</label>
|
||||||
|
<template v-if="treasuryAccess">
|
||||||
|
<label class="choice">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
:checked="treasuryAccess.enabled"
|
:checked="treasuryAccess.enabled"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
|
||||||
>
|
/>
|
||||||
دسترسی به مدیریت مالی
|
دسترسی به مدیریت مالی
|
||||||
</a-checkbox>
|
</label>
|
||||||
<template v-if="treasuryAccess.enabled">
|
<template v-if="treasuryAccess.enabled">
|
||||||
<a-space direction="vertical">
|
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
|
||||||
<a-checkbox
|
<input
|
||||||
v-for="perm in treasurySections"
|
type="checkbox"
|
||||||
:key="perm.key"
|
:checked="treasuryAccess.sections.includes(perm.key)"
|
||||||
:checked="treasuryAccess.sections.includes(perm.key)"
|
:disabled="disabled"
|
||||||
:disabled="disabled"
|
@change="toggleTreasurySection(perm.key)"
|
||||||
@change="toggleTreasurySection(perm.key)"
|
/>
|
||||||
>
|
{{ perm.label }}
|
||||||
{{ perm.label }}
|
</label>
|
||||||
</a-checkbox>
|
<label class="choice">
|
||||||
</a-space>
|
<input
|
||||||
<a-checkbox
|
type="checkbox"
|
||||||
:checked="treasuryAccess.ownAccounts"
|
:checked="treasuryAccess.ownAccounts"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
|
@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
|
</label>
|
||||||
v-for="account in filteredTreasuryAccounts"
|
<p class="muted">حسابهای دیگر</p>
|
||||||
:key="account.id"
|
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
|
||||||
:checked="treasuryAccess.accountIds.includes(account.id)"
|
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
|
||||||
:disabled="disabled"
|
<input
|
||||||
@change="toggleTreasuryAccount(account.id)"
|
type="checkbox"
|
||||||
>
|
:checked="treasuryAccess.accountIds.includes(account.id)"
|
||||||
{{ account.name }}
|
:disabled="disabled"
|
||||||
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
@change="toggleTreasuryAccount(account.id)"
|
||||||
</a-checkbox>
|
/>
|
||||||
</a-space>
|
{{ account.name }}
|
||||||
<a-typography-text v-if="!treasuryAccounts.length" type="secondary">
|
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
|
||||||
حسابی برای انتخاب نیست.
|
</label>
|
||||||
</a-typography-text>
|
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
|
||||||
</template>
|
|
||||||
</template>
|
</template>
|
||||||
</a-space>
|
</template>
|
||||||
</a-tab-pane>
|
</div>
|
||||||
</a-tabs>
|
<DataAccessEditor
|
||||||
|
v-else
|
||||||
|
:key="active"
|
||||||
|
:section="editorSection"
|
||||||
|
:model-value="sectionValue(editorSection)"
|
||||||
|
:stages="stagesForActive"
|
||||||
|
:pipelines="pipelines"
|
||||||
|
:boards="boardsForActive"
|
||||||
|
:activity-types="typesForActive"
|
||||||
|
:import-sources="importSources"
|
||||||
|
:import-sources-ready="importSourcesReady"
|
||||||
|
:import-sources-failed="importSourcesFailed"
|
||||||
|
:disabled="disabled || dataLocked"
|
||||||
|
:compact="compact"
|
||||||
|
@update:model-value="setSection(editorSection, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 || 'تحلیل ناموفق بود'"
|
تحلیل این یادداشت با AI
|
||||||
/>
|
</button>
|
||||||
<a-button type="link" @click="analyze">تلاش دوباره</a-button>
|
</p>
|
||||||
</a-flex>
|
|
||||||
<a-button
|
|
||||||
v-else-if="!suggestion"
|
|
||||||
type="link"
|
|
||||||
:disabled="loading"
|
|
||||||
@click="analyze"
|
|
||||||
>
|
|
||||||
تحلیل این یادداشت با AI
|
|
||||||
</a-button>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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 = {
|
|
||||||
...beside,
|
|
||||||
top: `${Math.round(box.top)}px`,
|
|
||||||
bottom: 'auto',
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
menuStyle.value = {
|
menuStyle.value = {
|
||||||
...beside,
|
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
|
||||||
top: 'auto',
|
right: `${Math.round(window.innerWidth - box.right)}px`,
|
||||||
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
|
minWidth: `${Math.round(Math.max(box.width, 200))}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">
|
<UiIcon name="building" :size="18" />
|
||||||
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
|
<span>{{ currentName }}</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" />
|
|
||||||
<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>
|
<strong>{{ org.name }}</strong>
|
||||||
<span class="org-item-copy">
|
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||||
<strong>{{ org.name }}</strong>
|
|
||||||
<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,
|
||||||
|
|||||||
@@ -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,51 +264,46 @@ 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"
|
type="button"
|
||||||
type="button"
|
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"
|
aria-labelledby="qc-title"
|
||||||
:data-place="panelPlacement"
|
:style="panelStyle"
|
||||||
aria-labelledby="qc-title"
|
>
|
||||||
:style="panelStyle"
|
<h2 id="qc-title">ایجاد سریع</h2>
|
||||||
>
|
<div class="items">
|
||||||
<h2 id="qc-title">ایجاد سریع</h2>
|
<button
|
||||||
<div class="items">
|
v-for="item in items"
|
||||||
<button
|
:key="item.key"
|
||||||
v-for="(item, index) in items"
|
class="item"
|
||||||
:key="item.key"
|
type="button"
|
||||||
class="item"
|
@click="go(item.to)"
|
||||||
type="button"
|
>
|
||||||
:style="{ '--qc-i': index }"
|
<span class="item-ico" :class="item.tone">
|
||||||
@click="go(item.to)"
|
<UiIcon :name="item.icon" :size="18" />
|
||||||
>
|
</span>
|
||||||
<span class="item-ico" :class="item.tone">
|
<span class="copy">
|
||||||
<UiIcon :name="item.icon" :size="18" />
|
<strong>{{ item.title }}</strong>
|
||||||
</span>
|
<small>{{ item.desc }}</small>
|
||||||
<span class="copy">
|
</span>
|
||||||
<strong>{{ item.title }}</strong>
|
</button>
|
||||||
<small>{{ item.desc }}</small>
|
</div>
|
||||||
</span>
|
</section>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</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);
|
transform: scale(1.05);
|
||||||
box-shadow:
|
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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>
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
error.value = apiMessage(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, 'ارسال پیامک ناموفق بود.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,82 +118,77 @@ 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">
|
||||||
<div v-if="chargeOpen" class="layer">
|
<div v-if="chargeOpen" class="layer">
|
||||||
<div class="backdrop" @click="closeCharge" />
|
<div class="backdrop" @click="closeCharge" />
|
||||||
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
|
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
|
||||||
<header>
|
<header>
|
||||||
<strong>شارژ اعتبار پیامک</strong>
|
<strong>شارژ اعتبار پیامک</strong>
|
||||||
<button type="button" class="x" @click="closeCharge">×</button>
|
<button type="button" class="x" @click="closeCharge">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="chargeInfo" class="charge-body">
|
<div v-if="chargeInfo" class="charge-body">
|
||||||
<div class="instruction-box">
|
<div class="instruction-box">
|
||||||
<p>{{ chargeInfo.instruction }}</p>
|
<p>{{ chargeInfo.instruction }}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-head">
|
|
||||||
<span>شماره کارت</span>
|
|
||||||
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
|
|
||||||
کپی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-head">
|
|
||||||
<span>شماره شبا</span>
|
|
||||||
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
|
|
||||||
کپی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card muted-card">
|
|
||||||
<span class="small">به نام</span>
|
|
||||||
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
|
<div class="info-card">
|
||||||
|
<div class="info-head">
|
||||||
|
<span>شماره کارت</span>
|
||||||
|
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
|
||||||
|
کپی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-head">
|
||||||
|
<span>شماره شبا</span>
|
||||||
|
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
|
||||||
|
کپی
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card muted-card">
|
||||||
|
<span class="small">به نام</span>
|
||||||
|
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="warnOpen" class="layer">
|
<div v-if="warnOpen" class="layer">
|
||||||
<div class="backdrop" @click="warnOpen = false" />
|
<div class="backdrop" @click="warnOpen = false" />
|
||||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||||
<header>
|
<header>
|
||||||
<strong>موجودی پیامک منفی است</strong>
|
<strong>موجودی پیامک منفی است</strong>
|
||||||
<button type="button" class="x" @click="warnOpen = false">×</button>
|
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||||
</header>
|
</header>
|
||||||
<p>
|
<p>
|
||||||
اعتبار پیامک محیط شما منفی شده
|
اعتبار پیامک محیط شما منفی شده
|
||||||
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||||
شارژ کنید.
|
شارژ کنید.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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,11 +62,18 @@ 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>
|
||||||
{{ label }}
|
<template v-if="!iconOnly">
|
||||||
<template v-if="hiddenCount">
|
{{ label }}
|
||||||
· {{ toFaDigits(hiddenCount) }} مخفی
|
<template v-if="hiddenCount">
|
||||||
|
· {{ toFaDigits(hiddenCount) }} مخفی
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-popover>
|
</a-popover>
|
||||||
|
|||||||
@@ -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,226 +389,424 @@ 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
|
:checked="Boolean(memberDraft[u.id])"
|
||||||
v-for="u in users.filter((x) => x.isActive !== false)"
|
@change="toggleMember(u.id)"
|
||||||
:key="u.id"
|
|
||||||
:xs="24"
|
|
||||||
:sm="12"
|
|
||||||
:lg="8"
|
|
||||||
>
|
|
||||||
<a-space>
|
|
||||||
<a-checkbox
|
|
||||||
:checked="Boolean(memberDraft[u.id])"
|
|
||||||
@change="toggleMember(u.id)"
|
|
||||||
>
|
|
||||||
{{ u.fullName }}
|
|
||||||
</a-checkbox>
|
|
||||||
<a-select
|
|
||||||
v-if="memberDraft[u.id]"
|
|
||||||
:value="memberDraft[u.id]"
|
|
||||||
:options="memberRoleOptions"
|
|
||||||
style="width: 120px"
|
|
||||||
@update:value="setRole(u.id, $event)"
|
|
||||||
/>
|
|
||||||
</a-space>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" :loading="saving" @click="saveMembers">ذخیره اعضا</a-button>
|
|
||||||
<a-button @click="editingId = null">انصراف</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-flex>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
</CrmSkeletonCard>
|
|
||||||
</CrmSkeleton>
|
|
||||||
|
|
||||||
<a-collapse :bordered="false" ghost>
|
|
||||||
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل">
|
|
||||||
<a-row :gutter="[16, 12]" align="middle">
|
|
||||||
<a-col :xs="24" :lg="8">
|
|
||||||
<JalaliDateInput
|
|
||||||
:model-value="cutoffLocal"
|
|
||||||
mode="date"
|
|
||||||
placeholder="تاریخ برش"
|
|
||||||
:disabled="cutoffSaving || cutoffLoading"
|
|
||||||
@update:model-value="onCutoffChange"
|
|
||||||
/>
|
/>
|
||||||
</a-col>
|
<span>{{ u.fullName }}</span>
|
||||||
<a-col :xs="24" :lg="8">
|
<select
|
||||||
<a-input v-model:value="priorSearch" placeholder="جستجوی کاربر…" style="width: 100%" />
|
v-if="memberDraft[u.id]"
|
||||||
</a-col>
|
:value="memberDraft[u.id]"
|
||||||
<a-col :xs="24" :lg="8">
|
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
|
||||||
<a-select
|
|
||||||
:value="priorPick"
|
|
||||||
:options="availablePriorOptions"
|
|
||||||
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
|
|
||||||
allow-clear
|
|
||||||
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)"
|
|
||||||
>
|
>
|
||||||
{{ u.fullName }}
|
<option value="member">عضو</option>
|
||||||
</a-tag>
|
<option value="manager">سرپرست</option>
|
||||||
</a-space>
|
</select>
|
||||||
<a-typography-text v-else type="secondary">
|
</label>
|
||||||
هنوز کاربری انتخاب نشده است.
|
<div class="actions">
|
||||||
</a-typography-text>
|
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
|
||||||
</a-flex>
|
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
|
||||||
</a-collapse-panel>
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<a-collapse-panel key="access" header="محدوده داده کاربران">
|
<section v-if="showPriorArchive" class="card">
|
||||||
<a-flex vertical :gap="8">
|
<header class="prior-head">
|
||||||
<a-typography-text type="secondary">
|
<h3>بایگانی اطلاعات قبل</h3>
|
||||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر جدا بگذارید.
|
<JalaliDateInput
|
||||||
</a-typography-text>
|
:model-value="cutoffLocal"
|
||||||
<a-empty
|
mode="date"
|
||||||
v-if="!editableUsers.length"
|
placeholder="تاریخ برش"
|
||||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
:disabled="cutoffSaving || cutoffLoading"
|
||||||
|
@update:model-value="onCutoffChange"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<p class="hint">
|
||||||
|
فقط کاربرانی که تیک میخورند سوئیچ کار جاری و بایگانی را میبینند. بقیهٔ محیط مثل قبل همهٔ
|
||||||
|
رکوردها را مطابق سطح دسترسی خودشان میبینند.
|
||||||
|
</p>
|
||||||
|
<div class="prior-picker">
|
||||||
|
<UiInput v-model="priorSearch" placeholder="جستجوی کاربر…" />
|
||||||
|
<select
|
||||||
|
v-model="priorPick"
|
||||||
|
:disabled="!availablePriorUsers.length || Boolean(priorSavingId)"
|
||||||
|
@change="pickPriorUser(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{{ availablePriorUsers.length ? 'انتخاب کاربر برای فعالسازی' : 'کاربر دیگری پیدا نشد' }}
|
||||||
|
</option>
|
||||||
|
<option v-for="u in availablePriorUsers" :key="u.id" :value="u.id">
|
||||||
|
{{ u.fullName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedPriorUsers.length" class="prior-chips">
|
||||||
|
<span v-for="u in selectedPriorUsers" :key="u.id" class="prior-chip">
|
||||||
|
{{ u.fullName }}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<header class="card-head">
|
||||||
|
<h3>محدوده داده</h3>
|
||||||
|
<UiInput
|
||||||
|
v-if="editableUsers.length"
|
||||||
|
v-model="accessQuery"
|
||||||
|
placeholder="جستجوی کاربر…"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<p class="hint">
|
||||||
|
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید فیلتر
|
||||||
|
جدا بگذارید.
|
||||||
|
</p>
|
||||||
|
<UiEmptyState
|
||||||
|
v-if="!editableUsers.length"
|
||||||
|
title="کاربر قابل تنظیم نیست"
|
||||||
|
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||||
|
/>
|
||||||
|
<p v-else-if="!filteredAccessUsers.length" class="hint">کاربری با این جستجو پیدا نشد.</p>
|
||||||
|
<div v-else class="vis-table">
|
||||||
|
<div v-for="u in filteredAccessUsers" :key="u.id" class="vis-user">
|
||||||
|
<div class="vis-row">
|
||||||
|
<span>
|
||||||
|
{{ u.fullName }}
|
||||||
|
<small class="vis-badge">{{ accessSummary(u) }}</small>
|
||||||
|
</span>
|
||||||
|
<UiButton v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
|
||||||
|
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم محدوده' }}
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedAccess === u.id" class="vis-scopes">
|
||||||
|
<label class="chip-row">
|
||||||
|
<input v-model="accessMode" type="radio" value="inherit" />
|
||||||
|
مثل نقش (بدون override)
|
||||||
|
</label>
|
||||||
|
<label class="chip-row">
|
||||||
|
<input v-model="accessMode" type="radio" value="custom" />
|
||||||
|
سفارشی برای این کاربر
|
||||||
|
</label>
|
||||||
|
<DataAccessPolicyPanel
|
||||||
|
v-model="draftAccess"
|
||||||
|
v-model:treasury-access="draftTreasury"
|
||||||
|
:show-treasury="showTreasury"
|
||||||
|
:data-locked="accessMode === 'inherit'"
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
<a-list v-else item-layout="vertical" :data-source="editableUsers">
|
<div class="actions">
|
||||||
<template #renderItem="{ item: u }">
|
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
|
||||||
<a-list-item>
|
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
|
||||||
<template v-if="canEdit" #actions>
|
</div>
|
||||||
<a-button type="link" @click="openAccessEditor(u)">
|
</div>
|
||||||
{{ expandedAccess === u.id ? 'بستن' : 'تنظیم' }}
|
</div>
|
||||||
</a-button>
|
</div>
|
||||||
</template>
|
</section>
|
||||||
<a-list-item-meta :description="accessSummary(u)">
|
</div>
|
||||||
<template #title>
|
|
||||||
<a-typography-text strong>{{ u.fullName }}</a-typography-text>
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
<a-flex v-if="expandedAccess === u.id" vertical :gap="8" style="width: 100%">
|
|
||||||
<a-radio-group v-model:value="accessMode">
|
|
||||||
<a-space wrap>
|
|
||||||
<a-radio value="inherit">مثل نقش</a-radio>
|
|
||||||
<a-radio value="custom">سفارشی</a-radio>
|
|
||||||
</a-space>
|
|
||||||
</a-radio-group>
|
|
||||||
<DataAccessPolicyPanel
|
|
||||||
v-model="draftAccess"
|
|
||||||
v-model:treasury-access="draftTreasury"
|
|
||||||
:show-treasury="showTreasury"
|
|
||||||
:data-locked="accessMode === 'inherit'"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" :loading="accessSaving" @click="saveUserAccess(u.id)">
|
|
||||||
ذخیره
|
|
||||||
</a-button>
|
|
||||||
<a-button @click="expandedAccess = null">انصراف</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-flex>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<a-select
|
<div class="user-multi" :class="{ 'with-all': selectAll }">
|
||||||
mode="multiple"
|
<a-select
|
||||||
allow-clear
|
mode="multiple"
|
||||||
show-search
|
allow-clear
|
||||||
option-filter-prop="label"
|
show-search
|
||||||
:value="modelValue"
|
:value="selectValue"
|
||||||
:options="options"
|
:options="options"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:placeholder="emptyLabel"
|
:placeholder="emptyLabel"
|
||||||
style="width: 100%"
|
:filter-option="filterOption"
|
||||||
@update:value="onUpdate"
|
:max-tag-count="tagLimit"
|
||||||
/>
|
:max-tag-placeholder="selectAll && allPicked ? tagPlaceholder : undefined"
|
||||||
|
style="width: 100%"
|
||||||
|
@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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -10,10 +10,9 @@ import type {
|
|||||||
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 { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, 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,
|
||||||
@@ -224,12 +264,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 +277,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 +327,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 +361,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 +394,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 +502,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,63 +522,72 @@ 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="{
|
<PipelineBoard
|
||||||
flex: '1 1 auto',
|
v-else
|
||||||
minHeight: 0,
|
:stages="boardStages"
|
||||||
height: '100%',
|
:dragging-id="draggingId"
|
||||||
minWidth: 0,
|
:drop-stage-id="dropStageId"
|
||||||
display: 'flex',
|
:can-manage="canManage"
|
||||||
flexDirection: 'column',
|
:copy="ACTIVITY_BOARD_COPY"
|
||||||
}"
|
:select-mode="selectMode"
|
||||||
>
|
:selected-ids="selectedIds"
|
||||||
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
|
:peek-id="peekId"
|
||||||
<a-flex
|
:preview-id="quickId"
|
||||||
v-if="initialLoading && !Object.keys(columns).length"
|
@drag-start="onDragStart"
|
||||||
:gap="12"
|
@drag-end="onDragEnd"
|
||||||
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
|
@drag-over="onDragOver"
|
||||||
>
|
@drop="onDrop"
|
||||||
<div
|
@add-deal="emit('add', $event)"
|
||||||
v-for="i in 4"
|
@open-deal="openActivity"
|
||||||
:key="`sk-col-${i}`"
|
@detail-deal="openFullActivity"
|
||||||
:style="{ width: '280px', flex: '0 0 280px' }"
|
@menu-deal="onMenuActivity"
|
||||||
>
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
<CrmSkeletonCard :rows="6" :min-height="360" />
|
@bulk-delete="emit('bulkDelete')"
|
||||||
</div>
|
@rename-stage="onRenameStage"
|
||||||
</a-flex>
|
@delete-stage="onDeleteStage"
|
||||||
<PipelineBoard
|
@move-stage="onMoveStage"
|
||||||
v-else
|
@column-drag-start="onColumnDragStart"
|
||||||
:stages="boardStages"
|
@column-drag-end="onColumnDragEnd"
|
||||||
:dragging-id="draggingId"
|
@add-stage="onAddStage"
|
||||||
:drop-stage-id="dropStageId"
|
@load-more="loadMore"
|
||||||
:can-manage="canManage"
|
/>
|
||||||
:copy="ACTIVITY_BOARD_COPY"
|
<ActivityQuickView
|
||||||
:select-mode="selectMode"
|
:activity-id="quickId"
|
||||||
:selected-ids="selectedIds"
|
@close="closeQuick"
|
||||||
@drag-start="onDragStart"
|
@changed="loadAll()"
|
||||||
@drag-end="onDragEnd"
|
@full="openFullActivity"
|
||||||
@drag-over="onDragOver"
|
/>
|
||||||
@drop="onDrop"
|
|
||||||
@add-deal="emit('add', $event)"
|
|
||||||
@open-deal="openActivity"
|
|
||||||
@menu-deal="onMenuActivity"
|
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
|
||||||
@bulk-delete="emit('bulkDelete')"
|
|
||||||
@rename-stage="onRenameStage"
|
|
||||||
@delete-stage="onDeleteStage"
|
|
||||||
@move-stage="onMoveStage"
|
|
||||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
|
||||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
|
||||||
@add-stage="onAddStage"
|
|
||||||
@load-more="loadMore"
|
|
||||||
/>
|
|
||||||
</CrmSkeleton>
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<DealCardOverlay
|
<DealCardOverlay
|
||||||
v-if="overlayDeal && draggingId"
|
v-if="overlayDeal && draggingId"
|
||||||
@@ -506,4 +611,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"
|
||||||
|
|||||||
@@ -11,34 +11,12 @@ import type {
|
|||||||
PersonDto,
|
PersonDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { canAssignToOthers } from '@/lib/assign-access';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { toFaDigits } from '@/lib/jalali';
|
import { toFaDigits } from '@/lib/jalali';
|
||||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
import UserMultiSelect from '@/components/UserMultiSelect.vue';
|
||||||
import {
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
Button as AButton,
|
|
||||||
Checkbox as ACheckbox,
|
|
||||||
Col as ACol,
|
|
||||||
Flex as AFlex,
|
|
||||||
FormItem as AFormItem,
|
|
||||||
Input as AInput,
|
|
||||||
InputNumber as AInputNumber,
|
|
||||||
Row as ARow,
|
|
||||||
Select as ASelect,
|
|
||||||
Space as ASpace,
|
|
||||||
Switch as ASwitch,
|
|
||||||
Tag as ATag,
|
|
||||||
Textarea as ATextarea,
|
|
||||||
Typography,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
import {
|
|
||||||
CloseOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
UpOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
} from '@/icons/ant-bridge';
|
|
||||||
|
|
||||||
const { Text: ATypographyText } = Typography;
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fields: ActivityFieldDef[];
|
fields: ActivityFieldDef[];
|
||||||
@@ -46,6 +24,8 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const model = defineModel<ActivityFieldValues>({ required: true });
|
const model = defineModel<ActivityFieldValues>({ required: true });
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
|
||||||
|
|
||||||
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
|
||||||
const v = model.value[key];
|
const v = model.value[key];
|
||||||
@@ -66,20 +46,9 @@ function multi(key: string): string[] {
|
|||||||
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
|
return Array.isArray(v) && v.every((x) => typeof x === 'string') ? (v as string[]) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldSpan(type: ActivityFieldDef['type']): number {
|
function toggleMulti(key: string, opt: string) {
|
||||||
return ['textarea', 'checklist', 'participants', 'multiselect'].includes(type) ? 24 : 12;
|
const list = multi(key);
|
||||||
}
|
set(key, list.includes(opt) ? list.filter((x) => x !== opt) : [...list, opt]);
|
||||||
|
|
||||||
function selectOptions(f: ActivityFieldDef) {
|
|
||||||
return (f.options ?? []).map((opt) => ({ value: opt, label: opt }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function onNumberUpdate(key: string, value: number | string | null) {
|
|
||||||
if (value == null || value === '') {
|
|
||||||
set(key, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
set(key, Number(value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- چکلیست ---------- */
|
/* ---------- چکلیست ---------- */
|
||||||
@@ -87,9 +56,7 @@ const checklistDraft = ref<Record<string, string>>({});
|
|||||||
|
|
||||||
function checklist(key: string): ActivityChecklistItem[] {
|
function checklist(key: string): ActivityChecklistItem[] {
|
||||||
const v = model.value[key];
|
const v = model.value[key];
|
||||||
return Array.isArray(v)
|
return Array.isArray(v) ? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object') : [];
|
||||||
? (v as ActivityChecklistItem[]).filter((x) => x && typeof x === 'object')
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addChecklistItem(key: string) {
|
function addChecklistItem(key: string) {
|
||||||
@@ -148,82 +115,39 @@ function addGuest(key: string) {
|
|||||||
|
|
||||||
const personQuery = ref<Record<string, string>>({});
|
const personQuery = ref<Record<string, string>>({});
|
||||||
const personResults = ref<Record<string, PersonDto[]>>({});
|
const personResults = ref<Record<string, PersonDto[]>>({});
|
||||||
const personSearching = ref<Record<string, boolean>>({});
|
const personOpenKey = ref('');
|
||||||
let personTimer: ReturnType<typeof setTimeout> | undefined;
|
let personTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let personSeq = 0;
|
let personSeq = 0;
|
||||||
|
|
||||||
function searchPeople(key: string, raw: string) {
|
function searchPeople(key: string) {
|
||||||
personQuery.value = { ...personQuery.value, [key]: raw };
|
personOpenKey.value = key;
|
||||||
if (personTimer) clearTimeout(personTimer);
|
if (personTimer) clearTimeout(personTimer);
|
||||||
const q = raw.trim();
|
const q = (personQuery.value[key] ?? '').trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
personResults.value = { ...personResults.value, [key]: [] };
|
personResults.value = { ...personResults.value, [key]: [] };
|
||||||
personSearching.value = { ...personSearching.value, [key]: false };
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
personSearching.value = { ...personSearching.value, [key]: true };
|
|
||||||
const seq = ++personSeq;
|
const seq = ++personSeq;
|
||||||
personTimer = setTimeout(async () => {
|
personTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
|
||||||
params: { q, limit: 8 },
|
params: { q, limit: 8 },
|
||||||
});
|
});
|
||||||
if (seq === personSeq) {
|
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: data.items };
|
||||||
personResults.value = { ...personResults.value, [key]: data.items };
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (seq === personSeq) {
|
if (seq === personSeq) personResults.value = { ...personResults.value, [key]: [] };
|
||||||
personResults.value = { ...personResults.value, [key]: [] };
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (seq === personSeq) {
|
|
||||||
personSearching.value = { ...personSearching.value, [key]: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 280);
|
}, 280);
|
||||||
}
|
}
|
||||||
|
|
||||||
const personSelectOptions = computed(() => {
|
function addPerson(key: string, p: PersonDto) {
|
||||||
const map: Record<string, Array<{ value: string; label: string; company: string }>> = {};
|
|
||||||
for (const f of props.fields) {
|
|
||||||
if (f.type !== 'participants') continue;
|
|
||||||
const selectedIds = new Set(participants(f.key).people.map((p) => p.id));
|
|
||||||
map[f.key] = (personResults.value[f.key] ?? [])
|
|
||||||
.filter((p) => !selectedIds.has(p.id))
|
|
||||||
.map((p) => ({
|
|
||||||
value: p.id,
|
|
||||||
label: p.fullName,
|
|
||||||
company: p.company?.name ?? '',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
function onPersonPick(key: string, value: unknown) {
|
|
||||||
const id = value == null ? '' : String(value);
|
|
||||||
if (!id) return;
|
|
||||||
const hit =
|
|
||||||
(personResults.value[key] ?? []).find((p) => p.id === id) ??
|
|
||||||
null;
|
|
||||||
if (!hit) return;
|
|
||||||
const cur = participants(key);
|
const cur = participants(key);
|
||||||
if (!cur.people.some((x) => x.id === hit.id)) {
|
if (!cur.people.some((x) => x.id === p.id)) {
|
||||||
patchParticipants(key, { people: [...cur.people, { id: hit.id, name: hit.fullName }] });
|
patchParticipants(key, { people: [...cur.people, { id: p.id, name: p.fullName }] });
|
||||||
}
|
}
|
||||||
personQuery.value = { ...personQuery.value, [key]: '' };
|
personQuery.value = { ...personQuery.value, [key]: '' };
|
||||||
personResults.value = { ...personResults.value, [key]: [] };
|
personResults.value = { ...personResults.value, [key]: [] };
|
||||||
}
|
personOpenKey.value = '';
|
||||||
|
|
||||||
function removePerson(key: string, id: string) {
|
|
||||||
patchParticipants(key, {
|
|
||||||
people: participants(key).people.filter((x) => x.id !== id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGuest(key: string, name: string) {
|
|
||||||
patchParticipants(key, {
|
|
||||||
guests: participants(key).guests.filter((x) => x !== name),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -233,237 +157,523 @@ watch(
|
|||||||
guestDraft.value = {};
|
guestDraft.value = {};
|
||||||
personQuery.value = {};
|
personQuery.value = {};
|
||||||
personResults.value = {};
|
personResults.value = {};
|
||||||
personSearching.value = {};
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-row :gutter="[16, 0]">
|
<div class="fields-grid">
|
||||||
<a-col
|
<div
|
||||||
v-for="f in fields"
|
v-for="f in fields"
|
||||||
:key="f.key"
|
:key="f.key"
|
||||||
:xs="24"
|
class="cf"
|
||||||
:md="fieldSpan(f.type)"
|
:class="{ wide: ['textarea', 'checklist', 'participants', 'multiselect'].includes(f.type) }"
|
||||||
>
|
>
|
||||||
<a-form-item :required="f.required">
|
<label :for="`cf-${f.key}`">
|
||||||
<template #label>
|
{{ f.label }}
|
||||||
{{ f.label }}
|
<i v-if="f.required">*</i>
|
||||||
</template>
|
</label>
|
||||||
|
|
||||||
<a-input
|
<input
|
||||||
v-if="f.type === 'text' || f.type === 'url'"
|
v-if="f.type === 'text' || f.type === 'url'"
|
||||||
:value="str(f.key)"
|
:id="`cf-${f.key}`"
|
||||||
:type="f.type === 'url' ? 'url' : 'text'"
|
:type="f.type === 'url' ? 'url' : 'text'"
|
||||||
:dir="f.type === 'url' ? 'ltr' : undefined"
|
:dir="f.type === 'url' ? 'ltr' : undefined"
|
||||||
:placeholder="f.placeholder || ''"
|
:value="str(f.key)"
|
||||||
:maxlength="1000"
|
:placeholder="f.placeholder || ''"
|
||||||
style="width: 100%"
|
maxlength="1000"
|
||||||
@update:value="(v: string) => set(f.key, v)"
|
@input="set(f.key, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-else-if="f.type === 'textarea'"
|
||||||
|
:id="`cf-${f.key}`"
|
||||||
|
:value="str(f.key)"
|
||||||
|
:placeholder="f.placeholder || ''"
|
||||||
|
rows="3"
|
||||||
|
@input="set(f.key, ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="f.type === 'number'"
|
||||||
|
:id="`cf-${f.key}`"
|
||||||
|
type="number"
|
||||||
|
inputmode="decimal"
|
||||||
|
dir="ltr"
|
||||||
|
:value="str(f.key)"
|
||||||
|
:placeholder="f.placeholder || ''"
|
||||||
|
@input="
|
||||||
|
set(
|
||||||
|
f.key,
|
||||||
|
($event.target as HTMLInputElement).value === ''
|
||||||
|
? null
|
||||||
|
: Number(($event.target as HTMLInputElement).value),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JalaliDateInput
|
||||||
|
v-else-if="f.type === 'date'"
|
||||||
|
:id="`cf-${f.key}`"
|
||||||
|
:model-value="str(f.key)"
|
||||||
|
@update:model-value="(v: string) => set(f.key, v || null)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
v-else-if="f.type === 'select'"
|
||||||
|
:id="`cf-${f.key}`"
|
||||||
|
:value="str(f.key)"
|
||||||
|
@change="set(f.key, ($event.target as HTMLSelectElement).value || null)"
|
||||||
|
>
|
||||||
|
<option value="">— انتخاب کنید —</option>
|
||||||
|
<option v-for="opt in f.options ?? []" :key="opt" :value="opt">{{ opt }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div v-else-if="f.type === 'multiselect'" class="chips">
|
||||||
|
<button
|
||||||
|
v-for="opt in f.options ?? []"
|
||||||
|
:key="opt"
|
||||||
|
type="button"
|
||||||
|
class="chip-opt"
|
||||||
|
:class="{ on: multi(f.key).includes(opt) }"
|
||||||
|
@click="toggleMulti(f.key, opt)"
|
||||||
|
>
|
||||||
|
<UiIcon v-if="multi(f.key).includes(opt)" name="check-plain" :size="12" />
|
||||||
|
{{ opt }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-else-if="f.type === 'checkbox'" class="switch">
|
||||||
|
<input
|
||||||
|
:id="`cf-${f.key}`"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="get<boolean>(f.key, false) === true"
|
||||||
|
@change="set(f.key, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
|
<i />
|
||||||
|
<span>{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<a-textarea
|
<div v-else-if="f.type === 'checklist'" class="checklist">
|
||||||
v-else-if="f.type === 'textarea'"
|
<p v-if="checklist(f.key).length" class="progress">
|
||||||
:value="str(f.key)"
|
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
||||||
:placeholder="f.placeholder || ''"
|
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
||||||
:rows="3"
|
</p>
|
||||||
style="width: 100%"
|
<ul>
|
||||||
@update:value="(v: string) => set(f.key, v)"
|
<li v-for="(it, idx) in checklist(f.key)" :key="it.id" :class="{ done: it.done }">
|
||||||
/>
|
<input
|
||||||
|
type="checkbox"
|
||||||
<a-input-number
|
|
||||||
v-else-if="f.type === 'number'"
|
|
||||||
:value="str(f.key) === '' ? undefined : Number(str(f.key))"
|
|
||||||
:placeholder="f.placeholder || ''"
|
|
||||||
style="width: 100%"
|
|
||||||
@update:value="(v: number | string | null) => onNumberUpdate(f.key, v)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<JalaliDateInput
|
|
||||||
v-else-if="f.type === 'date'"
|
|
||||||
:model-value="str(f.key)"
|
|
||||||
style="width: 100%"
|
|
||||||
@update:model-value="(v: string) => set(f.key, v || null)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-select
|
|
||||||
v-else-if="f.type === 'select'"
|
|
||||||
:value="str(f.key) || undefined"
|
|
||||||
:options="selectOptions(f)"
|
|
||||||
allow-clear
|
|
||||||
placeholder="— انتخاب کنید —"
|
|
||||||
style="width: 100%"
|
|
||||||
@update:value="(v: unknown) => set(f.key, v == null || v === '' ? null : String(v))"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-select
|
|
||||||
v-else-if="f.type === 'multiselect'"
|
|
||||||
mode="multiple"
|
|
||||||
:value="multi(f.key)"
|
|
||||||
:options="selectOptions(f)"
|
|
||||||
:placeholder="f.placeholder || 'انتخاب کنید'"
|
|
||||||
style="width: 100%"
|
|
||||||
@update:value="(v: unknown) => set(f.key, Array.isArray(v) ? v.map(String) : [])"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<a-space v-else-if="f.type === 'checkbox'" :size="8">
|
|
||||||
<a-switch
|
|
||||||
:checked="get<boolean>(f.key, false) === true"
|
|
||||||
@update:checked="(v) => set(f.key, v === true)"
|
|
||||||
/>
|
|
||||||
<a-typography-text>
|
|
||||||
{{ get<boolean>(f.key, false) === true ? 'بله' : 'خیر' }}
|
|
||||||
</a-typography-text>
|
|
||||||
</a-space>
|
|
||||||
|
|
||||||
<a-flex v-else-if="f.type === 'checklist'" vertical :gap="8">
|
|
||||||
<a-typography-text v-if="checklist(f.key).length" type="success">
|
|
||||||
{{ toFaDigits(checklist(f.key).filter((x) => x.done).length) }} از
|
|
||||||
{{ toFaDigits(checklist(f.key).length) }} انجام شده
|
|
||||||
</a-typography-text>
|
|
||||||
<a-flex
|
|
||||||
v-for="(it, idx) in checklist(f.key)"
|
|
||||||
:key="it.id"
|
|
||||||
align="center"
|
|
||||||
:gap="8"
|
|
||||||
>
|
|
||||||
<a-checkbox
|
|
||||||
:checked="it.done"
|
:checked="it.done"
|
||||||
@update:checked="(v: boolean) => updateChecklistItem(f.key, it.id, { done: v })"
|
aria-label="انجام شد"
|
||||||
|
@change="updateChecklistItem(f.key, it.id, { done: ($event.target as HTMLInputElement).checked })"
|
||||||
/>
|
/>
|
||||||
<a-input
|
<input
|
||||||
|
class="item-text"
|
||||||
:value="it.text"
|
:value="it.text"
|
||||||
:maxlength="300"
|
maxlength="300"
|
||||||
:style="{
|
@input="updateChecklistItem(f.key, it.id, { text: ($event.target as HTMLInputElement).value })"
|
||||||
flex: 1,
|
|
||||||
textDecoration: it.done ? 'line-through' : undefined,
|
|
||||||
}"
|
|
||||||
@update:value="(v: string) => updateChecklistItem(f.key, it.id, { text: v })"
|
|
||||||
/>
|
/>
|
||||||
<a-button
|
<button type="button" class="mini" :disabled="idx === 0" aria-label="بالا" @click="moveChecklistItem(f.key, idx, -1)">▲</button>
|
||||||
:disabled="idx === 0"
|
<button
|
||||||
aria-label="بالا"
|
type="button"
|
||||||
@click="moveChecklistItem(f.key, idx, -1)"
|
class="mini"
|
||||||
>
|
|
||||||
<template #icon><UpOutlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button
|
|
||||||
:disabled="idx === checklist(f.key).length - 1"
|
:disabled="idx === checklist(f.key).length - 1"
|
||||||
aria-label="پایین"
|
aria-label="پایین"
|
||||||
@click="moveChecklistItem(f.key, idx, 1)"
|
@click="moveChecklistItem(f.key, idx, 1)"
|
||||||
>
|
>
|
||||||
<template #icon><DownOutlined /></template>
|
▼
|
||||||
</a-button>
|
</button>
|
||||||
<a-button
|
<button type="button" class="mini danger" aria-label="حذف" @click="removeChecklistItem(f.key, it.id)">
|
||||||
danger
|
<UiIcon name="x" :size="13" />
|
||||||
aria-label="حذف"
|
</button>
|
||||||
@click="removeChecklistItem(f.key, it.id)"
|
</li>
|
||||||
>
|
</ul>
|
||||||
<template #icon><CloseOutlined /></template>
|
<div class="add-row">
|
||||||
</a-button>
|
<input
|
||||||
</a-flex>
|
v-model="checklistDraft[f.key]"
|
||||||
<a-space :size="8" style="width: 100%">
|
maxlength="300"
|
||||||
<a-input
|
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
||||||
:value="checklistDraft[f.key] ?? ''"
|
@keydown.enter.prevent="addChecklistItem(f.key)"
|
||||||
:maxlength="300"
|
/>
|
||||||
:placeholder="f.placeholder || 'مورد جدید را بنویسید و Enter بزنید'"
|
<button type="button" class="add-btn" @click="addChecklistItem(f.key)">
|
||||||
style="width: 100%"
|
<UiIcon name="plus" :size="14" />
|
||||||
@update:value="(v: string) => (checklistDraft[f.key] = v)"
|
افزودن
|
||||||
@pressEnter="addChecklistItem(f.key)"
|
</button>
|
||||||
/>
|
</div>
|
||||||
<a-button type="dashed" @click="addChecklistItem(f.key)">
|
</div>
|
||||||
<template #icon><PlusOutlined /></template>
|
|
||||||
افزودن
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-flex>
|
|
||||||
|
|
||||||
<a-flex v-else-if="f.type === 'participants'" vertical :gap="16">
|
<div v-else-if="f.type === 'participants'" class="participants">
|
||||||
<a-flex vertical :gap="4">
|
<div class="p-block">
|
||||||
<a-typography-text type="secondary">
|
<small>همکاران (فعالیت در لیست آنها هم نمایش داده میشود)</small>
|
||||||
همکاران (فعالیت در لیست آنها هم نمایش داده میشود)
|
<UserMultiSelect
|
||||||
</a-typography-text>
|
:model-value="participants(f.key).userIds"
|
||||||
<UserMultiSelect
|
:users="users"
|
||||||
:model-value="participants(f.key).userIds"
|
:disabled="!canAssignOthers"
|
||||||
:users="users"
|
add-label="افزودن همکار"
|
||||||
add-label="افزودن همکار"
|
empty-label="همکاری انتخاب نشده"
|
||||||
empty-label="همکاری انتخاب نشده"
|
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
||||||
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
|
/>
|
||||||
/>
|
</div>
|
||||||
</a-flex>
|
<div class="p-block">
|
||||||
|
<small>اشخاص / مشتریان</small>
|
||||||
<a-flex vertical :gap="8">
|
<div class="tags">
|
||||||
<a-typography-text type="secondary">اشخاص / مشتریان</a-typography-text>
|
<span v-for="p in participants(f.key).people" :key="p.id" class="tag">
|
||||||
<a-space v-if="participants(f.key).people.length" :size="6" wrap>
|
<UiIcon name="user" :size="12" />
|
||||||
<a-tag
|
{{ p.name }}
|
||||||
v-for="p in participants(f.key).people"
|
<button
|
||||||
:key="p.id"
|
type="button"
|
||||||
closable
|
aria-label="حذف"
|
||||||
color="cyan"
|
@click="patchParticipants(f.key, { people: participants(f.key).people.filter((x) => x.id !== p.id) })"
|
||||||
:bordered="false"
|
|
||||||
@close="removePerson(f.key, p.id)"
|
|
||||||
>
|
>
|
||||||
<UserOutlined />
|
×
|
||||||
{{ p.name }}
|
</button>
|
||||||
</a-tag>
|
</span>
|
||||||
</a-space>
|
</div>
|
||||||
<a-select
|
<div class="person-search">
|
||||||
show-search
|
<input
|
||||||
allow-clear
|
v-model="personQuery[f.key]"
|
||||||
:filter-option="false"
|
type="search"
|
||||||
:value="undefined"
|
|
||||||
:options="personSelectOptions[f.key] ?? []"
|
|
||||||
:not-found-content="
|
|
||||||
personSearching[f.key]
|
|
||||||
? 'در حال جستجو…'
|
|
||||||
: (personQuery[f.key] || '').trim()
|
|
||||||
? 'شخصی پیدا نشد.'
|
|
||||||
: 'نام یا موبایل را جستجو کنید'
|
|
||||||
"
|
|
||||||
placeholder="جستجوی نام یا موبایل شخص…"
|
placeholder="جستجوی نام یا موبایل شخص…"
|
||||||
style="width: 100%"
|
@input="searchPeople(f.key)"
|
||||||
@search="(q: string) => searchPeople(f.key, q)"
|
@focus="personOpenKey = f.key"
|
||||||
@update:value="(v: unknown) => onPersonPick(f.key, v)"
|
/>
|
||||||
>
|
<ul v-if="personOpenKey === f.key && (personResults[f.key]?.length ?? 0) > 0" class="results">
|
||||||
<template #option="{ label, company }">
|
<li v-for="p in personResults[f.key]" :key="p.id">
|
||||||
<a-flex vertical :gap="0">
|
<button type="button" @click="addPerson(f.key, p)">
|
||||||
<a-typography-text>{{ label }}</a-typography-text>
|
<b>{{ p.fullName }}</b>
|
||||||
<a-typography-text v-if="company" type="secondary">{{ company }}</a-typography-text>
|
<small v-if="p.company?.name">{{ p.company.name }}</small>
|
||||||
</a-flex>
|
</button>
|
||||||
</template>
|
</li>
|
||||||
</a-select>
|
</ul>
|
||||||
</a-flex>
|
</div>
|
||||||
|
</div>
|
||||||
<a-flex vertical :gap="8">
|
<div class="p-block">
|
||||||
<a-typography-text type="secondary">مهمانان دیگر (نام آزاد)</a-typography-text>
|
<small>مهمانان دیگر (نام آزاد)</small>
|
||||||
<a-space v-if="participants(f.key).guests.length" :size="6" wrap>
|
<div class="tags">
|
||||||
<a-tag
|
<span v-for="g in participants(f.key).guests" :key="g" class="tag guest">
|
||||||
v-for="g in participants(f.key).guests"
|
{{ g }}
|
||||||
:key="g"
|
<button
|
||||||
closable
|
type="button"
|
||||||
:bordered="false"
|
aria-label="حذف"
|
||||||
@close="removeGuest(f.key, g)"
|
@click="patchParticipants(f.key, { guests: participants(f.key).guests.filter((x) => x !== g) })"
|
||||||
>
|
>
|
||||||
{{ g }}
|
×
|
||||||
</a-tag>
|
</button>
|
||||||
</a-space>
|
</span>
|
||||||
<a-space :size="8" style="width: 100%">
|
</div>
|
||||||
<a-input
|
<div class="add-row">
|
||||||
:value="guestDraft[f.key] ?? ''"
|
<input
|
||||||
:maxlength="120"
|
v-model="guestDraft[f.key]"
|
||||||
placeholder="نام مهمان و Enter"
|
maxlength="120"
|
||||||
style="width: 100%"
|
placeholder="نام مهمان و Enter"
|
||||||
@update:value="(v: string) => (guestDraft[f.key] = v)"
|
@keydown.enter.prevent="addGuest(f.key)"
|
||||||
@pressEnter="addGuest(f.key)"
|
/>
|
||||||
/>
|
<button type="button" class="add-btn" @click="addGuest(f.key)">
|
||||||
<a-button type="dashed" @click="addGuest(f.key)">
|
<UiIcon name="plus" :size="14" />
|
||||||
<template #icon><PlusOutlined /></template>
|
افزودن
|
||||||
افزودن
|
</button>
|
||||||
</a-button>
|
</div>
|
||||||
</a-space>
|
</div>
|
||||||
</a-flex>
|
</div>
|
||||||
</a-flex>
|
</div>
|
||||||
</a-form-item>
|
</div>
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fields-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf > label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted, #64748b);
|
||||||
|
font-size: var(--fs-xs, 0.78rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf > label i {
|
||||||
|
color: var(--color-danger, #dc2626);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf input:not([type='checkbox']),
|
||||||
|
.cf select,
|
||||||
|
.cf textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
min-height: var(--control-h);
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf textarea {
|
||||||
|
min-height: 5rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips,
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-opt {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-opt.on {
|
||||||
|
border-color: #5eead4;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch i {
|
||||||
|
width: 44px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch i::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 3px;
|
||||||
|
top: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
transition: 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + i {
|
||||||
|
background: #14b8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked + i::after {
|
||||||
|
inset-inline-start: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist li input[type='checkbox'] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #14b8a6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist .item-text {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 2.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist li.done .item-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #0f766e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini.danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
border: 1px dashed #5eead4;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: #0f766e;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px dashed #e2e8f0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-block > small {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: #0f766e;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.guest {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-search {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
position: absolute;
|
||||||
|
inset-inline: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
z-index: 20;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results button {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
text-align: start;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results button:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results b {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results small {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.fields-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cf.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type PostponeKey = 'h1' | 'tomorrow-am' | 'next-saturday' | 'next-week' | 'custo
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
dueAt?: string | null;
|
dueAt?: string | null;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
/** بالاتر از مودال خلاصهٔ فعالیت */
|
||||||
|
overlayZIndex?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -154,7 +156,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">
|
||||||
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
|
||||||
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
|
||||||
<div class="handle" aria-hidden="true" />
|
<div class="handle" aria-hidden="true" />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user