Compare commits

..

11 Commits

Author SHA1 Message Date
6d49fd0b89 Bind staging nginx to localhost for Windows Docker Desktop
Publish port 8088 on 127.0.0.1 only so netsh portproxy can expose staging
to LAN/public IPs; document the one-time portproxy setup on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 17:35:54 +03:30
e1c0434ed1 Fix frontend staging healthcheck for locale redirect
Accept 2xx/3xx on / so next-intl redirect to /en no longer marks the
container unhealthy and blocks nginx from starting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:34:58 +03:30
eff27e359b Fix staging containers failing on Windows Docker builds
Strip CRLF from entrypoint scripts in backend/frontend images, use Docker
DNS in staging nginx config, and wait for healthy app services before nginx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:16:14 +03:30
2b2cca8361 fix docker url for making connection with gitea. 2026-08-23 15:20:14 +03:30
0e8e3a4a27 local ip set instead of static public ip server to find it in network. 2026-08-23 14:13:29 +03:30
a43c433d55 lable self-hosted change to windows 2026-08-23 11:34:10 +03:30
8b462ea45a Merge branch 'master' into feature/ci-cd 2026-08-23 11:11:27 +03:30
ebd2de39cf staging deploy md doc. 2026-08-23 11:11:07 +03:30
1258ecf899 Enable Gitea Actions staging deploy on master push 2026-08-23 11:10:28 +03:30
b779a57cba remove ./public from dockerfile. 2026-08-21 14:58:21 +03:30
e6c236f1a0 package-locks and prisma migration update. 2026-08-20 11:30:14 +03:30
28 changed files with 822 additions and 678 deletions

View File

@@ -10,17 +10,16 @@ alwaysApply: false
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first details type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens todays strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields.
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add.
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add.
2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Detail type may differ from appointment purpose (purpose only defaults new details).
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
@@ -201,7 +201,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF breaks Alpine entrypoints ("No such file or directory").
*.sh text eol=lf

View File

@@ -1,30 +1,32 @@
# Build backend/frontend images, push to Gitea Container Registry, deploy with pull-only compose.
# Staging: build backend/frontend images, push to Gitea Container Registry, deploy on self-hosted runner.
#
# Repository Variables (Settings → Actions → Variables) — non-secret:
# REGISTRY_HOST e.g. 178.131.50.201:3000 (no http/https)
# REGISTRY_OWNER Gitea user or org that owns the packages (same as image namespace)
# Triggers: push to master/main, or manual workflow_dispatch.
#
# Repository Variables (Settings → Actions → Variables):
# REGISTRY_HOST Docker registry host:port (no http/https). Same PC as Gitea → 127.0.0.1:3000
# REGISTRY_OWNER Gitea user or org that owns the packages
# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash)
# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets
#
# Optional:
# STAGING_HTTP_PORT host port for nginx (default 8088)
# CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000
#
# Same Windows PC runs Gitea + runner + deploy:
# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host)
# REGISTRY_HOST → host.docker.internal:3000 (docker commands run inside Docker Desktop VM)
# Gitea app.ini ROOT_URL → http://host.docker.internal:3000/
# PUBLIC_BASE_URL → public IP:8088 (browser URL for staging app)
#
# Add host.docker.internal:3000 to Docker Desktop insecure-registries.
#
# Repository Secrets (Settings → Actions → Secrets):
# REGISTRY_USERNAME Gitea username for docker login
# REGISTRY_PASSWORD Gitea access token (packages:read/write) or account password
#
# HTTP registry (typical self-hosted Gitea): Docker defaults to HTTPS. If login/push fails with
# "server gave HTTP response to HTTPS client", add REGISTRY_HOST (e.g. 192.168.1.100:3000) to the
# Docker daemon "insecure-registries" on the RUNNER machine, then restart Docker (Docker Desktop
# → Settings → Docker Engine → JSON → "insecure-registries": ["host:port"]).
# Docker on runner: add registry hosts to insecure-registries, e.g. ["127.0.0.1:3000","178.131.50.201:3000"]
#
# Optional:
# STAGING_HTTP_PORT host port for nginx (default 8088)
#
# Required for deploy job (absolute path on the runner host):
# DEPLOY_SECRETS_DIR folder containing database.staging.env + backend.staging.env
#
# Runner: self-hosted with Docker. Default shell is powershell (Windows act_runner often has no WSL bash).
# For a Linux runner, change defaults.run.shell to bash and restore bash syntax if needed.
#
# We do NOT use gitea.com/actions/checkout — many restricted networks cannot reach gitea.com.
# Checkout is a plain git clone from the same Gitea host.
# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner).
name: Registry — build, push, deploy
@@ -38,189 +40,149 @@ defaults:
shell: powershell
jobs:
temp-success:
runs-on: self-hosted
build-and-push:
runs-on: windows
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps:
- name: Temporary placeholder (always success)
- name: Checkout (clone from this Gitea — no gitea.com)
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Temporary workflow is active."
Write-Host "Trigger: ${{ github.event_name }}"
Write-Host "Branch: ${{ github.ref_name }}"
Write-Host "Commit: ${{ github.sha }}"
Write-Host "Production build/push/deploy steps are intentionally commented."
exit 0
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$Branch = "${{ github.ref_name }}"
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
# ---------------------------------------------------------------------------
# Production pipeline is temporarily disabled.
# Uncomment these jobs after split-DNS / registry reachability is fixed.
# ---------------------------------------------------------------------------
#
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# - name: Image tag and registry prefix
# - name: Log in to container registry
# - name: Build and push backend
# - name: Build and push frontend
#
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# - name: Write deploy.registry.env and validate secrets path
# - name: Log in to container registry (for pull)
# - name: Pull and start stack
- name: Image tag and registry prefix
id: meta
run: |
$ErrorActionPreference = 'Stop'
$short = (git rev-parse --short HEAD).Trim()
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
- name: Log in to container registry
run: |
$ErrorActionPreference = 'Stop'
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push backend
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
docker build `
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
./backend
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
####SAMPLE
# name: Registry — build, push, deploy
- name: Build and push frontend
env:
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
$base = $env:PUBLIC_BASE_URL
docker build `
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
./frontend
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
# on:
# push:
# branches: [main, master]
# workflow_dispatch:
deploy:
needs: build-and-push
runs-on: windows
steps:
- name: Checkout (shallow clone from this Gitea — no gitea.com)
run: |
$ErrorActionPreference = 'Stop'
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$Branch = "${{ github.ref_name }}"
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
# defaults:
# run:
# shell: powershell
- name: Write deploy.registry.env and validate secrets path
run: |
$ErrorActionPreference = 'Stop'
$SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
if ([string]::IsNullOrWhiteSpace($SD)) {
Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
Write-Host "where database.staging.env and backend.staging.env live (not in git)."
exit 1
}
if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
exit 1
}
if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
exit 1
}
$stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' }
$imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
$lines = @(
"REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
"IMAGE_TAG=$imageTag",
"STAGING_HTTP_PORT=$stagingPort",
"DEPLOY_SECRETS_DIR=$SD"
)
Set-Location infrastructure
$lines | Set-Content -Path deploy.registry.env -Encoding utf8
# jobs:
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Image tag and registry prefix
# id: meta
# run: |
# $ErrorActionPreference = 'Stop'
# $short = (git rev-parse --short HEAD).Trim()
# $utf8 = New-Object System.Text.UTF8Encoding $false
# [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
# $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
# [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
# - name: Log in to container registry
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Build and push backend
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# docker build `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
# ./backend
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
# - name: Build and push frontend
# env:
# PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# $base = $env:PUBLIC_BASE_URL
# docker build `
# --build-arg "NEXT_PUBLIC_API_URL=$base/api" `
# --build-arg "NEXT_PUBLIC_APP_URL=$base" `
# --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
# ./frontend
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Write deploy.registry.env and validate secrets path
# run: |
# $ErrorActionPreference = 'Stop'
# $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($SD)) {
# Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
# Write-Host "where database.staging.env and backend.staging.env live (not in git)."
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
# exit 1
# }
# $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' }
# $imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
# $lines = @(
# "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
# "IMAGE_TAG=$imageTag",
# "STAGING_HTTP_PORT=$stagingPort",
# "DEPLOY_SECRETS_DIR=$SD"
# )
# Set-Location infrastructure
# $lines | Set-Content -Path deploy.registry.env -Encoding utf8
# - name: Log in to container registry (for pull)
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Pull and start stack
# run: |
# $ErrorActionPreference = 'Stop'
# Set-Location infrastructure
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
- name: Log in to container registry (for pull)
run: |
$ErrorActionPreference = 'Stop'
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Pull and start stack
run: |
$ErrorActionPreference = 'Stop'
Set-Location infrastructure
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d

View File

@@ -10,7 +10,7 @@ Dental clinic ↔ lab platform (monorepo):
|------|--------|
| `backend/` | NestJS, Prisma, PostgreSQL |
| `frontend/` | Next.js 16, React 19, next-intl, Tailwind |
| `infrastructure/` | Docker, nginx, deploy scripts |
| `infrastructure/` | Docker, nginx, deploy scripts — prod: `DEPLOY.md`, staging: `STAGING-DEPLOY.md` |
**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`.
@@ -44,8 +44,8 @@ frontend/src/
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
**Treatment edit / details (quick ref):**
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment cards patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
@@ -53,9 +53,8 @@ frontend/src/
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).

View File

@@ -42,103 +42,13 @@ SSL is issued automatically via **Certbot** (`scripts/init-letsencrypt.sh`). Ngi
---
## Deploy on your own server (Docker + Gitea)
## Staging deploy (Gitea Actions + Windows)
High level: **build container images → push to a registry → server pulls images and runs Compose**. Optionally **Gitea Actions** automates that on every merge to `main` / `master`.
**Full guide:** [`infrastructure/STAGING-DEPLOY.md`](infrastructure/STAGING-DEPLOY.md)
### 1. One-time server preparation
Merge or push to **`master`** → Gitea Actions builds images → deploys to `http://<host>:8088`.
1. Install **Docker** and **Docker Compose** on the server.
2. Run **Gitea** with the **container registry** enabled (same host/port you use for `docker login`, e.g. `178.131.50.201:3000`).
3. Copy the repo (or deploy only `infrastructure/` + secrets). You need at least:
- `infrastructure/docker-compose.registry.yml`
- `infrastructure/nginx/` configs referenced by that compose file
- `infrastructure/database/init.sql` if used by your Postgres service
4. **Secrets on the server** (never commit real values):
- Copy `infrastructure/database.staging.env.example`**`database.staging.env`** (Postgres user/password/db).
- Copy `infrastructure/backend.staging.env.example`**`backend.staging.env`** (e.g. `DATABASE_URL`, JWT, pointing at the compose Postgres service name).
- Put both files in one directory on the server, e.g. `/opt/dyolink/secrets/`.
5. **Registry login from the server** (same credentials you use for `docker push`):
```bash
docker login <registry-host>:<port> -u <user>
```
For HTTP registries, Docker may require **`insecure-registries`** on the daemon.
### 2. Manual deploy (build images elsewhere, run on server)
On your **dev machine** (after successful local builds):
```powershell
$REG = "<registry-host>:<port>"
$OWN = "<registry-owner>"
$TAG = "manual"
docker build -t "${REG}/${OWN}/dyolink-backend:${TAG}" -t "${REG}/${OWN}/dyolink-backend:latest" ./backend
docker build `
--build-arg NEXT_PUBLIC_API_URL="http://<your-public-ip>:<nginx-port>/api" `
--build-arg NEXT_PUBLIC_APP_URL="http://<your-public-ip>:<nginx-port>" `
--build-arg NEXT_PUBLIC_APP_NAME="Dyolink" `
-t "${REG}/${OWN}/dyolink-frontend:${TAG}" `
-t "${REG}/${OWN}/dyolink-frontend:latest" `
./frontend
docker push "${REG}/${OWN}/dyolink-backend:${TAG}"
docker push "${REG}/${OWN}/dyolink-backend:latest"
docker push "${REG}/${OWN}/dyolink-frontend:${TAG}"
docker push "${REG}/${OWN}/dyolink-frontend:latest"
```
On the **server**, from `infrastructure/`:
1. Create **`deploy.registry.env`** (see `infrastructure/deploy.registry.env.example`):
- `REGISTRY_PREFIX=<host>:<port>/<owner>` (no `http://`, no trailing slash)
- `IMAGE_TAG=latest` or the tag you pushed
- `STAGING_HTTP_PORT=<host port>` (e.g. `8088` — browser uses `http://<ip>:8088`)
2. Set **`DEPLOY_SECRETS_DIR`** to the absolute path of the folder containing `database.staging.env` and `backend.staging.env` (you can export it in the shell or add it to `deploy.registry.env` if your Compose setup expects it).
3. Pull and start:
```bash
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
```
The backend container runs **`prisma migrate deploy`** on startup (via entrypoint) when `NODE_ENV=production`, so schema updates apply after you deploy a new image that includes new migrations.
### 3. Automatic deploy (Gitea Actions)
Workflow file: **`.gitea/workflows/registry-build-deploy.yml`**.
**Requirements:**
- **Gitea Actions** enabled for the repository.
- A **self-hosted runner** (with Docker) registered to Gitea — the workflow uses `runs-on: self-hosted`.
- **Windows runners:** the workflow uses **PowerShell** (not Bash). Giteas runner was failing with `execvpe(/bin/bash) failed` when Bash was routed through WSL without a real `/bin/bash`. If your runner is **Linux**, switch `.gitea/workflows/registry-build-deploy.yml` to `defaults.run.shell: bash` and use Bash syntax instead.
- **Repository → Actions → Variables** (examples):
- `REGISTRY_HOST` — e.g. `178.131.50.201:3000`
- `REGISTRY_OWNER` — image namespace (same as Docker image path after the host), e.g. `admin`
- `PUBLIC_BASE_URL` — URL users open in the browser, e.g. `http://178.131.50.201:8088` (no trailing slash)
- `DEPLOY_SECRETS_DIR` — **absolute path on the runner machine** to the folder containing `database.staging.env` and `backend.staging.env`
- Optional: `STAGING_HTTP_PORT` (defaults to `8088`)
- **Repository → Actions → Secrets:**
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD` — access token with package read/write (or equivalent)
**Trigger:** push to **`main`** or **`master`**, or run the workflow manually (**workflow_dispatch**).
The pipeline clones from your Gitea instance, builds and pushes backend/frontend images, then on the runner runs **`docker compose pull`** and **`up -d`** using `infrastructure/docker-compose.registry.yml`.
Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/registry-build-deploy.yml)
---
@@ -148,5 +58,6 @@ The pipeline clones from your Gitea instance, builds and pushes backend/frontend
|------|------|
| `backend/Dockerfile` | API image |
| `frontend/Dockerfile` | Web image |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |

View File

@@ -47,7 +47,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "lab_cases_dueDate_idx";

View File

@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
const treatmentInclude = {
patient: {
select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
},
details: {
orderBy: [{ sortOrder: 'asc' as const }],
@@ -1366,8 +1366,6 @@ export class TreatmentsService {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
};
details: Array<{
id: string;
@@ -1428,8 +1426,6 @@ export class TreatmentsService {
firstName: treatment.patient.firstName,
lastName: treatment.patient.lastName,
isWalkIn: treatment.patient.isWalkIn,
mobile: treatment.patient.mobile ?? null,
email: treatment.patient.email ?? null,
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),

View File

@@ -41,19 +41,19 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
USER dyolink
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]

View File

@@ -683,9 +683,7 @@
"selectedPatient": "Selected patient",
"purposeLabel": "Purpose:",
"loadingAppointments": "Loading appointments…",
"selectDayWithAppointment": "Search for a patient or select a visit from the day strip.",
"noTreatmentFoundTitle": "No treatment found",
"noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.",
"selectDayWithAppointment": "Select a day with at least one appointment.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
"errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.",
@@ -709,8 +707,6 @@
"walkIn": "Walk-in",
"newTreatment": "New treatment",
"newTreatmentPatientPrompt": "Who is this visit for?",
"newTreatmentUseCurrent": "Use {name}",
"newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.",
"walkInPickerHint": "No named patient — always available, no search needed.",
"errorCreateTreatment": "Could not create treatment.",
"deleteEmptyTreatment": "Delete empty treatment",
@@ -721,7 +717,6 @@
"detailsTitle": "Treatment details",
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",

View File

@@ -684,9 +684,7 @@
"selectedPatient": "بیمار انتخاب شده",
"purposeLabel": "هدف:",
"loadingAppointments": "در حال بارگذاری نوبت‌ها...",
"selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
"noTreatmentFoundTitle": "درمانی یافت نشد",
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. می‌توانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
"confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.",
@@ -710,8 +708,6 @@
"walkIn": "بدون نوبت (مراجع)",
"newTreatment": "درمان جدید",
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
"newTreatmentUseCurrent": "استفاده از {name}",
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
"walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
"deleteEmptyTreatment": "حذف درمان خالی",
@@ -722,7 +718,6 @@
"detailsTitle": "جزئیات درمان",
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",

View File

@@ -683,9 +683,7 @@
"selectedPatient": "Geselecteerde patiënt",
"purposeLabel": "Doel:",
"loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.",
"noTreatmentFoundTitle": "Geen behandeling gevonden",
"noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
@@ -709,8 +707,6 @@
"walkIn": "Inloop",
"newTreatment": "Nieuwe behandeling",
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
"newTreatmentUseCurrent": "{name} gebruiken",
"newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.",
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
"deleteEmptyTreatment": "Lege behandeling verwijderen",
@@ -721,7 +717,6 @@
"detailsTitle": "Behandeldetails",
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",

View File

@@ -81,7 +81,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -2300,7 +2299,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2373,7 +2371,6 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@@ -2899,7 +2896,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3203,7 +3199,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -3281,7 +3276,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4083,7 +4077,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4269,7 +4262,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6318,6 +6310,17 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
@@ -6774,7 +6777,6 @@
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6784,7 +6786,6 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6797,7 +6798,6 @@
"resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -6813,8 +6813,7 @@
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-qr-code": {
"version": "2.2.0",
@@ -6834,7 +6833,6 @@
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -6887,8 +6885,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -7660,7 +7657,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7823,7 +7819,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8189,7 +8184,6 @@
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -12,10 +12,3 @@ export type DayStripItem = {
subtitle: string;
canDelete?: boolean;
};
/** Unscheduled card banner: first lines type only. Empty / missing type → no color. */
export function unscheduledStripColorCode(
details: readonly { treatmentType?: string | null }[],
): string {
return details[0]?.treatmentType?.trim() ?? '';
}

View File

@@ -1,60 +1,73 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
/** Chips under lab search — last destinations this clinic actually sent a case to. */
export const MAX_RECENT_LABS = 3;
export type LabDispatchDefaults = {
lastLabId: string | null;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
}
function uniqueIds(ids: string[]): string[] {
const out: string[] = [];
for (const id of ids) {
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return [];
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return [];
const parsed = JSON.parse(raw) as {
recentLabIds?: unknown;
lastLabId?: unknown;
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
return {
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
};
const fromList = Array.isArray(parsed.recentLabIds)
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
: [];
// Older builds stored a single lastLabId used as an auto-selected default.
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
} catch {
return [];
return EMPTY;
}
}
function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
storageKey(clinicOrganizationId),
JSON.stringify({ recentLabIds }),
);
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
} catch {
// Ignore quota / private-mode failures.
}
}
/** Record a lab after a successful send — suggestion chips only, never a form default. */
export function rememberRecentLab(
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined,
labId: string,
prosthesisTypeCode: string,
): void {
if (!clinicOrganizationId || !labId) return;
writeRecentLabIds(
clinicOrganizationId,
uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
);
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, {
...current,
lastLabId: labId,
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
}

View File

@@ -24,12 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px';
/**
* Centers a mark on the inline-end edge of a tooth column (between this tooth
* and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL.
*/
const EDGE_MARK_ANCHOR =
'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center';
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
@@ -291,42 +285,40 @@ export function FdiToothChart({
/>
) : null}
{renderEdge ? (
<div className={EDGE_MARK_ANCHOR}>
{linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)}
</div>
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)
) : null}
</div>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,6 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -28,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -41,6 +48,7 @@ interface LabCasesDispatchPanelProps {
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
@@ -116,6 +124,7 @@ export function LabCasesDispatchPanel({
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +138,7 @@ export function LabCasesDispatchPanel({
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false,
onInviteLab,
sendBusyId,
@@ -140,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -157,14 +168,7 @@ export function LabCasesDispatchPanel({
const activeLabCase =
labCaseForActiveDetail ??
(activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -204,7 +208,6 @@ export function LabCasesDispatchPanel({
useEffect(() => {
setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -223,6 +226,45 @@ export function LabCasesDispatchPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
@@ -245,11 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -261,11 +317,13 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -405,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => handleSelectOrganization(o)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -554,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>

View File

@@ -0,0 +1,20 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -4,45 +4,19 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import { formatMobileForDisplay } from '@/lib/phone';
import type { Patient } from '@/types/patient';
const pickerChoiceClass =
'w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45';
interface NewTreatmentPatientPickerProps {
creating?: boolean;
currentPatient?: {
id: string;
displayName: string;
mobile?: string | null;
email?: string | null;
} | null;
onSelectWalkIn: () => void | Promise<void>;
onSelectPatient: (patient: Patient) => void | Promise<void>;
onSelectCurrentPatient?: () => void | Promise<void>;
onCancel: () => void;
}
function contactLine(
patient: { mobile?: string | null; email?: string | null },
fallback: string,
): string {
const mobile = patient.mobile?.trim()
? formatMobileForDisplay(patient.mobile.trim())
: '';
if (mobile) return mobile;
const email = patient.email?.trim();
if (email) return email;
return fallback;
}
export function NewTreatmentPatientPicker({
creating = false,
currentPatient = null,
onSelectWalkIn,
onSelectPatient,
onSelectCurrentPatient,
onCancel,
}: NewTreatmentPatientPickerProps) {
const t = useTranslations('treatment');
@@ -57,25 +31,11 @@ export function NewTreatmentPatientPicker({
type="button"
disabled={creating}
onClick={() => void onSelectWalkIn()}
className={pickerChoiceClass}
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
>
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
</button>
{currentPatient && onSelectCurrentPatient ? (
<button
type="button"
disabled={creating}
onClick={() => void onSelectCurrentPatient()}
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
className={pickerChoiceClass}
>
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
<p className="text-xs text-text-muted mt-0.5 break-words">
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
</p>
</button>
) : null}
<PatientSearchCombobox
search={search}
onSearchChange={setSearch}

View File

@@ -203,12 +203,12 @@ export function TreatmentDetailsEditor({
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown
label={t('treatmentType')}
@@ -268,8 +268,6 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : showFields && !activeDetail ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>
) : null}
</div>
);

View File

@@ -15,6 +15,7 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
@@ -32,7 +33,7 @@ import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
import type { DayStripItem } from '@/components/treatment/dayStrip';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
@@ -56,11 +57,7 @@ import {
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -206,7 +203,6 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
patientId: record.patientId,
patientFirstName: record.patient.firstName,
patientLastName: record.patient.lastName,
patientMobile: record.patient.mobile,
providerUserId: record.providerUserId,
startAt: record.startAt,
endAt: record.endAt,
@@ -387,12 +383,10 @@ export function TreatmentWorkspace({
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
const [searchedPatient, setSearchedPatient] = useState<
(Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
mobile?: string | null;
email?: string | null;
}) | null
>(null);
const [searchedPatient, setSearchedPatient] = useState<Pick<
Patient,
'id' | 'firstName' | 'lastName'
> | null>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false);
@@ -505,8 +499,6 @@ export function TreatmentWorkspace({
id: selectedAppointment.patientId,
firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName,
mobile: selectedAppointment.patientMobile ?? null,
email: null,
purpose: selectedAppointment.purpose,
isWalkIn: false,
};
@@ -517,8 +509,6 @@ export function TreatmentWorkspace({
id: selectedStandalone.patientId,
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
purpose: selectedStandalone.details[0]?.treatmentType,
isWalkIn,
};
@@ -528,9 +518,7 @@ export function TreatmentWorkspace({
id: searchedPatient.id,
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
mobile: searchedPatient.mobile ?? null,
email: searchedPatient.email ?? null,
purpose: undefined,
purpose: undefined as string | undefined,
isWalkIn: false,
};
}
@@ -541,23 +529,6 @@ export function TreatmentWorkspace({
const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const namedActivePatient =
activePatient && !activePatient.isWalkIn && activePatient.id
? {
id: activePatient.id,
displayName: activePatientName ?? '',
mobile: activePatient.mobile ?? null,
email: activePatient.email ?? null,
}
: null;
const searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext;
const showSearchedPatientLoading =
searchedWithoutLiveVisit && (patientSearchBusy || historyLoading);
const showNoTreatmentFound =
searchedWithoutLiveVisit &&
!patientSearchBusy &&
!historyLoading &&
history.length === 0;
const unreadUpdatesCount = unreadLabCases.length;
const otherPatientsUnreadCount = useMemo(
@@ -629,8 +600,8 @@ export function TreatmentWorkspace({
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = unscheduledStripColorCode(current.details);
const nextColor = unscheduledStripColorCode(nextDetails);
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
@@ -661,7 +632,8 @@ export function TreatmentWorkspace({
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const colorCode = unscheduledStripColorCode(sourceDetails);
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return {
kind: 'unscheduled' as const,
id: tr.id,
@@ -763,7 +735,7 @@ export function TreatmentWorkspace({
mapped.length > 0
? mapped
: options?.seedBlankIfEmpty
? [newDetail()]
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
@@ -778,7 +750,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
}, [treatmentCatalog]);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
@@ -831,7 +803,6 @@ export function TreatmentWorkspace({
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
@@ -939,11 +910,11 @@ export function TreatmentWorkspace({
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
orgsResponse.data.some((o) => o.id === id && o.active),
);
if (recentIds.length > 0) {
setRecentOrganizationIds(recentIds);
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
);
}
} catch (error: unknown) {
if (!cancelled) {
@@ -1578,51 +1549,24 @@ export function TreatmentWorkspace({
return;
}
const ok = await flushDraftSave();
if (!ok) return;
setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
});
try {
const stripAppointment = appointments.find((row) => row.patientId === patient.id);
const stripStandalone = standaloneTreatments.find(
(row) => row.patientId === patient.id && !row.patient?.isWalkIn,
);
if (stripAppointment || stripStandalone) {
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(true);
if (stripAppointment) {
setSelectedAppointmentId(stripAppointment.id);
setSelectedStandaloneId(null);
} else if (stripStandalone) {
setSelectedAppointmentId(null);
setSelectedStandaloneId(stripStandalone.id);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
return;
}
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
mobile: patient.mobile,
email: patient.email,
});
resetToLiveContext();
setNewTreatmentPickerOpen(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectionLocked(true);
setHistory([]);
setHistoryLoading(true);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
await loadTreatmentIntoWorkspace(latest);
}
await refreshHistory(patient.id);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null);
@@ -1636,11 +1580,8 @@ export function TreatmentWorkspace({
selectedStandalone?.patientId,
searchedPatient?.id,
hasLiveContext,
flushDraftSave,
appointments,
standaloneTreatments,
resetToLiveContext,
loadTreatmentIntoWorkspace,
refreshHistory,
showError,
t,
tErrors,
@@ -1942,9 +1883,7 @@ export function TreatmentWorkspace({
if (idx < 0) return;
const target = details[idx];
if (!target || isDetailLocked(target)) return;
if (!window.confirm(
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
@@ -2020,9 +1959,14 @@ export function TreatmentWorkspace({
return;
}
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
@@ -2038,9 +1982,11 @@ export function TreatmentWorkspace({
}, [
activeDetailId,
canEditTreatmentForDay,
currentOrganization?.id,
details,
labCaseDrafts,
labDependentCodes,
orgs,
persistDraft,
persistLabCases,
selectedAppointment,
@@ -2160,8 +2106,8 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
rememberLastLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
@@ -2202,24 +2148,11 @@ export function TreatmentWorkspace({
return (
<div className="space-y-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="min-w-0 shrink-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
) : null}
</div>
<div className="min-w-0 flex-1">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
</div>
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
@@ -2250,6 +2183,15 @@ export function TreatmentWorkspace({
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
<div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="space-y-2">
<Button
@@ -2264,16 +2206,10 @@ export function TreatmentWorkspace({
{newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker
creating={creatingStandalone}
currentPatient={namedActivePatient}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id })
}
onSelectCurrentPatient={
namedActivePatient
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
: undefined
}
onCancel={() => setNewTreatmentPickerOpen(false)}
/>
) : null}
@@ -2281,14 +2217,8 @@ export function TreatmentWorkspace({
) : null}
{activePatient ? (
<div
className={`space-y-0.5 ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{t('selectedPatient')}
</p>
<div className="space-y-0.5 border-t border-border/60 pt-3">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
{activePatient.purpose ? (
<p className="text-[11px] text-text-secondary">
@@ -2300,11 +2230,7 @@ export function TreatmentWorkspace({
) : null}
</div>
) : (
<p
className={`text-sm text-text-muted ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</p>
)}
@@ -2407,24 +2333,6 @@ export function TreatmentWorkspace({
</div>
<div className="space-y-3 min-w-0 w-full">
{showSearchedPatientLoading ? (
<div className="surface-card p-6">
<p className="text-sm text-text-muted">{t('loading')}</p>
</div>
) : showNoTreatmentFound ? (
<div className="surface-card w-full p-6 space-y-3">
<h2 className="text-lg font-semibold text-text-primary">
{t('noTreatmentFoundTitle')}
</h2>
<p className="text-sm text-text-secondary">
{t('noTreatmentFoundBody', {
name: activePatientName ?? '',
action: t('newTreatment'),
})}
</p>
</div>
) : (
<>
<TreatmentDetailsEditor
details={details}
activeDetailId={activeDetailId}
@@ -2438,14 +2346,9 @@ export function TreatmentWorkspace({
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const seedFromAppointment =
details.length === 0 && selectedAppointment
? defaultTreatmentTypeForAppointment(
selectedAppointment.purpose,
treatmentCatalog,
)
: undefined;
const next = newDetail(seedFromAppointment);
const next = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
@@ -2684,6 +2587,7 @@ export function TreatmentWorkspace({
{entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
@@ -2691,6 +2595,7 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2708,21 +2613,33 @@ export function TreatmentWorkspace({
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
) : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</>
)}
</div>
</div>
</div>

View File

@@ -38,7 +38,6 @@ export interface TreatmentAppointment {
patientId: string;
patientFirstName: string;
patientLastName: string;
patientMobile?: string | null;
providerUserId: string;
startAt: string;
endAt: string;
@@ -140,8 +139,6 @@ export interface PastTreatment {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
} | null;
details: PastTreatmentDetail[];
labCases: PastLabCase[];

View File

@@ -0,0 +1,316 @@
# Dyolink — Staging deploy (Gitea + Windows)
Automatic staging on a **self-hosted Gitea** machine: merge (or push) to **`master`** → build Docker images → push to Gitea Container Registry → deploy on the same host.
**Example:** Gitea at `http://178.131.50.201:3000`, staging app at `http://178.131.50.201:8088`.
Production (`nudentic.ir` on Linux, tag-based releases) is documented in [`DEPLOY.md`](DEPLOY.md).
---
## Architecture
```
Push / merge to master
Gitea Actions (self-hosted act_runner on Windows)
Build backend + frontend → push to Gitea registry
docker compose pull + up -d (docker-compose.registry.yml)
http://<host-ip>:8088 → nginx → frontend / backend → postgres
```
| Service | Image source |
|----------|---------------------------------------------------|
| postgres | `postgres:15-alpine` (pulled from Docker Hub) |
| backend | `<REGISTRY_HOST>/<owner>/dyolink-backend:<sha>` |
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
| nginx | `nginx:alpine` |
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`.
---
## Triggers
| Event | Staging deploy? |
|-------|-----------------|
| Push to `master` | Yes |
| PR merged into `master` | Yes (merge = push to `master`) |
| Push to other branches only | No |
| Git tag (e.g. `v1.0.0`) | No — reserved for production later |
| Manual | Gitea → Actions → **Registry — build, push, deploy** → Run workflow |
Workflow file: [`.gitea/workflows/registry-build-deploy.yml`](../.gitea/workflows/registry-build-deploy.yml)
---
## One-time setup (Windows host)
### 1. Docker Desktop
- Install and keep **Docker Desktop running** during builds/deploys.
- **Settings → Docker Engine** — allow HTTP registry:
```json
{
"insecure-registries": [
"host.docker.internal:3000",
"127.0.0.1:3000",
"178.131.50.201:3000",
"192.168.1.100:3000"
]
}
```
Apply & restart Docker Desktop.
**Why `host.docker.internal`?** Docker Desktop runs in a Linux VM. `docker login` runs from that VM — `127.0.0.1:3000` is the VM, not Gitea on Windows.
### 2b. Gitea `app.ini` — match registry URL
Edit `C:\gitea\custom\conf\app.ini`:
```ini
[server]
ROOT_URL = http://host.docker.internal:3000/
```
Restart Gitea. Gitea uses `ROOT_URL` for Docker registry auth redirects.
### 2c. Gitea Actions runner
Download [act_runner](https://gitea.com/gitea/act_runner/releases) → e.g. `C:\gitea-runner\act_runner.exe`.
**Important — runner level:** the registration token decides which repos can use the runner.
| Token from | Runner level | Works for `admin/dyolink`? |
|------------|--------------|----------------------------|
| **Site Administration → Actions → Runners** | Instance (global) | Yes (recommended) |
| **Repo → Settings → Actions → Runners** | Repository | Yes |
| **User profile → Settings → Actions → Runners** | Individual | Often **no** — jobs stay queued with “no matching online runner” |
If your runner shows **Type: Individual** in Gitea but jobs never start, delete it and re-register with an **instance** or **repository** token (table above).
Register (use **localhost** when Gitea runs on the same PC — public IP often fails locally):
```powershell
cd C:\gitea-runner
# Stop daemon first (Ctrl+C) if running
.\act_runner.exe register `
--instance "http://127.0.0.1:3000" `
--token "<token-from-Site-Admin-OR-repo-Runners-page>" `
--name "windows-staging" `
--labels "windows:host"
```
Start (leave running, or install as a Windows service later):
```powershell
.\act_runner.exe daemon
```
**Verify:** open **your repo****Settings → Actions → Runners**`windows-staging` must appear here as **Idle/Online** (not only under user settings).
**Requires:** `git` on PATH (for workflow checkout step).
### 3. Secret env files (not in git)
```powershell
New-Item -ItemType Directory -Force -Path "C:\dyolink\secrets"
```
Copy examples and edit:
- `infrastructure/database.staging.env.example``C:\dyolink\secrets\database.staging.env`
- `infrastructure/backend.staging.env.example``C:\dyolink\secrets\backend.staging.env`
Rules:
- `DATABASE_URL` password must match `POSTGRES_PASSWORD`.
- `FRONTEND_URL` must match `PUBLIC_BASE_URL` (e.g. `http://178.131.50.201:8088`).
- Replace JWT secrets with long random values (not `CHANGE_ME`).
### 4. Gitea repository Variables
**Settings → Actions → Variables**
| Name | Example | Notes |
|------|---------|--------|
| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Also set Gitea `ROOT_URL` to match (see below). |
| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages |
| `PUBLIC_BASE_URL` | `http://178.131.50.201:8088` | How **users** open staging in a browser (public IP OK) |
| `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows |
| `CLONE_HOST` | `127.0.0.1:3000` | Git clone (runs on Windows host, not inside Docker VM) |
| `STAGING_HTTP_PORT` | `8088` | Optional (8088 is default) |
**Naming note:** Gitea rejects variable names starting with `GITEA_` or `GITHUB_`. Use `CLONE_HOST`, not `GITEA_CLONE_URL`.
### 5. Gitea repository Secrets
**Settings → Actions → Secrets**
| Name | Value |
|------|--------|
| `REGISTRY_USERNAME` | Gitea username |
| `REGISTRY_PASSWORD` | Gitea access token with **package read/write** |
Create token: profile → **Settings → Applications → Generate New Token**.
### 6. Firewall (once)
```powershell
New-NetFirewallRule -DisplayName "Dyolink Staging 8088" -Direction Inbound -Protocol TCP -LocalPort 8088 -Action Allow
```
### 7. External access on Windows + Docker Desktop (portproxy)
Gitea on **:3000** runs natively on Windows and is reachable from your Mac. Staging **:8088** runs in **Docker Desktop**`127.0.0.1:8088` works on the PC, but `http://<public-ip>:8088` from another machine may get **Empty reply from server** unless you forward the port.
Compose binds nginx to **127.0.0.1:8088** only. After deploy, run **once** in **PowerShell as Administrator**:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8088 connectaddress=127.0.0.1 connectport=8088
netsh interface portproxy show all
```
Verify on the server:
```powershell
curl http://127.0.0.1:8088/health
```
From your Mac:
```bash
curl http://178.131.50.201:8088/health
```
If the public IP still fails but LAN works, add **router port forward 8088** → Windows PC (same as Gitea **3000**).
To remove portproxy later:
```powershell
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8088
```
---
## Test the pipeline
### Before first run
- [ ] Docker Desktop running
- [ ] `act_runner.exe daemon` running
- [ ] All Variables + Secrets set (including `CLONE_HOST`)
- [ ] Secret env files exist under `DEPLOY_SECRETS_DIR`
- [ ] Workflow enabled on `master` (see repo)
### Option A — Manual workflow (safest first test)
1. Gitea → repo → **Actions**
2. **Registry — build, push, deploy****Run workflow** → branch `master`
3. Watch jobs: **build-and-push****deploy** (first run ~1530 min)
### Option B — Push to master
```bash
git push origin master
```
Or merge a PR into `master` — same result.
### Verify success
**On Windows (PowerShell):**
```powershell
docker ps
```
Expect: `dyolink_nginx_staging`, `dyolink_backend_staging`, `dyolink_frontend_staging`, `dyolink_postgres_staging`.
**From browser or another machine:**
```text
http://178.131.50.201:8088
```
**Health check:**
```powershell
curl http://178.131.50.201:8088/api/health
```
Expected: `{"status":"ok",...}`
**Gitea packages:** profile/org → **Packages** — should list `dyolink-backend` and `dyolink-frontend` after first build.
---
## Manual deploy (without CI)
Useful when debugging registry/compose without re-running the full workflow.
On the Windows host, from repo `infrastructure/`:
1. Create `deploy.registry.env` from [`deploy.registry.env.example`](deploy.registry.env.example)
2. Set `REGISTRY_PREFIX`, `IMAGE_TAG`, `STAGING_HTTP_PORT`, `DEPLOY_SECRETS_DIR`
3. `docker login 178.131.50.201:3000 -u <user>`
4. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend`
5. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d`
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `no matching online runner with label` | Runner **offline** → start `act_runner.exe daemon`. Or wrong **runner level** → re-register with token from **Site Administration → Actions → Runners** or **repo → Settings → Actions → Runners** (not user profile). Confirm runner appears on **repo** Runners page as Online. |
| Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` |
| Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` |
| `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. |
| `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). |
| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker |
| `Missing database.staging.env` | Check `DEPLOY_SECRETS_DIR` path and file names |
| `docker login` denied | Token needs package permissions; check username/secret |
| Git clone fails in workflow | Set `CLONE_HOST=127.0.0.1:3000` |
| Port 8088 unreachable from Mac / empty reply | Docker Desktop: run **portproxy** (§7). `127.0.0.1:8088/health` must work on Windows first. |
| Backend restart loop | JWT secrets still placeholder; fix `backend.staging.env` |
| Backend DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` |
| `dumb-init docker-entrypoint.sh: No such file or directory` | Windows CRLF in shell scripts — fixed in Dockerfiles (rebuild images). |
| `frontend is unhealthy` / deploy waits on frontend | Next.js `/` redirects to `/en` (3xx). Rebuild after healthcheck fix (accepts 2xx/3xx). |
**Logs:**
```powershell
docker logs dyolink_backend_staging --tail 50
docker logs dyolink_nginx_staging --tail 50
docker logs dyolink_frontend_staging --tail 50
```
---
## File reference
| Path | Role |
|------|------|
| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy |
| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) |
| `infrastructure/deploy.registry.env.example` | Manual deploy env template |
| `infrastructure/database.staging.env.example` | Postgres secrets template |
| `infrastructure/backend.staging.env.example` | API secrets template |
| `infrastructure/nginx/http-only.conf` | HTTP reverse proxy for staging |
---
## Production (later)
| Environment | Trigger | Host |
|-------------|---------|------|
| Staging | Push/merge to `master` | Windows + Gitea |
| Production | Git tag `v*.*.*` | Linux + `nudentic.ir` |
Production flow will use Docker Hub (or registry) + [`DEPLOY.md`](DEPLOY.md) — not yet wired to the same workflow.

View File

@@ -15,5 +15,8 @@ IMAGE_TAG=latest
STAGING_HTTP_PORT=8088
# Absolute path on the server where database.staging.env and backend.staging.env live.
# Use forward slashes on Windows. Same variable as Gitea Actions DEPLOY_SECRETS_DIR.
# DEPLOY_SECRETS_DIR=D:/dyolink/secrets
# Use forward slashes on Windows. Same path as Gitea Actions variable DEPLOY_SECRETS_DIR.
# DEPLOY_SECRETS_DIR=C:/dyolink/secrets
#
# Gitea Actions also needs CLONE_HOST=127.0.0.1:3000 when runner and Gitea share one Windows host.
# See STAGING-DEPLOY.md (do not use GITEA_* variable names — Gitea rejects them).

View File

@@ -75,7 +75,8 @@ services:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
@@ -85,10 +86,13 @@ services:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
- backend
- frontend
backend:
condition: service_healthy
frontend:
condition: service_healthy
ports:
- "${STAGING_HTTP_PORT:-8088}:80"
# Localhost only — Docker Desktop on Windows often breaks 0.0.0.0; use netsh portproxy for LAN/public (see STAGING-DEPLOY.md).
- "127.0.0.1:${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx

View File

@@ -77,7 +77,8 @@ services:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
@@ -87,10 +88,12 @@ services:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
- backend
- frontend
backend:
condition: service_healthy
frontend:
condition: service_healthy
ports:
- "${STAGING_HTTP_PORT:-8088}:80"
- "127.0.0.1:${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx

View File

@@ -1,15 +1,10 @@
# HTTP only — local dev and IP-based staging (no TLS).
# Use with: docker compose and map host port e.g. 8080:80 or 8088:80
#
# Dynamic proxy_pass via Docker DNS (127.0.0.11) so nginx starts even if
# backend/frontend containers are not up yet (static upstream blocks fail at boot).
upstream dyolink_backend {
server backend:3000;
keepalive 32;
}
upstream dyolink_frontend {
server frontend:3000;
keepalive 32;
}
resolver 127.0.0.11 valid=10s ipv6=off;
server {
listen 80;
@@ -19,7 +14,8 @@ server {
client_max_body_size 50M;
location / {
proxy_pass http://dyolink_frontend;
set $frontend_upstream http://frontend:3000;
proxy_pass $frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -33,7 +29,8 @@ server {
}
location /api {
proxy_pass http://dyolink_backend;
set $backend_upstream http://backend:3000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';