Hesap güvenliği için authenticator app (Google Authenticator, 1Password,
Authy etc.) based TOTP. SMS yok — sadece app-based per user request.
Enroll flow (/settings/security)
- startMfaEnrollAction → account.createMFAAuthenticator('totp'),
returns otpauth URI + plain secret as backup.
- MfaPanel client island: starts the flow, shows the QR (rendered via
api.qrserver.com for zero deps) plus the secret as text. Picks the
6-digit code → verifyMfaEnrollAction calls
updateMFAAuthenticator(totp, otp) + updateMFA(true) +
createMFARecoveryCodes(). The recovery codes are surfaced once on
success with a 'save these now' warning.
- disableMfaAction + regenerateRecoveryCodesAction give the same
panel a disable + 'yeni yedek kodlar' option once MFA is active.
- settings-nav now has 'Güvenlik' between 'Görünüm' and 'Hesap
Aktivitesi'.
Sign-in flow
- signInAction:
1. createEmailPasswordSession (sets cookie as before)
2. users.get(userId).mfa? If yes:
a. otp empty → return { mfaRequired: true, error }
b. otp present → createMfaChallenge({factor: totp}) +
updateMfaChallenge(challengeId, otp). Failure tears the
partial session down and bounces back with mfaRequired.
- AuthState gained an mfaRequired field. The login form watches it
and reveals an autofocused 6-digit OTP input on the next render.
User types the code, submits the form again, the same action
finishes the challenge and redirects.
Existing accounts without MFA are unaffected — they never hit the
challenge branch.
audit_logs was a write-only firehose: every action wrote to it but
nothing ever read it. Surfaced the last 200 entries on a new
/settings/activity page so workspace admins can audit who did what.
- lib/appwrite/audit-queries.ts: listAuditLogs(tenantId, limit=100)
scoped to the caller's tenantId via Query.equal — multi-tenant
safety preserved.
- /settings/activity/page.tsx: server-rendered table — time, user,
action badge (create/update/delete), entity label (TR), changes
summary. Resolves userIds → displayName via a single bulk lookup
against TABLES.profiles. Falls back to a truncated id when a
profile isn't found so the row still reads.
Settings now has a horizontal tab nav too — there were six pages under
/settings with no cross-links between them. Added:
- settings/layout.tsx wraps every settings page with the new nav.
- settings/components/settings-nav.tsx (client): pathname-active
state, scrolls horizontally on mobile. Items: Çalışma Alanı,
Profilim, Üyeler, Bildirimler, Görünüm, Hesap Aktivitesi.
A lab opening its inbox first thing in the morning shouldn't have to
click 'İşleme Al' on every overnight submission. Added a single bulk
action that flips every currently-pending job into in_progress in one
shot.
- bulkAcceptPendingJobsAction (lab only, owner/admin/member):
lists every pending job for this lab (limit 200), then for each
row in parallel writes status=in_progress + currentStep=alt_yapi_prova
+ location=at_lab. History rows and clinic notifications fire as
fire-and-forget so a single failure doesn't block the rest. Returns
{ accepted } — count actually moved.
- BulkAcceptButton (client island, /jobs/inbound only) shows when
the current filtered list has at least one pending row, with a
confirm dialog. Disabled / spinner while in flight.
- 'Tümünü okundu işaretle' bulk action on /notifications was already
in place, so nothing else needed there.
Notifications mark-all was already wired earlier; this commit covers
the inbox half.
Clinics' accountants want a paper / PDF for every payment they record.
Rather than pull in a PDF lib (jspdf / react-pdf etc.) and ship another
~150KB to every user, did this with print-CSS: a server-rendered receipt
page that prints cleanly.
- /finance/payments/[paymentId]/receipt: server component, loads the
Payment row, refuses 404/notFound unless the viewer is one of the
two parties on it. Resolves lab vs clinic by direction (inflow ⇒
tenant is lab) and pulls each side's tenant_settings (companyName,
taxId, address) for the header.
- Layout: card with header (lab name + VKN + address), two-column
block (tahsil edilen / ödeme tarihi), bold amount, method + status
row, optional note. Footer shows the receipt id + creation date.
- ReceiptControls (client island): back-to-finance button and a
'Yazdır / PDF' button calling window.print(). Both hidden in print
via 'print:hidden'.
- my-pending-payments-card gets a 'Makbuz' link per row alongside
'Geri al', so a clinic can grab a printable copy of any payment
they've submitted — pending or confirmed.
Clinics had no way to look up 'what have we made for this patient
before'. The patient row showed in the list and edit dialog, but no
deeper page. Added /patients/[id] with the patient header, notes card
and a 'İş Geçmişi' table that's chronological.
- listPatientJobs(patientId, patientCode, clinicTenantId) merges two
queries — explicit patientId match (new jobs) and patientCode
match (legacy rows from before we had the relation). Dedupes by
$id and sorts createdAt desc. Returns plain.
- /patients/[patientId]/page.tsx (clinic-only via requireTenantKind):
notFound on missing/foreign rows, header shows code + full name +
Arşivlenmiş badge, 'Bu hastaya yeni iş' shortcut into /jobs/new,
history table with date + type + member count + status badge +
due badge + a 'Aç' button per row.
- Patient list rows now link both the protocol code and the name
cells to /patients/[$id] so clinics can click straight in. The
edit/archive controls stay on the row trailing edge as before.
The inbox pages were single-table dumps of every job ever, which gets
useless past ~50 records. Added a filter bar driven by URL search params
so links and back-button work properly.
- listInboundJobs / listOutboundJobs accept a JobListFilters arg
({ status, location, q }). Status and location push down to
Appwrite via Query.equal; q is applied in-memory against
patientCode + counterpart company name (case-insensitive,
locale-aware via Turkish toLocaleLowerCase). Both list functions
delegate to a shared listJobsFor() so they can't drift apart.
- JobsFilterBar (client): debounced text input (250ms) for q,
Select for status (all/pending/in_progress/sent/delivered/cancelled)
and location (all/at_lab/at_clinic). All three commit to the URL
via router.replace inside startTransition so the table re-renders
with the server data without a full reload. 'Temizle' button
appears once any filter is active.
- /jobs/inbound and /jobs/outbound now read searchParams (awaited per
Next 16 conventions), pass them as filters, and render the bar
above the table. Empty state copy points to the filters so users
don't think the system lost their jobs.
Clinics that record a payment now get visibility on what happened to it.
Previously the row went into limbo — clinic clicked 'Ödeme Yap', balance
didn't move (lab approval pending), and the clinic had no in-app place
to confirm the submission landed.
- /finance clinic-side now renders a new card 'Gönderdiğim Ödemeler'
listing payments where tenantId == self AND status in (pending,
rejected). Confirmed rows drop out (they're already reflected in
the balance above).
- Each row shows counterpart, amount, date, method, note plus a
status badge: amber 'Onay bekliyor' or destructive 'Reddedildi'.
- Pending rows expose a 'Geri al' button — fires deletePaymentAction
so a clinic can withdraw a submission it sent in error before the
lab acts on it. Rejected rows stay read-only for audit.
- Card is hidden when the list is empty so the page stays tidy.
The job_status_history table was already being populated on every
transition; the detail page just rendered a flat list with date only.
Replaced it with a proper vertical timeline:
- Card title moved from 'Aşama Geçmişi' to 'Akış Geçmişi' since we
now include side-trips (revision requests), not just forward steps.
- Vertical guide line with a coloured node per entry: emerald for
a normal step completion, rose for a revision request. Spotting a
bounced prova in the history is a glance.
- Revision rows get an inline 'Düzeltme talebi' pill; the '[Düzeltme
talebi]' prefix is stripped from the visible note so the actual
feedback text reads cleanly.
- Always rendered (with an empty-state line) so the card position
doesn't move around as the case progresses.
Up to now the only thing a clinic could do on a prova was approve it.
If the casting didn't fit there was no way to bounce the case back to
the lab short of cancelling the whole thing. Real-world flow needs a
'try again, this is what's wrong' lever, so:
- requestRevisionAction (clinic only): pre-conditions
in_progress + at_clinic + currentStep set; flips location → at_lab
while leaving currentStep untouched so the same prova stage repeats
after the lab redoes the work. Requires a note (the lab can't fix
what it doesn't know is broken) — appended to job_status_history
with a '[Düzeltme talebi]' prefix and surfaced to the lab via
notification.
- JobActionsPanel: when the clinic side sees a prova (in_progress +
at_clinic) it now shows two buttons — Onayla as before, plus
Düzeltme İste (variant=destructive). The dialog requires a note
before submit.
dueDate was sitting on every job but the UI never warned anyone about
it. Added a small badge primitive and surfaced it everywhere a job is
listed plus a dedicated dashboard card.
- lib/appwrite/due-date.ts: dueState() buckets a job into overdue /
today / soon (1-3 days) / future / none, with delivered + cancelled
jobs always resolving to none. dueLabel() returns the Turkish text.
- components/due-badge.tsx: renders nothing for future/none, a
secondary badge for today/soon, a destructive badge for overdue.
Drop-in (job, className).
- JobsTable (inbound + outbound): new 'Termin' column shows the date
and the DueBadge stacked. Sorting still on createdAt for now —
explicit ordering by dueDate will come with the filter task.
- Job detail header: badge stack now has the DueBadge before the
status pill so a glance at the page shows whether the case is
behind schedule.
- Dashboard: getDashboardData fetches up to 10 overdue jobs in
parallel (lessThan('dueDate', now), excluding delivered/cancelled).
Added a destructive-tinted 'Geciken İşler' card above the rest of
the widgets when the list is non-empty, with quick links into each
job's detail page.
Active scan + image traffic was going to bloat Storage fast — every
delivered case has tens of MB of STL hanging around forever. Now closing
a case via 'Teslim Aldım' fires a background archive sweep that deletes
the binary from the bucket but keeps the job_files row, so audit
('kim, ne, ne zaman yükledi') is preserved.
- DB: job_files.archivedAt datetime (nullable).
- archiveJobFiles(jobId) (lib/appwrite/job-file-archive.ts):
lists rows, storage.deleteFile each, stamps archivedAt on the row.
All in try/catch so partial Storage failures don't roll back the
'delivered' transition.
- markDeliveredAction fires it as 'void archiveJobFiles(jobId)' — same
fire-and-forget pattern as audit/notifications/finance sync.
UI / API
- Job detail file row dims to 60% opacity, shows 'Arşivlendi
{tarih}' inline, and disables both the download dialog trigger and
the STL viewer button.
- /api/jobs/[jobId]/files/[fileId]/download returns 410 Gone with a
Turkish message when archivedAt is set — direct-URL hot links can't
fish the file back either.
Clinics upload intraoral scans as raw STL/PLY/OBJ and labs need to see
what was captured before accepting the case. Built a tooth-level 3D
preview that runs entirely in the browser — no server-side rendering,
no extra service, just three.js + react-three-fiber driven off the
existing download proxy.
Component (src/components/stl-viewer.tsx)
- Detects format from the filename (.stl / .ply / .obj).
- fetch() → ArrayBuffer → STLLoader / PLYLoader / OBJLoader.parse.
- OBJ comes back as a Group; merge the child meshes into a single
BufferGeometry (positions concat) so all three formats render
through the same mesh path.
- Scene: dark background, ambient + two directional lights, a
light-grey standard material, drei <Bounds> for auto-fit framing,
OrbitControls with damping. A 'Sığdır' button in the corner re-fits
the camera by remounting Bounds.
- Cleanup: geometry.dispose() on unmount, fetch cancellation guard,
inline error/loading states.
Wiring (job-files-panel.tsx)
- VIEWABLE_RE = /\.(stl|ply|obj)$/i. Only those rows get the new
eye-icon button.
- STLViewer is loaded via next/dynamic with ssr:false so three.js
(~500KB minified) only enters the bundle when the dialog actually
opens. Mounting is also gated on viewerOpen, so closing the dialog
frees the WebGL context.
- Dialog is a tall (85vh) wide (max-w-5xl) shell with the filename
+ size in the header and the canvas filling the rest.
Deps added: three, @react-three/fiber, @react-three/drei (+ types).
A payment recorded by the lab itself is self-evident (the lab knows it
got paid). One recorded by the clinic is just a claim until the lab
agrees. Added a status field to enforce that distinction so labs can
approve payments per-clinic instead of trusting whatever the clinic
typed in.
DB
- payments.status enum (pending | confirmed | rejected, default
confirmed). Existing rows keep the default and continue to be
counted in balances.
Server
- recordPaymentAction now stamps status='pending' when the caller is a
clinic and 'confirmed' when the caller is a lab. A clinic submission
pings the lab via createNotification so it surfaces on the
notifications bell as well as on /finance.
- confirmPaymentAction (lab only): flips a pending row to confirmed
after verifying the lab is the counterpart. Notifies the clinic on
success.
- rejectPaymentAction (lab only): flips to rejected, notifies the
clinic. Rejected rows stay visible for audit but never count toward
the balance.
Queries
- listIncomingPayments(tenantId) — payments where this tenant is the
counterpart (the other side recorded them). Paired with listPayments
we now see the same physical payment from either ledger.
- computeBalancesByCounterpart upgraded to handle both shapes via an
inflowFor() helper that normalises 'who got the money'. Only
confirmed rows reduce the open balance.
- filterPendingForConfirmation() returns the lab-side approval queue,
sorted newest-first.
UI
- /finance loads own + incoming payments, dedupes by $id, then feeds
the merged list to balance/pending helpers.
- New PendingPaymentsCard sits above the balances table on the lab
side. Per-row: clinic name, amount, date, method, note, plus inline
Onayla / Reddet buttons. Empty state hides the whole card.
- Confirm + reject use the same router.refresh pattern as the rest of
the action panels so the queue and the balances both update without
a manual reload.
Klinik-laboratuvar finance in TR is dönemsel, not job-by-job. Forcing
the lab to mark each finance_entry as paid was unrealistic — labs get a
single 50.000 TL transfer covering twelve jobs and don't want to walk a
list. Reworked the page around connection balances and free-amount
payments.
Data model
- New 'payments' table:
tenantId whose ledger it lives in
counterpartTenantId the other side of the transaction
direction inflow (lab received) | outflow (clinic paid)
amount, currency, paymentDate
method cash | bank | card | check | other
notes, recordedBy
Indexes: (tenantId, counterpartTenantId), paymentDate DESC. Permission:
both teams can read, only owners/admins of the recording side can
update or delete.
Server
- recordPaymentAction: requires owner/admin, verifies an approved
connection exists between (lab, clinic), then writes a single row.
Direction is inferred from the caller's tenant kind so a lab can
never accidentally book an outflow.
- deletePaymentAction: same auth, tenant-scoped delete.
- listPayments(tenantId) + computeBalancesByCounterpart({ kind,
entries, payments }): one pass over both ledgers, returns
[{ counterpartTenantId, invoiced, paid, open, lastPaymentAt }]
sorted by open desc. invoiced pulls from finance_entries (receivable
for lab, debt for clinic); paid pulls from the new payments table.
UI
- /finance now leads with a Bakiye kartı: a row per connected
counterpart showing invoiced, paid, last payment date and the open
amount tinted green (lab alacak) or red (clinic borç), each with an
inline 'Ödeme Al' / 'Ödeme Yap' button.
- RecordPaymentDialog: amount (defaults to the open balance, lump
sums obviously not pre-filled with a specific entry), date,
currency, method (Nakit/Banka/Kart/Çek/Diğer), free-text note.
Posts to recordPaymentAction, refresh on success.
- Stat cards reworked: Toplam Açık Alacak/Borç and Tahsil Edilen /
Ödenen replaced the old pending-totals so the headline numbers
actually reflect the new flow.
Existing finance_entries (job-driven receivables/debts) remain the
single source of truth for 'how much was invoiced'; the new table tracks
'how much was actually collected'. Open balance = invoiced - paid, always
computed live — no individual entry needs to be marked 'paid' anymore.
Previously creating a job dumped the clinic on /jobs/outbound and left
them to navigate into the new job's detail page and upload scans there.
Splitting the form into a wizard keeps the whole 'publish a job' flow
on one page.
Step 1 — İş Bilgileri (existing form): lab, patient, product, teeth,
notes, due date. The submit button is now 'Devam Et — Dosyalar'. On
success createJobAction returns the new jobId, we stash it in state and
flip step → 'files' instead of router.push'ing away.
Step 2 — Dosyalar (new): a FilesStep component with a file picker that
queues each selection, kicks off a parallel XHR upload to the existing
/api/jobs/[jobId]/files endpoint, and shows per-row progress. Three
states per row: uploading (real byte progress), processing (server is
writing to Appwrite, indeterminate), done. Errors surface inline.
User exits via:
- 'İlanı Tamamla' → /jobs/[id] (the new job detail page).
- 'Şimdilik atla' → /jobs/outbound, as before. Disabled while any
upload is still in flight so files don't get abandoned mid-stream.
The shared StepIndicator (1 İş Bilgileri → 2 Dosyalar) sits at the top
of both screens; a checkmark replaces the number once a step is done.
Real prosthetic production isn't a one-way pipeline — the work moves
between lab and clinic multiple times. After substructure is produced
the lab hands it to the clinic for a fitting, the clinic approves it
back to the lab, the lab builds the superstructure, hands it back for
a second fitting, the clinic approves again, the lab does cila/bitim,
and finally delivers it to the clinic for handover to the patient.
Previously we only had a single 'advance step' action callable by the
lab, which collapsed all of that into a linear forward push and didn't
capture who physically had the work at any given moment.
DB
- New jobs.location enum (at_clinic | at_lab, default at_clinic).
- Existing jobs keep working via a 'location ?? at_lab' fallback in
code; no manual backfill required for the four test rows.
State machine
- acceptJobAction (lab): pending → in_progress, currentStep=alt_yapi_prova,
location=at_lab. Skips the implicit 'olcu' production step now that
accepting the job means the lab has the impression in hand.
- handToClinicAction (lab, NEW): at_lab → at_clinic, step stays the
same. If step is cila_bitim, status becomes 'sent' (final delivery)
and finance sync fires.
- approveAtClinicAction (clinic, NEW): at_clinic → at_lab, step
advances to the next stage so the lab knows what to produce next.
- markDeliveredAction unchanged — clinic confirms the final handoff.
- advanceStepAction removed; its single forward push doesn't fit the
new bidirectional flow.
UI
- JobActionsPanel now picks the right button from the role + status +
location matrix:
* Lab + pending → 'İşleme Al'
* Lab + in_progress + at_lab + cila_bitim → 'Cila Bitim — Nihai Teslime Gönder'
* Lab + in_progress + at_lab + other → '{stage} Provaya Gönder'
* Clinic + in_progress + at_clinic → '{stage} Provası Tamam'
* Clinic + sent → 'Teslim Aldım'
* Both + pending → 'İptal Et'
- Job detail surfaces a new 'Şu An' info row that resolves to a
human-readable location ('Klinikte', 'Laboratuvarda', 'Hasta'ya
teslim edildi', ...) so anyone glancing at the page can tell where
the work physically is.
Lab side reported that after accepting a job / advancing a step the
button kept its 'Yükleniyor' state and the page didn't reflect the new
status until they hit refresh. Two issues stacked on top of each other:
1. The button forms were passing the action through an extra
startTransition wrap — 'action={(fd) => startTransition(() => action(fd))}'.
With React 19 + useActionState this is unnecessary; useActionState
already manages its own transition. The double transition can leave
the dispatch's pending flag wedged in some race orderings, which
matches what the user saw.
2. revalidatePath() on the server invalidates the RSC cache but does not
trigger client navigation. So even after the action returned, the
page kept rendering the stale Job snapshot — and since the buttons
are conditional on job.status, the now-stale 'pending' status meant
the button stayed visible.
Fix in JobActionsPanel and the four sibling components (connections
delete row, pending inbound, pending outbound, file row delete):
- Removed the startTransition wrap; forms point at 'action' directly.
- Added useRouter() and call router.refresh() in the same useEffect
branch where the success toast fires. This forces the Server
Component tree to re-fetch, picks up the new job.status, and the
actions panel rerenders into whatever button is next in the flow.
- Cleaned the now-unused useTransition imports.
Net effect: tap 'İşleme Al' → spinner appears, ~400ms later the toast
hits and the row updates in place to 'Sonraki Aşama' without any
manual refresh.
Files were grabbed via a plain anchor with the 'download' attribute, so
clicking the icon just spawned a silent browser download — nothing in
the UI moved, and users (especially labs receiving scans) couldn't tell
whether the click registered.
Wrapped the download button in a confirm dialog that mirrors the existing
delete flow: title 'Dosya indirilsin mi?', filename + size in the body,
Vazgeç / İndir buttons. The 'İndir' button programmatically clicks a
hidden anchor pointing at the /api/.../download proxy and surfaces a
'İndirme başladı.' toast with the filename so there's a clear visual ack
even before the OS download tray pops.
What the clinic sees has changed from a fixed 6-item Protez Türü dropdown
to the actual products that lab has published in its catalog. Picking 'Premium
Zirkonyum (1500 TRY / diş)' is now an option; picking the generic
'Zirkonyum' bucket is not.
Wiring
- DB: jobs.prostheticId string column (optional — legacy jobs stay valid).
The denormalised prostheticType is still written, sourced server-side
from the chosen catalog row, so reports/aggregates keep working.
- validation/job.ts: prostheticId required, prostheticType removed from
the form payload (computed instead).
- job-actions.ts: looks up the catalog row, verifies tenantId match +
not archived, then pulls .type and .unitPrice from it to drive
calculateJobPriceForProsthetic.
Pricing helper
- New calculateJobPriceForProsthetic(prosthetic, clinicTenantId,
teethCount). Reuses the same clinic_pricing cascade
(custom price wins, otherwise discountPercent off catalog) but skips
the type-based catalog lookup entirely — it already has the row.
- /api/pricing/quote rewritten to accept { prostheticId, teethCount },
still gated on an approved connection between clinic and the lab that
owns the product.
UI
- jobs/new page server-loads each connected lab's active prosthetics
once and hands them to NewJobForm as prostheticsByLab.
- NewJobForm: 'Ürün *' Select replaces 'Protez Türü *'. The list filters
by the currently selected lab; switching labs clears the chosen
product. Each option shows name + 'unitPrice / diş' on the right.
Once selected, a small caption surfaces the underlying category
label ('Kategori: Zirkonyum') so the clinic still understands what
bucket the product falls into.
- PriceQuoteCard's hasInputs gating moved off prostheticType to
prostheticId.
Reduced the patient record to the minimum a dental clinic actually needs:
just a code, optional first/last name and free-text notes. Phone and
date-of-birth fields are gone from the UI everywhere — Add form, edit
dialog inside the table, the Bağlantı Bilgileri block on job detail, and
the table column list. The patient list now surfaces 'Notlar' instead.
Backend
- DB: firstName and lastName columns set to required=false via Appwrite
MCP (tables_db_update_string_column). Existing rows untouched.
- schema.ts Patient interface: firstName/lastName now optional, phone
and dateOfBirth removed from the type entirely. The underlying columns
are still in the DB so legacy rows aren't broken — we just stop
referencing them in code.
- validation/patient.ts: firstName/lastName drop min(1), phone and dob
fields removed.
- patient-actions.ts: pickFields no longer reads phone/dob, create and
update payloads no longer write them.
UI fallbacks
- PatientsTable: header has 'Notlar' instead of Telefon/Doğum. Ad Soyad
cell shows the joined name or em-dash. Edit dialog mirrors the same
simplified form.
- jobs/[jobId] detail page: when patient row has neither name, the page
title falls back to 'Hasta {patientCode}' (same as before for jobs
without a linked patient). The Hasta Bilgileri card now shows Ad Soyad
and Patient Code side by side, with notes spanning both columns.
Clinics couldn't see what an order would cost them before publishing.
Added a server-priced quote box that updates as the lab, prosthetic type
and tooth selection change. Three rendered states:
1. Discounted (custom unit price or discountPercent):
Katalog (4 × 1.500 ₺) 6.000 ₺
Klinik indirimi (%15) -900 ₺
──────────────────────
Toplam 5.100 ₺
2. Plain catalog match (no clinic override):
4 × 1.500 ₺ 6.000 ₺
'Katalog fiyatı uygulanıyor'
3. No catalog row for the chosen type:
'Fiyat işe başlanırken laboratuvar tarafından belirlenecek'
Bits to make this work
- calculateJobPrice now returns catalogUnitPrice, catalogTotal, savings
and teethCount alongside the final amount, so the client can render
a breakdown without reaching for any other table.
- POST /api/pricing/quote endpoint guards on clinic kind + an approved
connection before exposing pricing — no leaking catalog data to
unconnected clinics, and no per-lab discount snooping.
- NewJobForm's lab and prosthetic Selects became controlled (hidden
inputs mirror state to the form action), and a debounced effect
(250ms) re-fetches the quote when any of {lab, type, teeth.length}
changes. AbortController cancels in-flight requests when inputs change
again.
The Bağlantılarım table rendered the pricing summary cell as a vertical
<ul> — one rule per line — which doubled or tripled the row height for
labs that priced multiple prosthetic types. User asked for the rules to
sit on a single line, separated by commas.
Switched to an inline <span> with React.Fragment-joined rules, comma
separator, and the truncation suffix stays in the same line.
Truncation rule unchanged: show first 3, then ', +N kural'.
The /connections page was firing one listClinicPricing call per approved
counterpart inside Promise.all. That meant a lab with 10 clinics paid 10
sequential Appwrite roundtrips on every load, and worse, every time the
ClinicPricingDialog saved a row revalidatePath('/connections') ran the
whole fan-out again — saving a single discount felt like the request had
hung.
Replaced the per-peer query with listAllPricingForLab /
listAllPricingForClinic (single Query.equal on the side-specific column,
limit 500) and group the result into a Map client-side. One roundtrip
regardless of how many connections you have.
Also flipped the audit-log calls in setClinicPricingAction /
clearClinicPricingAction from 'await logAudit(...)' to 'void logAudit(...)'
— audit is best-effort by design and never blocks the user-facing
mutation; awaiting it doubled the perceived latency for nothing.
Even after switching the page-level grid columns to minmax(0,1fr), the
Card primitive itself was still bringing its intrinsic min-content into
the column — every Card defaults to 'min-width: auto', and a Card with
a wide table inside resolves min-content to the table width. That meant
the column still couldn't collapse and the table's overflow-x-auto
wrapper never saw a constrained parent, so the action buttons spilled
past the Card border.
Added 'min-w-0' to Card and CardContent so:
- the Card collapses to whatever the grid track allows;
- CardContent collapses inside the Card, letting the Table wrapper
finally enforce its overflow-x-auto scroll.
Also fixed two inner form grids that had the same '1fr' overflow trap:
ProstheticForm and ProstheticsTable's edit dialog both use a
[price][currency] split, switched to minmax(0,1fr)_100px so the 'Para
birimi' label / TRY value no longer get pushed past the form edge.
CSS grid tracks named '1fr' have an implicit 'min-width: auto' that defers
to the child's intrinsic minimum content size. With wide tables inside
those tracks (Ürünler, Hastalar, dashboard recent jobs) that minimum was
the full table width, so the column blew past its share of the row and
the table's overflow-x-auto wrapper never got a chance to do its job —
the whole page scrolled horizontally instead.
Switched the offending tracks to 'minmax(0,1fr)' which lets them collapse
to zero and lets the table primitive's own overflow handle horizontal
scroll inside the cell as designed.
Touched:
- /products grid-cols-[1fr_360px] → [minmax(0,1fr)_360px]
- /patients grid-cols-[1fr_360px] → [minmax(0,1fr)_360px]
- /dashboard grid-cols-[2fr_1fr] → [minmax(0,2fr)_minmax(0,1fr)]
Small inline forms ([1fr_auto], [1fr_100px]) were left as-is — they don't
host tables.
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.
XHR's upload.progress event only tracks browser→server byte transfer. It
fires 100% the moment the multipart body has been pushed, but at that
point our route handler hasn't even started streaming the files into
Appwrite Storage yet. A 200MB upload completes the network phase fast,
then sits 30-60 seconds while the server does sequential storage.createFile
+ tablesDB.createRow per file. The UI was stuck on '100%' the whole time,
making it look frozen.
Switched the form to a discriminated phase state:
idle → uploading (real bytes %) → processing (full bar + spinner)
→ idle (success/fail)
Listening on xhr.upload's own load event flips us to 'processing' the
instant the body is done. The outer xhr.load fires when the route handler
responds with JSON — that's when we toast + router.refresh().
User now sees: bar fills, then 'Sunucu Appwrite'a yazıyor — büyük dosyalar
30-60 sn sürebilir' message until the response comes back. No more
mystery wait.
Memory [[feedback_use_server_only_async]]: 'use server' files can only
export async functions. notification-actions.ts also exported the
NotificationActionState type and an initialNotificationActionState const,
which Next 16 now flags hard at runtime ('A "use server" file can only
export async functions, found object.').
Moved both to a sibling notification-types.ts (same pattern we already
follow for connection-types / job-file-types / prosthetic-types). The
client component imports the const from -types and the actions from
-actions; no behaviour change.
Cluster: Appwrite container _APP_STORAGE_LIMIT 30000000 → 209715200
(200MB) in /root/services/appwrite/.env on kovaksoft-coolify, then
docker compose up -d to roll the worker pool with the new value.
Backup of the .env left at .env.bak.<date>.
Bucket: job-files maximumFileSize updated to 209715200 via Appwrite MCP
(storage_update_bucket).
App: MAX_FILE_BYTES in both the upload API route and the original server
action raised to 200MB. Client-side panel guard relaxed accordingly —
one large file is now allowed to fill the entire batch (the 200MB
proxy/serverActions cap is the bottleneck, not the per-file rule).
Error copy updated.
isletmem and any other tenants on the cluster also get the new limit,
which is the desired behaviour — old 30MB ceiling was a relic of an
Appwrite default that no DLS workflow can actually live with.
- next.config: serverActions.bodySizeLimit + experimental.proxyClientMaxBodySize
bumped from 500mb back down to 200mb. Batch ceiling (client side) is 180mb
to stay comfortably under the proxy cap.
- New POST /api/jobs/[jobId]/files endpoint replaces the server action for
upload. Same auth/permissions/rollback semantics, but Returns JSON so the
client can read the response. Server action is retained for delete only.
- JobFilesPanel switched from useActionState to XMLHttpRequest.upload —
xhr.upload.onprogress feeds a Progress bar (real bytes, not a fake
ticker). Cancel button aborts the in-flight request. Successful upload
triggers router.refresh() to repopulate the file list.
Server actions can't expose upload progress (no streaming feedback in the
RSC protocol yet), so any progress UX needs to go through fetch/XHR
against a route handler. Trade-off accepted.
The previous attempt put 'middlewareClientMaxBodySize' at the top level of
NextConfig — Next 16.1 rejected it as an unrecognised key. Inspecting the
shipped config schema (node_modules/next/dist/server/config-schema.js)
revealed the option lives under experimental and was renamed to
'proxyClientMaxBodySize' when middleware.ts → proxy.ts; the old name is
still accepted but deprecated. Switched to the new name and confirmed Next
now lists it in the Experiments banner at boot.
While we were at it the cap was bumped to 500MB (was 100MB) so batch
uploads have headroom over the 30MB-per-file bucket limit. Added a
client-side pre-flight in JobFilesPanel: rejects individual files >30MB
and total batches >400MB *before* hitting the server, with inline error
messaging instead of a cryptic 'Unexpected end of form' bounce.
Also raised serverActions.bodySizeLimit to 500mb to match.
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.
- getDashboardData aggregates open jobs, pending-action jobs, unread
notifications, pending finance totals, approved connection count, recent
jobs (up to 8) and recent notifications (up to 5) — single Promise.all so
the dashboard renders in one round-trip.
- Four stat cards, each a Link to the relevant module; tone (positive /
negative) flips between clinic (payable) and lab (receivable).
- Clinic users with zero approved connections see a 'Bağlantı Kur' prompt
card so they don't get stuck on /jobs/new.
- Recent jobs table is role-aware: lab sees Klinik column + 'Son Gelen
İşler' header, clinic sees Laboratuvar column + 'Son Giden İşler' header.
- Recent notifications panel with read/unread dot, clickable header arrow
to /notifications.
- ActiveContext now carries 'kind' (mirror of TenantSettings.kind) so we no
longer reach into ctx.settings?.kind in callers.