What changed
- jobs.teeth (FDI string[]). memberCount becomes a derived field (teeth.length).
A new TeethChart component renders the full permanent dentition as a
16-column grid for each arch with click-toggle selection.
- /jobs/new: removed the price + currency inputs and the manual memberCount
field. Clinics now pick teeth via the chart; the form blocks submission
until at least one tooth is selected.
- createJobAction calls a new calculateJobPrice() helper that walks the
pricing cascade and writes price + currency on the job server-side. A
clinic-supplied price hidden field would now be ignored — the field
isn't even in the schema.
Pricing cascade (calculateJobPrice, lib/appwrite/pricing.ts)
1. clinic_pricing row matching (lab, clinic, type) with customUnitPrice
→ use that flat unit price.
2. clinic_pricing row with discountPercent → catalog unitPrice × (1-d).
3. lab's prosthetics catalog row matching type (not archived).
4. nothing → price stays null; lab can still set it manually later.
Clinic-specific overrides (clinic_pricing table)
- Unique on (labTenantId, clinicTenantId, prostheticType) so each
combination has at most one rule.
- Row permissions: read by both teams (transparency for clinic), write
only by lab — clinic can see the discount they're getting but cannot
edit it.
- setClinicPricingAction validates an approved connection exists before
creating/updating, and rejects requests where neither customUnitPrice
nor discountPercent is set.
- clearClinicPricingAction wipes a rule (catalog price re-applies).
UI
- /connections 'Bağlantılarım' table gets a new column showing the active
pricing rules per counterpart. Lab side has a 'Fiyatlandırma' button
that opens a dialog (PROSTHETIC_TYPE × customPrice|discountPercent form
+ list of active rules with delete). Clinic side is read-only.
- Job detail: 'Fiyat' field now shows 'Lab tarafından belirlenecek' when
null, instead of a literal —. Adds a 'Dişler' info block listing the
selected FDI numbers.
Clinics get a real patient ledger. Labs see only patientCode — no name,
phone, date of birth, or notes ever cross the team boundary.
Data model
- New table 'patients' (clinicTenantId, patientCode, firstName, lastName,
phone?, dateOfBirth?, notes?, archived). Unique index on
(clinicTenantId, patientCode) so each clinic gets its own code space.
Fulltext index on (firstName, lastName) for future patient search.
Row permissions Role.team(clinicTenantId) only — labs literally cannot
read the rows.
- jobs.patientId attribute (optional) + key index, references the
patient row when one exists. patientCode stays denormalised on jobs so
labs keep a stable identifier without joining patients.
Server
- createPatientAction: clinic-only, requireTenantKind guard. Protocol no
is optional; if absent we generate a 6-char unique code (re-roll on
collision, 8 attempts). Duplicate protocol no within a clinic is
rejected with a friendly error.
- updatePatientAction: edits name/phone/dob/notes. patientCode is
explicitly NOT mutable — re-keying historical jobs would be confusing.
- archivePatientAction: toggle, preserves history.
- listPatients / getPatient queries return plain objects via toPlain.
UI
- /patients page (clinic-only, sidebar nav 'Hastalar', middleware
protected): table + add form + edit dialog + archive.
- /jobs/new: patient Select replaces the bare patientCode input. Picking
a patient locks the patientCode field to that patient's code; falling
back to 'Hasta listesinde yok — kodu manuel gir' keeps the old free-
text flow.
- createJobAction validates patientId ownership and overwrites
patientCode with the patient's code on the server, so a manipulated
form can't desync the two.
- /jobs/[jobId] (clinic side only): adds a 'Hasta Bilgileri' card with
name/phone/dob/notes and uses the patient's full name as the page
title. Lab side is unchanged — code only.
The protocol-no / generated-code split matches what the user asked for:
existing patient management software's protocol number flows in directly,
otherwise the system mints one.
Next 16's server-to-client serializer rejects values whose prototype is
not plain Object. node-appwrite returns row objects carrying internal
helpers (toString etc.), so every <ClientComponent prop={row}> crashed with
'Only plain objects, and a few built-ins, can be passed to Client
Components from Server Components.'
Added a tiny toPlain helper that JSON-roundtrips any value and applied it
at the boundary of every query that returns rows consumed by 'use client'
files:
- connection-queries (enrich)
- job-queries (inbound, outbound, approved labs)
- job-file-queries (listJobFiles)
- job-history-queries (listJobHistory)
- prosthetic-queries (listProsthetics, listActiveProsthetics)
- finance-queries (listFinanceEntries)
- notification-helpers (listNotifications)
- dashboard-queries (getDashboardData)
- jobs/[jobId] page (direct getRow for the job prop on JobActionsPanel)
Internal Maps inside queries stay live — only the data crossing the
server/client boundary is normalised.