From de46da216fdf01e88260143142bc0812b17fa65a Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 17:49:38 +0330 Subject: [PATCH 1/9] bugfix: FDI tooth chart is now compatible with RTL direction. --- .../components/ui/treatment/FdiToothChart.tsx | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index d61b1b3..eba19a3 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -24,6 +24,12 @@ 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]; @@ -285,40 +291,42 @@ export function FdiToothChart({ /> ) : null} {renderEdge ? ( - linkInteractive ? ( - diff --git a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx deleted file mode 100644 index 71eddee..0000000 --- a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx +++ /dev/null @@ -1,20 +0,0 @@ -'use client'; - -import { AlertCircle } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -export function LabShipmentBlockedNotice() { - const t = useTranslations('treatment'); - - return ( -
-
- -
-

{t('labShipmentBlockedTitle')}

-

{t('labShipmentBlockedBody')}

-
-
-
- ); -} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index 884ffdc..dd7cedd 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps { canEdit: boolean; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; - onAddDetail: () => void; + onAddDetail: (initialType?: string) => void; onRemoveDetail?: (detailClientId: string) => void; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise; onRemoveAttachment?: (attachmentId: string) => void; @@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({ type="button" variant="primary" disabled={!canEdit || disabled} - onClick={onAddDetail} + onClick={() => onAddDetail()} fullWidth className="sm:w-auto shrink-0" > @@ -203,12 +203,12 @@ export function TreatmentDetailsEditor({ {stepper && activeDetail ?
{stepper}
: null} + {activeDetail && showMissingTeethLabBlock ? ( +

{t('labShipmentBlockedBody')}

+ ) : null} + {showFields && activeDetail ? (
- {showMissingTeethLabBlock && ( -

{t('labShipmentBlockedBody')}

- )} -
{footer}
: null}
+ ) : showFields && !activeDetail ? ( +
+ { + const nextType = e.target.value; + if (nextType) onAddDetail(nextType); + }} + disabled={!canEdit || disabled} + > + + {treatmentCatalog.map((entry, index) => ( + + ))} + + {chart ? ( +
+
+

{chartLockMessage}

+
+
{chart}
+
+ ) : chartLockMessage ? ( +

{chartLockMessage}

+ ) : null} +
) : null} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 4d60d18..26c9517 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -15,7 +15,6 @@ 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'; @@ -57,7 +56,11 @@ import { } from '@/components/treatment/toothSelectionGroups'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; -import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults'; +import { + loadRecentLabIds, + MAX_RECENT_LABS, + rememberRecentLab, +} 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'; @@ -735,7 +738,7 @@ export function TreatmentWorkspace({ mapped.length > 0 ? mapped : options?.seedBlankIfEmpty - ? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))] + ? [newDetail()] : []; setDetails(nextDetails); setActiveDetailId((prev) => { @@ -750,7 +753,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( @@ -803,6 +806,7 @@ export function TreatmentWorkspace({ useEffect(() => { setShowWholeTreatmentPlan(false); rangeAnchorRef.current = null; + setOrganizationSearch(''); const pending = pendingEntryStepRef.current; pendingEntryStepRef.current = null; setEntryStep(pending ?? 'treatment'); @@ -910,11 +914,11 @@ export function TreatmentWorkspace({ setLabDependentCodes( new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)), ); - 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), - ); + const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) => + orgsResponse.data.some((o) => o.id === id && o.active), + ); + if (recentIds.length > 0) { + setRecentOrganizationIds(recentIds); } } catch (error: unknown) { if (!cancelled) { @@ -1959,14 +1963,9 @@ 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]; @@ -1982,11 +1981,9 @@ export function TreatmentWorkspace({ }, [ activeDetailId, canEditTreatmentForDay, - currentOrganization?.id, details, labCaseDrafts, labDependentCodes, - orgs, persistDraft, persistLabCases, selectedAppointment, @@ -2106,8 +2103,8 @@ export function TreatmentWorkspace({ setRecentOrganizationIds((prev) => { const orgId = labCase.destinationOrganizationId!; - rememberLastLab(currentOrganization?.id, orgId); - return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10); + rememberRecentLab(currentOrganization?.id, orgId); + return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS); }); showSuccess(t('successCaseSent')); notifyTabBadgesChanged(); @@ -2345,10 +2342,8 @@ export function TreatmentWorkspace({ canEdit={canEdit} saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} - onAddDetail={() => { - const next = newDetail( - defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog), - ); + onAddDetail={(initialType) => { + const next = newDetail(initialType); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment'); @@ -2587,7 +2582,6 @@ export function TreatmentWorkspace({ {entryStep === 'lab' ? (
- {showLabShipmentBlocked ? : null} {showLabDispatchPanel ? ( { - 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 : (

{t('entryStepLabUnavailable')}

From 50725b7b3fc4c73add67b4c9c78abffeed1cd796 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 19:36:37 +0330 Subject: [PATCH 3/9] bugfix: no-detail treatment ui made some confusion. it is improved. at least to a degree. --- .cursor/rules/treatment-workspace.mdc | 6 ++-- .cursor/skills/treatment-workspace/SKILL.md | 2 +- AGENTS.md | 2 +- frontend/messages/en.json | 1 + frontend/messages/fa.json | 1 + frontend/messages/nl.json | 1 + frontend/src/components/treatment/dayStrip.ts | 7 ++++ .../ui/treatment/TreatmentDetailsEditor.tsx | 33 ++----------------- .../ui/treatment/TreatmentWorkspace.tsx | 17 +++++----- 9 files changed, 27 insertions(+), 43 deletions(-) diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 54c8891..3c3cc53 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -10,14 +10,14 @@ 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 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`. +- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s 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`. - **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). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. +- **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. - **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. Empty plan (`[]`) still shows type-first overlay (not a blank Add-only card). +- **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. - **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. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 426b784..dabe36d 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -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 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 card’s 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. +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 detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s 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`. **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 (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) diff --git a/AGENTS.md b/AGENTS.md index 3f2a573..bf72e37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ frontend/src/ **Treatment edit / details (quick ref):** - 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 appointment’s 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`). +- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s 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`). - 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 11–21 / 41–31 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. diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ba37dc5..2450819 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -717,6 +717,7 @@ "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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 755adda..179ba2d 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -718,6 +718,7 @@ "detailsTitle": "جزئیات درمان", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", + "confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 6aae598..167ac9e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -717,6 +717,7 @@ "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", diff --git a/frontend/src/components/treatment/dayStrip.ts b/frontend/src/components/treatment/dayStrip.ts index 41707a2..1e8b3b0 100644 --- a/frontend/src/components/treatment/dayStrip.ts +++ b/frontend/src/components/treatment/dayStrip.ts @@ -12,3 +12,10 @@ export type DayStripItem = { subtitle: string; canDelete?: boolean; }; + +/** Unscheduled card banner: first line’s type only. Empty / missing type → no color. */ +export function unscheduledStripColorCode( + details: readonly { treatmentType?: string | null }[], +): string { + return details[0]?.treatmentType?.trim() ?? ''; +} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index dd7cedd..89bcae0 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps { canEdit: boolean; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; uploadBusy: boolean; - onAddDetail: (initialType?: string) => void; + onAddDetail: () => void; onRemoveDetail?: (detailClientId: string) => void; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise; onRemoveAttachment?: (attachmentId: string) => void; @@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({ type="button" variant="primary" disabled={!canEdit || disabled} - onClick={() => onAddDetail()} + onClick={onAddDetail} fullWidth className="sm:w-auto shrink-0" > @@ -269,34 +269,7 @@ export function TreatmentDetailsEditor({ {footer ?
{footer}
: null}
) : showFields && !activeDetail ? ( -
- { - const nextType = e.target.value; - if (nextType) onAddDetail(nextType); - }} - disabled={!canEdit || disabled} - > - - {treatmentCatalog.map((entry, index) => ( - - ))} - - {chart ? ( -
-
-

{chartLockMessage}

-
-
{chart}
-
- ) : chartLockMessage ? ( -

{chartLockMessage}

- ) : null} -
+

{t('noDetails')}

) : null} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 26c9517..b893458 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -32,7 +32,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 type { DayStripItem } from '@/components/treatment/dayStrip'; +import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip'; import { areDetailsPersistable, defaultTreatmentTypeForAppointment, @@ -603,8 +603,8 @@ export function TreatmentWorkspace({ setStandaloneTreatments((prev) => { const current = prev.find((row) => row.id === selectedStandaloneId); if (!current) return prev; - const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; - const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; + const prevColor = unscheduledStripColorCode(current.details); + const nextColor = unscheduledStripColorCode(nextDetails); if ( prevColor === nextColor && areUnscheduledDetailsStripDeletable(current.details) === @@ -635,8 +635,7 @@ export function TreatmentWorkspace({ const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current ? details : tr.details; - const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim())); - const colorCode = typedDetail?.treatmentType?.trim() ?? ''; + const colorCode = unscheduledStripColorCode(sourceDetails); return { kind: 'unscheduled' as const, id: tr.id, @@ -1887,7 +1886,9 @@ export function TreatmentWorkspace({ if (idx < 0) return; const target = details[idx]; if (!target || isDetailLocked(target)) return; - if (!window.confirm(t('confirmRemoveDetail'))) return; + if (!window.confirm( + details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'), + )) return; const nextDetails = details.filter((d) => d.clientId !== detailClientId); const nextActive = @@ -2342,8 +2343,8 @@ export function TreatmentWorkspace({ canEdit={canEdit} saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} - onAddDetail={(initialType) => { - const next = newDetail(initialType); + onAddDetail={() => { + const next = newDetail(); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment'); From b1405b22b1b9e455f3bbca3a0dd75eb022bf6195 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 20:11:43 +0330 Subject: [PATCH 4/9] improvement: patient search moved to the top of treatment feature. --- .cursor/rules/treatment-workspace.mdc | 1 + .cursor/skills/treatment-workspace/SKILL.md | 4 +- AGENTS.md | 2 +- .../modules/treatments/treatments.service.ts | 6 +- frontend/messages/en.json | 4 +- frontend/messages/fa.json | 4 +- frontend/messages/nl.json | 4 +- .../treatment/NewTreatmentPatientPicker.tsx | 42 +++++++++- .../ui/treatment/TreatmentWorkspace.tsx | 84 ++++++++++++++----- frontend/src/types/treatment.ts | 3 + 10 files changed, 124 insertions(+), 30 deletions(-) diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 3c3cc53..e4c4412 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -14,6 +14,7 @@ alwaysApply: false - **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). **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. - **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`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index dabe36d..55e8974 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -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 detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s 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`. **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 (`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 from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s 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. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) @@ -201,7 +201,7 @@ Use shared `Checkbox` (not native ``) 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 → 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). +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 Walk-in, the current named patient card, or search). Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. diff --git a/AGENTS.md b/AGENTS.md index bf72e37..7ab5676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ 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. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. +- 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). **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 card’s 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 detail’s 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`). - 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. diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index f370657..21be004 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl const treatmentInclude = { patient: { - select: { id: true, firstName: true, lastName: true, isWalkIn: true }, + select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true }, }, details: { orderBy: [{ sortOrder: 'asc' as const }], @@ -1366,6 +1366,8 @@ export class TreatmentsService { firstName: string; lastName: string; isWalkIn: boolean; + mobile?: string | null; + email?: string | null; }; details: Array<{ id: string; @@ -1426,6 +1428,8 @@ 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)), diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2450819..7412cea 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -683,7 +683,7 @@ "selectedPatient": "Selected patient", "purposeLabel": "Purpose:", "loadingAppointments": "Loading appointments…", - "selectDayWithAppointment": "Select a day with at least one appointment.", + "selectDayWithAppointment": "Search for a patient or select a visit from the day strip.", "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.", @@ -707,6 +707,8 @@ "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", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 179ba2d..9316b36 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -684,7 +684,7 @@ "selectedPatient": "بیمار انتخاب شده", "purposeLabel": "هدف:", "loadingAppointments": "در حال بارگذاری نوبت‌ها...", - "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.", "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", @@ -708,6 +708,8 @@ "walkIn": "بدون نوبت (مراجع)", "newTreatment": "درمان جدید", "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "newTreatmentUseCurrent": "استفاده از {name}", + "newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.", "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.", "deleteEmptyTreatment": "حذف درمان خالی", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 167ac9e..46b7491 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -683,7 +683,7 @@ "selectedPatient": "Geselecteerde patiënt", "purposeLabel": "Doel:", "loadingAppointments": "Afspraken laden...", - "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", + "selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.", "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.", @@ -707,6 +707,8 @@ "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", diff --git a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx index 0be070d..f732bab 100644 --- a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx +++ b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx @@ -4,19 +4,45 @@ 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; onSelectPatient: (patient: Patient) => void | Promise; + onSelectCurrentPatient?: () => void | Promise; 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'); @@ -31,11 +57,25 @@ export function NewTreatmentPatientPicker({ type="button" disabled={creating} onClick={() => void onSelectWalkIn()} - 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" + className={pickerChoiceClass} >

{t('walkIn')}

{t('walkInPickerHint')}

+ {currentPatient && onSelectCurrentPatient ? ( + + ) : null} ('patient'); const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState(null); - const [searchedPatient, setSearchedPatient] = useState | null>(null); + const [searchedPatient, setSearchedPatient] = useState< + (Pick & { + mobile?: string | null; + email?: string | null; + }) | null + >(null); const [patientSearchBusy, setPatientSearchBusy] = useState(false); const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false); const [creatingStandalone, setCreatingStandalone] = useState(false); @@ -502,6 +505,8 @@ export function TreatmentWorkspace({ id: selectedAppointment.patientId, firstName: selectedAppointment.patientFirstName, lastName: selectedAppointment.patientLastName, + mobile: selectedAppointment.patientMobile ?? null, + email: null, purpose: selectedAppointment.purpose, isWalkIn: false, }; @@ -512,6 +517,8 @@ 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, }; @@ -521,7 +528,9 @@ export function TreatmentWorkspace({ id: searchedPatient.id, firstName: searchedPatient.firstName, lastName: searchedPatient.lastName, - purpose: undefined as string | undefined, + mobile: searchedPatient.mobile ?? null, + email: searchedPatient.email ?? null, + purpose: undefined, isWalkIn: false, }; } @@ -532,6 +541,15 @@ 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 unreadUpdatesCount = unreadLabCases.length; const otherPatientsUnreadCount = useMemo( @@ -1557,6 +1575,8 @@ export function TreatmentWorkspace({ id: patient.id, firstName: patient.firstName, lastName: patient.lastName, + mobile: patient.mobile, + email: patient.email, }); try { const response = await treatmentsApi.listPatientHistory(patient.id, 1); @@ -2146,11 +2166,24 @@ export function TreatmentWorkspace({ return (
-
-

{t('title')}

- {!canEdit ? ( -

{t('subtitleReadOnly')}

- ) : null} +
+
+

{t('title')}

+ {!canEdit ? ( +

{t('subtitleReadOnly')}

+ ) : null} +
+
+ +
- {canEdit && !isViewingPastDay ? (
From 8d34def0016ad1980f1725d2f02122867171d857 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 22 Aug 2026 20:33:11 +0330 Subject: [PATCH 6/9] improvement: the last flow improved a little. --- .cursor/skills/treatment-workspace/SKILL.md | 2 +- AGENTS.md | 2 +- .../src/components/ui/treatment/TreatmentWorkspace.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 932ab50..c0ba28b 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -45,7 +45,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet - 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 only seeds the **first** line of an empty draft — **Add detail** starts with an empty type. +- 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. - 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. diff --git a/AGENTS.md b/AGENTS.md index 4cb937a..ee313a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ frontend/src/ **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 draft; **Add detail** starts with an empty type. +- 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. - **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). diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 586a146..0d22bba 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -2438,7 +2438,14 @@ export function TreatmentWorkspace({ saveStatus={saveStatus} uploadBusy={uploadBusyDetailId === activeDetailId} onAddDetail={() => { - const next = newDetail(); + const seedFromAppointment = + details.length === 0 && selectedAppointment + ? defaultTreatmentTypeForAppointment( + selectedAppointment.purpose, + treatmentCatalog, + ) + : undefined; + const next = newDetail(seedFromAppointment); setDetails((prev) => [...prev, next]); setActiveDetailId(next.clientId); setEntryStep('treatment'); From 3a16f04c51788d4385fafdc0373c9e34e542efe7 Mon Sep 17 00:00:00 2001 From: rameen Date: Mon, 20 Jul 2026 12:54:51 +0330 Subject: [PATCH 7/9] package-locks and prisma migration update. --- .../migrations/20260714194721/migration.sql | 2 ++ frontend/package-lock.json | 32 ++++++++----------- 2 files changed, 15 insertions(+), 19 deletions(-) create mode 100644 backend/prisma/migrations/20260714194721/migration.sql diff --git a/backend/prisma/migrations/20260714194721/migration.sql b/backend/prisma/migrations/20260714194721/migration.sql new file mode 100644 index 0000000..17e0f5f --- /dev/null +++ b/backend/prisma/migrations/20260714194721/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "lab_cases_dueDate_idx"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31dfa4d..3428e26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } From 24c4feb11ca660b39d89fe2c025efb03a664abb1 Mon Sep 17 00:00:00 2001 From: rameen Date: Sun, 23 Aug 2026 11:10:28 +0330 Subject: [PATCH 8/9] Enable Gitea Actions staging deploy on master push --- .gitea/workflows/registry-build-deploy.yml | 339 +++++++++------------ AGENTS.md | 2 +- README.md | 99 +----- infrastructure/deploy.registry.env.example | 7 +- 4 files changed, 158 insertions(+), 289 deletions(-) diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index 1ca249e..1f5fd2e 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -1,30 +1,25 @@ -# 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: +# Triggers: push to master/main, or manual workflow_dispatch. +# +# Repository Variables (Settings → Actions → Variables): # 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) +# 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 when runner is on the same host as Gitea and public IP fails locally, +# set to 127.0.0.1:3000 (no http:// — same style as REGISTRY_HOST) # # 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_HOST to insecure-registries (HTTP registry), then restart Docker. # -# 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 +33,149 @@ defaults: shell: powershell jobs: - temp-success: + build-and-push: runs-on: self-hosted + 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: self-hosted + 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 diff --git a/AGENTS.md b/AGENTS.md index ee313a0..8236b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index a412280..2302c2f 100644 --- a/README.md +++ b/README.md @@ -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://: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 : -u - ``` - - 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 = ":" -$OWN = "" -$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://:/api" ` - --build-arg NEXT_PUBLIC_APP_URL="http://:" ` - --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=:/` (no `http://`, no trailing slash) - - `IMAGE_TAG=latest` or the tag you pushed - - `STAGING_HTTP_PORT=` (e.g. `8088` — browser uses `http://: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). Gitea’s 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` | diff --git a/infrastructure/deploy.registry.env.example b/infrastructure/deploy.registry.env.example index 4e8eb3b..87f1add 100644 --- a/infrastructure/deploy.registry.env.example +++ b/infrastructure/deploy.registry.env.example @@ -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). From 201300f446a29a370e9a1ccfc7df313975b7c51a Mon Sep 17 00:00:00 2001 From: rameen Date: Sun, 23 Aug 2026 11:11:07 +0330 Subject: [PATCH 9/9] staging deploy md doc. --- infrastructure/STAGING-DEPLOY.md | 251 +++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 infrastructure/STAGING-DEPLOY.md diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md new file mode 100644 index 0000000..c100080 --- /dev/null +++ b/infrastructure/STAGING-DEPLOY.md @@ -0,0 +1,251 @@ +# 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://:8088 → nginx → frontend / backend → postgres +``` + +| Service | Image source | +|----------|---------------------------------------------------| +| postgres | `postgres:15-alpine` (pulled from Docker Hub) | +| backend | `//dyolink-backend:` | +| frontend | `//dyolink-frontend:` | +| 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": ["178.131.50.201:3000", "127.0.0.1:3000"] +} +``` + +Apply & restart Docker. + +### 2. Gitea Actions runner + +Download [act_runner](https://gitea.com/gitea/act_runner/releases) → e.g. `C:\gitea-runner\act_runner.exe`. + +Register (use **localhost** when Gitea runs on the same PC — public IP often fails locally): + +```powershell +cd C:\gitea-runner +.\act_runner.exe register ` + --instance "http://127.0.0.1:3000" ` + --token "" ` + --name "windows-staging" ` + --labels "self-hosted,windows" +``` + +Start (leave running, or install as a Windows service later): + +```powershell +.\act_runner.exe daemon +``` + +Gitea → repo → **Settings → Actions → Runners** should show **Online**. + +**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` | `178.131.50.201:3000` | No `http://` | +| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages | +| `PUBLIC_BASE_URL` | `http://178.131.50.201:8088` | No trailing `/` | +| `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows | +| `CLONE_HOST` | `127.0.0.1:3000` | When runner and Gitea are same machine | +| `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 +``` + +--- + +## 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 ~15–30 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 ` +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 | +|---------|-----| +| 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` | +| `server gave HTTP response to HTTPS client` | Add registry 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 | Windows firewall rule; confirm nginx container is up | +| Backend restart loop | JWT secrets still placeholder; fix `backend.staging.env` | +| Backend DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` | + +**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.