Compare commits

...

22 Commits

Author SHA1 Message Date
kovakmedya 2762aceb04 feat(notifications): severity (info/warning) + cover the gaps in the flow matrix
Modelled the real crown-and-bridge workflow (impression → accept →
framework try-in → bisque try-in → glaze/cila → delivery, with revisions
bouncing back at any try-in stage). Mapped every event in our system to
the appropriate side and tagged the ones a user actually needs to act on
versus normal progress traffic.

DB
  - notifications.severity enum ('info' | 'warning', default 'info').
    Existing rows default to info.

Helper
  - createNotification gains an optional severity param; persists into
    the new column.

Matrix changes (warnings vs infos)
  Lab side:
    - Düzeltme talebi (clinic → lab)          WARNING
    - İş iptal edildi (counterpart action)    WARNING
  Clinic side:
    - Ödeme reddedildi (lab → clinic)         WARNING
    - Bağlantı reddedildi (counterpart → req) WARNING (newly fired —
      rejectConnectionAction now sends a notification, previously silent)
  Everything else (accept, hand-off, prova ready, prova approved,
  delivery, payment submitted/approved, connection request/approved):
  stays at info.

Behaviour additions in actions
  - cancelJobAction now notifies the *other* party. Previously cancelled
    jobs vanished from one side's inbox without warning. severity=warning.
  - rejectConnectionAction notifies the requester (severity=warning) so
    they know the connection didn't come through.
  - requestRevisionAction tags its existing notification as warning.
  - rejectPaymentAction tags its existing notification as warning.

UI
  - Notifications list row: unread warning rows get an amber dot, an
    amber-tinted background, and a 'Dikkat' destructive badge instead
    of the regular 'Yeni' secondary one.
  - Dashboard recent-notifications widget mirrors the same dot + bold
    treatment so warnings stand out at a glance.

Net result: a user opening the bell sees normal traffic muted (still
listed for the 'herkes her şeyden haberdar' guarantee) and the things
that actually need them coloured amber and labeled Dikkat.
2026-05-23 17:54:47 +03:00
kovakmedya f3442e644a fix(jobs): server-side redirect after each transition + ?flash toast
User reported that hitting 'İşleme Al' (and every sibling transition
button) succeeded server-side but the UI didn't update — they had to
refresh manually. router.refresh() from the client useEffect was racing
Next 16's RSC payload cache in production and losing.

Replaced the round-trip pattern with the canonical Next approach:
the server action does revalidatePath as before, then calls
redirect(`/jobs/$jobId?flash=<key>`). redirect() throws NEXT_REDIRECT
inside the action, the framework navigates the client, and the
destination page gets a fresh RSC payload — no client-side cache layer
to fight.

Actions wired:
  - acceptJobAction        → ?flash=accepted
  - handToClinicAction     → ?flash=handed
  - approveAtClinicAction  → ?flash=approved
  - requestRevisionAction  → ?flash=revision
  - markDeliveredAction    → ?flash=delivered
  - cancelJobAction        → ?flash=cancelled

Because redirect() never returns, the success branch of every button's
useEffect was now dead code. Trimmed every panel button to only watch
state.error (errors still come back through useActionState the normal
way) and removed the now-unused useRouter / router.refresh wiring.

Toast handling moved to a single client island:
  - components/flash-toast.tsx: reads ?flash, toasts the matching
    Turkish message, then router.replace's the URL without the param
    so a manual reload doesn't re-fire the toast. A useRef guard
    blocks the StrictMode double-mount in dev.
  - Mounted once in (dashboard)/layout.tsx wrapped in <Suspense> per
    Next's requirement for useSearchParams in a layout.

Net result: tap a button, ~400ms later the page is on the new state,
toast confirms it. No manual refresh, no cache mystery.
2026-05-23 16:54:30 +03:00
kovakmedya 68f82d79c2 feat(kvkk): workspace data export + permanent delete
KVKK 'veri taşınabilirliği' ve 'unutulma hakkı' için iki uçtan yeni
akış: dışa aktar ve kalıcı sil. İkisi de /settings/workspace altındaki
yeni 'Tehlikeli Bölge' kartına eklendi, sadece owner görür.

Export — GET /api/account/export
  - requireTenant guard.
  - Sırayla tenant-owned tablolar (settings, profiles, connections,
    patients, clinic_pricing, jobs, job_files, history, prosthetics,
    finance_entries, payments, notifications, audit_logs) gezilir.
    Çift sahipli tablolar (jobs, connections, payments, clinic_pricing,
    job_files, history) için iki kez sorgu atılıp $id ile dedupe edilir.
  - { exportedAt, tenantId, tenantKind, requestedBy, data } JSON olarak
    Content-Disposition: attachment ile döner. ~ek dep yok.

Delete — deleteWorkspaceAction (owner-only, server action)
  - Onay: kullanıcının companyName'i birebir yazması gerekir.
  - 1) Storage: tenant logosu + arşivlenmemiş job_files objelerini sil.
  - 2) DB: hard-delete tüm tenant verisi — notifications, audit_logs,
    payments, finance_entries, history, job_files, jobs, clinic_pricing,
    prosthetics, patients, connections, profiles, tenant_settings.
    Her tablo için Query.equal + sayfa sayfa 500'er silme.
  - 3) teams.delete(tenantId) — son aşama, takım yok artık.
  - 4) active-tenant cookie temizle; session bırak (kullanıcı başka
    workspace'e geçebilir veya çıkış yapabilir).
  - redirect('/onboarding').

UI — DangerZone (client)
  - 'JSON indir' butonu: /api/account/export'a fetch, blob download.
  - 'Sil' Dialog: çalışma alanı adı confirm input ile gated; eşit
    olana kadar 'Kalıcı olarak sil' disabled.
2026-05-22 16:28:30 +03:00
kovakmedya 3e15d9f937 feat(security): two-factor authentication (TOTP)
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.
2026-05-22 16:25:26 +03:00
kovakmedya 424a323952 feat(settings): user-visible audit log + nav across settings sections
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.
2026-05-22 16:18:45 +03:00
kovakmedya 3de06add71 feat(jobs): bulk-accept all pending inbox items
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.
2026-05-22 16:13:59 +03:00
kovakmedya 353d93ad56 feat(finance): printable receipt page for a payment
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.
2026-05-22 16:12:09 +03:00
kovakmedya 88a42c9d06 feat(patients): detail page with full job history
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.
2026-05-22 16:10:20 +03:00
kovakmedya df02ea7107 feat(jobs): filter + search on inbound and outbound lists
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.
2026-05-22 16:08:06 +03:00
kovakmedya 503a98fcb3 feat(finance): clinic sees its own pending / rejected payments
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.
2026-05-22 16:06:06 +03:00
kovakmedya 94e9dffaef feat(jobs): step-by-step timeline on the detail page
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.
2026-05-22 16:05:07 +03:00
kovakmedya 53e443b4f1 feat(jobs): clinic-side 'Düzeltme İste' (revision request) flow
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.
2026-05-22 16:03:36 +03:00
kovakmedya d7d2ac557b feat(jobs): due-date awareness — DueBadge + dashboard 'Geciken İşler' widget
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.
2026-05-22 16:02:13 +03:00
kovakmedya d3977a5dcf feat(jobs): purge file binaries when a job is delivered, keep metadata
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.
2026-05-22 15:58:58 +03:00
kovakmedya 9e78d506ae feat(jobs): in-browser STL / PLY / OBJ scan viewer
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).
2026-05-22 01:51:05 +03:00
kovakmedya 0e4033aa3f feat(finance): clinic submits, lab confirms — payment approval flow
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.
2026-05-22 01:47:10 +03:00
kovakmedya b1046e945a feat(finance): connection-based balances + lump-sum payment recording
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.
2026-05-22 01:42:21 +03:00
kovakmedya 5dab958085 feat(jobs/new): two-step wizard — details, then files
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.
2026-05-22 01:36:56 +03:00
kovakmedya 479972e9a9 feat(workflow): split job step from location, model back-and-forth between lab and clinic
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.
2026-05-22 01:31:49 +03:00
kovakmedya cdb2a15643 fix(ui): router.refresh after server actions so status updates show without reload
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.
2026-05-22 01:15:32 +03:00
kovakmedya 6fec52b98d feat(jobs): confirm-before-download dialog so users see what's happening
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.
2026-05-22 01:08:10 +03:00
kovakmedya 12631cf9c5 perf+fix: file download proxy + drop awaits on audit/notifications/finance sync
Two problems reported by the user:

1. File downloads broken on the lab side.
   The link in JobFilesPanel pointed straight at Appwrite's
   /storage/.../view URL. Storage permissions are scoped to the job's two
   teams, but the browser only has a session cookie for our app domain,
   not for db.kovaksoft.com — so the cross-origin request hit Appwrite
   as a guest and 401'd.

   New /api/jobs/[jobId]/files/[fileId]/download route. requireTenant()
   first, then verify the caller's tenant is one of (clinicTenantId,
   labTenantId) on the parent job, then storage.getFileDownload via the
   admin SDK and stream the buffer back with Content-Disposition:
   attachment so the browser saves it under the original filename.
   listJobFiles now hands out that relative URL instead of the Appwrite
   one — same anchor in the panel, just routed through us.

2. Saves and edits feel slow whenever a notification is involved.
   Every mutation was awaiting logAudit, createNotification and
   syncFinanceForJob in sequence. None of these need to block the user
   response — audit is best-effort logging, notifications are async UX,
   and the finance sync is idempotent and re-runs on the next mutation
   anyway. Switched all 46 call sites across the action modules to
   void-fire-and-forget (matching the pattern we already used in
   clinic-pricing-actions). Net effect: each mutation drops ~3 sequential
   Appwrite roundtrips before the server action returns.
2026-05-22 01:05:25 +03:00
68 changed files with 5065 additions and 325 deletions
+8 -1
View File
@@ -37,6 +37,8 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"appwrite": "^24.2.0", "appwrite": "^24.2.0",
@@ -57,12 +59,16 @@
"recharts": "3.6.0", "recharts": "3.6.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"three": "^0.184.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.3.2", "zod": "^4.3.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": ["sharp", "unrs-resolver"], "onlyBuiltDependencies": [
"sharp",
"unrs-resolver"
],
"patchedDependencies": { "patchedDependencies": {
"node-fetch-native-with-agent@1.7.2": "patches/node-fetch-native-with-agent@1.7.2.patch" "node-fetch-native-with-agent@1.7.2": "patches/node-fetch-native-with-agent@1.7.2.patch"
} }
@@ -72,6 +78,7 @@
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
+483
View File
@@ -91,6 +91,12 @@ importers:
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: ^1.2.8 specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.15)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0))(@types/react@19.2.15)(@types/three@0.184.1)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0)
'@react-three/fiber':
specifier: ^9.6.1
version: 9.6.1(@types/react@19.2.15)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0)
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.3.0 version: 4.3.0
@@ -151,6 +157,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.6.0 version: 3.6.0
three:
specifier: ^0.184.0
version: 0.184.0
vaul: vaul:
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -173,6 +182,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.15) version: 19.2.3(@types/react@19.2.15)
'@types/three':
specifier: ^0.184.1
version: 0.184.1
eslint: eslint:
specifier: ^9.39.2 specifier: ^9.39.2
version: 9.39.4(jiti@2.7.0) version: 9.39.4(jiti@2.7.0)
@@ -250,6 +262,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6': '@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -265,6 +281,9 @@ packages:
'@date-fns/tz@1.5.0': '@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@dnd-kit/accessibility@3.1.1': '@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies: peerDependencies:
@@ -533,6 +552,14 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mediapipe/tasks-vision@0.10.17':
resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==}
'@monogrid/gainmap-js@3.4.0':
resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==}
peerDependencies:
three: '>= 0.159.0'
'@napi-rs/wasm-runtime@1.1.4': '@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies: peerDependencies:
@@ -1187,6 +1214,42 @@ packages:
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-three/drei@10.7.7':
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
peerDependencies:
'@react-three/fiber': ^9.0.0
react: ^19
react-dom: ^19
three: '>=0.159'
peerDependenciesMeta:
react-dom:
optional: true
'@react-three/fiber@9.6.1':
resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==}
peerDependencies:
expo: '>=43.0'
expo-asset: '>=8.4'
expo-file-system: '>=11.0'
expo-gl: '>=11.0'
react: '>=19 <19.3'
react-dom: '>=19 <19.3'
react-native: '>=0.78'
three: '>=0.156'
peerDependenciesMeta:
expo:
optional: true
expo-asset:
optional: true
expo-file-system:
optional: true
expo-gl:
optional: true
react-dom:
optional: true
react-native:
optional: true
'@reduxjs/toolkit@2.12.0': '@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies: peerDependencies:
@@ -1313,6 +1376,9 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -1343,6 +1409,9 @@ packages:
'@types/d3-timer@3.0.2': '@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/draco3d@1.4.10':
resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1355,17 +1424,34 @@ packages:
'@types/node@25.9.1': '@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/offscreencanvas@2019.7.3':
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
'@types/react': ^19.2.0 '@types/react': ^19.2.0
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': '*'
'@types/react@19.2.15': '@types/react@19.2.15':
resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/three@0.184.1':
resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==}
'@types/use-sync-external-store@0.0.6': '@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@typescript-eslint/eslint-plugin@8.59.4': '@typescript-eslint/eslint-plugin@8.59.4':
resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1535,6 +1621,14 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@use-gesture/core@10.3.1':
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
'@use-gesture/react@10.3.1':
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
peerDependencies:
react: '>= 16.8.0'
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies: peerDependencies:
@@ -1625,11 +1719,17 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.31: baseline-browser-mapping@2.10.31:
resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bignumber.js@9.3.1: bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
@@ -1649,6 +1749,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1665,6 +1768,12 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
camera-controls@3.1.2:
resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==}
engines: {node: '>=22.0.0', npm: '>=10.5.1'}
peerDependencies:
three: '>=0.126.1'
caniuse-lite@1.0.30001793: caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -1701,6 +1810,11 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1804,6 +1918,9 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
detect-gpu@5.0.70:
resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1815,6 +1932,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
draco3d@1.5.7:
resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==}
dunder-proto@1.0.1: dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2020,6 +2140,12 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.6.10:
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -2100,6 +2226,9 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
glsl-noise@0.0.0:
resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==}
gopd@1.2.0: gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2140,6 +2269,12 @@ packages:
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -2148,6 +2283,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0: immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
@@ -2237,6 +2375,9 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'} engines: {node: '>=0.12.0'}
is-promise@2.2.2:
resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==}
is-regex@1.2.1: is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2283,6 +2424,11 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
its-fine@2.0.0:
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
peerDependencies:
react: ^19.0.0
jiti@2.7.0: jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
@@ -2338,6 +2484,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2427,6 +2576,12 @@ packages:
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
maath@0.10.8:
resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==}
peerDependencies:
'@types/three': '>=0.134.0'
three: '>=0.134.0'
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2438,6 +2593,14 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
meshline@3.3.1:
resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==}
peerDependencies:
three: '>=0.137'
meshoptimizer@1.1.1:
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
micromatch@4.0.8: micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
@@ -2595,10 +2758,16 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
potpack@1.0.2:
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
promise-worker-transferable@1.0.4:
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2677,6 +2846,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
react-use-measure@2.1.7:
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
peerDependencies:
react: '>=16.13'
react-dom: '>=16.13'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.3: react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2705,6 +2883,10 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1: reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -2804,6 +2986,15 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stats-gl@2.4.2:
resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==}
peerDependencies:
'@types/three': '*'
three: '*'
stats.js@0.17.0:
resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2860,6 +3051,11 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
suspend-react@0.1.3:
resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
peerDependencies:
react: '>=17.0'
tailwind-merge@3.6.0: tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -2870,6 +3066,19 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
three-mesh-bvh@0.8.3:
resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==}
peerDependencies:
three: '>= 0.159.0'
three-stdlib@2.36.1:
resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==}
peerDependencies:
three: '>=0.128.0'
three@0.184.0:
resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -2881,6 +3090,19 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
troika-three-text@0.52.4:
resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==}
peerDependencies:
three: '>=0.125.0'
troika-three-utils@0.52.4:
resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==}
peerDependencies:
three: '>=0.125.0'
troika-worker-utils@0.52.0:
resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==}
ts-api-utils@2.5.0: ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -2893,6 +3115,9 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-rat@0.1.2:
resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==}
tw-animate-css@1.4.0: tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -2972,6 +3197,10 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utility-types@3.11.0:
resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==}
engines: {node: '>= 4'}
vaul@1.1.2: vaul@1.1.2:
resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
peerDependencies: peerDependencies:
@@ -2981,6 +3210,12 @@ packages:
victory-vendor@37.3.6: victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
webgl-constants@1.1.1:
resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==}
webgl-sdf-generator@1.1.1:
resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3022,6 +3257,21 @@ packages:
zod@4.4.3: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
zustand@5.0.13: zustand@5.0.13:
resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -3121,6 +3371,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@babel/runtime@7.29.2': {}
'@babel/template@7.28.6': '@babel/template@7.28.6':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
@@ -3146,6 +3398,8 @@ snapshots:
'@date-fns/tz@1.5.0': {} '@date-fns/tz@1.5.0': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.3)': '@dnd-kit/accessibility@3.1.1(react@19.2.3)':
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
@@ -3394,6 +3648,13 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mediapipe/tasks-vision@0.10.17': {}
'@monogrid/gainmap-js@3.4.0(three@0.184.0)':
dependencies:
promise-worker-transferable: 1.0.4
three: 0.184.0
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
@@ -4046,6 +4307,59 @@ snapshots:
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.15)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0))(@types/react@19.2.15)(@types/three@0.184.1)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0)':
dependencies:
'@babel/runtime': 7.29.2
'@mediapipe/tasks-vision': 0.10.17
'@monogrid/gainmap-js': 3.4.0(three@0.184.0)
'@react-three/fiber': 9.6.1(@types/react@19.2.15)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0)
'@use-gesture/react': 10.3.1(react@19.2.3)
camera-controls: 3.1.2(three@0.184.0)
cross-env: 7.0.3
detect-gpu: 5.0.70
glsl-noise: 0.0.0
hls.js: 1.6.16
maath: 0.10.8(@types/three@0.184.1)(three@0.184.0)
meshline: 3.3.1(three@0.184.0)
react: 19.2.3
stats-gl: 2.4.2(@types/three@0.184.1)(three@0.184.0)
stats.js: 0.17.0
suspend-react: 0.1.3(react@19.2.3)
three: 0.184.0
three-mesh-bvh: 0.8.3(three@0.184.0)
three-stdlib: 2.36.1(three@0.184.0)
troika-three-text: 0.52.4(three@0.184.0)
tunnel-rat: 0.1.2(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
utility-types: 3.11.0
zustand: 5.0.13(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
optionalDependencies:
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- '@types/react'
- '@types/three'
- immer
'@react-three/fiber@9.6.1(@types/react@19.2.15)(immer@11.1.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.184.0)':
dependencies:
'@babel/runtime': 7.29.2
'@types/webxr': 0.5.24
base64-js: 1.5.1
buffer: 6.0.3
its-fine: 2.0.0(@types/react@19.2.15)(react@19.2.3)
react: 19.2.3
react-use-measure: 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
scheduler: 0.27.0
suspend-react: 0.1.3(react@19.2.3)
three: 0.184.0
use-sync-external-store: 1.6.0(react@19.2.3)
zustand: 5.0.13(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
optionalDependencies:
react-dom: 19.2.3(react@19.2.3)
transitivePeerDependencies:
- '@types/react'
- immer
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.3)(redux@5.0.1))(react@19.2.3)': '@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies: dependencies:
'@standard-schema/spec': 1.1.0 '@standard-schema/spec': 1.1.0
@@ -4147,6 +4461,8 @@ snapshots:
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -4176,6 +4492,8 @@ snapshots:
'@types/d3-timer@3.0.2': {} '@types/d3-timer@3.0.2': {}
'@types/draco3d@1.4.10': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
@@ -4186,16 +4504,35 @@ snapshots:
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
'@types/offscreencanvas@2019.7.3': {}
'@types/react-dom@19.2.3(@types/react@19.2.15)': '@types/react-dom@19.2.3(@types/react@19.2.15)':
dependencies: dependencies:
'@types/react': 19.2.15 '@types/react': 19.2.15
'@types/react-reconciler@0.28.9(@types/react@19.2.15)':
dependencies:
'@types/react': 19.2.15
'@types/react@19.2.15': '@types/react@19.2.15':
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/stats.js@0.17.4': {}
'@types/three@0.184.1':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
fflate: 0.8.3
meshoptimizer: 1.1.1
'@types/use-sync-external-store@0.0.6': {} '@types/use-sync-external-store@0.0.6': {}
'@types/webxr@0.5.24': {}
'@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -4357,6 +4694,13 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2': '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true optional: true
'@use-gesture/core@10.3.1': {}
'@use-gesture/react@10.3.1(react@19.2.3)':
dependencies:
'@use-gesture/core': 10.3.1
react: 19.2.3
acorn-jsx@5.3.2(acorn@8.16.0): acorn-jsx@5.3.2(acorn@8.16.0):
dependencies: dependencies:
acorn: 8.16.0 acorn: 8.16.0
@@ -4469,8 +4813,14 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.31: {} baseline-browser-mapping@2.10.31: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bignumber.js@9.3.1: {} bignumber.js@9.3.1: {}
brace-expansion@1.1.14: brace-expansion@1.1.14:
@@ -4494,6 +4844,11 @@ snapshots:
node-releases: 2.0.45 node-releases: 2.0.45
update-browserslist-db: 1.2.3(browserslist@4.28.2) update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -4513,6 +4868,10 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
camera-controls@3.1.2(three@0.184.0):
dependencies:
three: 0.184.0
caniuse-lite@1.0.30001793: {} caniuse-lite@1.0.30001793: {}
chalk@4.1.2: chalk@4.1.2:
@@ -4550,6 +4909,10 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.6
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -4644,6 +5007,10 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
object-keys: 1.1.1 object-keys: 1.1.1
detect-gpu@5.0.70:
dependencies:
webgl-constants: 1.1.1
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {} detect-node-es@1.1.0: {}
@@ -4652,6 +5019,8 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
draco3d@1.5.7: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -5003,6 +5372,10 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
fflate@0.6.10: {}
fflate@0.8.3: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@@ -5091,6 +5464,8 @@ snapshots:
define-properties: 1.2.1 define-properties: 1.2.1
gopd: 1.2.0 gopd: 1.2.0
glsl-noise@0.0.0: {}
gopd@1.2.0: {} gopd@1.2.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
@@ -5123,10 +5498,16 @@ snapshots:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
hls.js@1.6.16: {}
ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
immediate@3.0.6: {}
immer@10.2.0: {} immer@10.2.0: {}
immer@11.1.8: {} immer@11.1.8: {}
@@ -5219,6 +5600,8 @@ snapshots:
is-number@7.0.0: {} is-number@7.0.0: {}
is-promise@2.2.2: {}
is-regex@1.2.1: is-regex@1.2.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -5271,6 +5654,13 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
set-function-name: 2.0.2 set-function-name: 2.0.2
its-fine@2.0.0(@types/react@19.2.15)(react@19.2.3):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.15)
react: 19.2.3
transitivePeerDependencies:
- '@types/react'
jiti@2.7.0: {} jiti@2.7.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -5319,6 +5709,10 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lie@3.3.0:
dependencies:
immediate: 3.0.6
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -5386,6 +5780,11 @@ snapshots:
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
maath@0.10.8(@types/three@0.184.1)(three@0.184.0):
dependencies:
'@types/three': 0.184.1
three: 0.184.0
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -5394,6 +5793,12 @@ snapshots:
merge2@1.4.1: {} merge2@1.4.1: {}
meshline@3.3.1(three@0.184.0):
dependencies:
three: 0.184.0
meshoptimizer@1.1.1: {}
micromatch@4.0.8: micromatch@4.0.8:
dependencies: dependencies:
braces: 3.0.3 braces: 3.0.3
@@ -5557,8 +5962,15 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
potpack@1.0.2: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
promise-worker-transferable@1.0.4:
dependencies:
is-promise: 2.2.2
lie: 3.3.0
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@@ -5629,6 +6041,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.15 '@types/react': 19.2.15
react-use-measure@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
optionalDependencies:
react-dom: 19.2.3(react@19.2.3)
react@19.2.3: {} react@19.2.3: {}
recharts@3.6.0(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1): recharts@3.6.0(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
@@ -5677,6 +6095,8 @@ snapshots:
gopd: 1.2.0 gopd: 1.2.0
set-function-name: 2.0.2 set-function-name: 2.0.2
require-from-string@2.0.2: {}
reselect@5.1.1: {} reselect@5.1.1: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
@@ -5820,6 +6240,13 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stats-gl@2.4.2(@types/three@0.184.1)(three@0.184.0):
dependencies:
'@types/three': 0.184.1
three: 0.184.0
stats.js@0.17.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0
@@ -5892,12 +6319,32 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
suspend-react@0.1.3(react@19.2.3):
dependencies:
react: 19.2.3
tailwind-merge@3.6.0: {} tailwind-merge@3.6.0: {}
tailwindcss@4.3.0: {} tailwindcss@4.3.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}
three-mesh-bvh@0.8.3(three@0.184.0):
dependencies:
three: 0.184.0
three-stdlib@2.36.1(three@0.184.0):
dependencies:
'@types/draco3d': 1.4.10
'@types/offscreencanvas': 2019.7.3
'@types/webxr': 0.5.24
draco3d: 1.5.7
fflate: 0.6.10
potpack: 1.0.2
three: 0.184.0
three@0.184.0: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinyglobby@0.2.16: tinyglobby@0.2.16:
@@ -5909,6 +6356,20 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
troika-three-text@0.52.4(three@0.184.0):
dependencies:
bidi-js: 1.0.3
three: 0.184.0
troika-three-utils: 0.52.4(three@0.184.0)
troika-worker-utils: 0.52.0
webgl-sdf-generator: 1.1.1
troika-three-utils@0.52.4(three@0.184.0):
dependencies:
three: 0.184.0
troika-worker-utils@0.52.0: {}
ts-api-utils@2.5.0(typescript@5.9.3): ts-api-utils@2.5.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -5922,6 +6383,14 @@ snapshots:
tslib@2.8.1: {} tslib@2.8.1: {}
tunnel-rat@0.1.2(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3):
dependencies:
zustand: 4.5.7(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)
transitivePeerDependencies:
- '@types/react'
- immer
- react
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
type-check@0.4.0: type-check@0.4.0:
@@ -6039,6 +6508,8 @@ snapshots:
dependencies: dependencies:
react: 19.2.3 react: 19.2.3
utility-types@3.11.0: {}
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -6065,6 +6536,10 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
webgl-constants@1.1.1: {}
webgl-sdf-generator@1.1.1: {}
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
@@ -6122,6 +6597,14 @@ snapshots:
zod@4.4.3: {} zod@4.4.3: {}
zustand@4.5.7(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.15
immer: 11.1.8
react: 19.2.3
zustand@5.0.13(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): zustand@5.0.13(@types/react@19.2.15)(immer@11.1.8)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
optionalDependencies: optionalDependencies:
'@types/react': 19.2.15 '@types/react': 19.2.15
@@ -88,6 +88,24 @@ export function LoginForm1({
/> />
</div> </div>
{state.mfaRequired && (
<div className="grid gap-2">
<Label htmlFor="otp">Authenticator kodu</Label>
<Input
id="otp"
name="otp"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
autoComplete="one-time-code"
autoFocus
placeholder="000000"
className="font-mono text-lg tracking-widest"
required
/>
</div>
)}
{state.error && ( {state.error && (
<p className="text-destructive text-sm text-center" role="alert"> <p className="text-destructive text-sm text-center" role="alert">
{state.error} {state.error}
@@ -1,7 +1,8 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useActionState, useEffect, useState, useTransition } from "react"; import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -108,17 +109,18 @@ function ApprovedRow({
deleteConnectionAction, deleteConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success("Bağlantı silindi."); toast.success("Bağlantı silindi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -190,11 +192,7 @@ function ApprovedRow({
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="outline">Vazgeç</Button> <Button type="button" variant="outline">Vazgeç</Button>
</DialogClose> </DialogClose>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" disabled={pending} variant="destructive"> <Button type="submit" disabled={pending} variant="destructive">
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useTransition } from "react"; import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2, X } from "lucide-react"; import { Check, Loader2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -64,17 +65,25 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
rejectConnectionAction, rejectConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (approveState.ok) toast.success("Bağlantı onaylandı."); if (approveState.ok) {
else if (approveState.error) toast.error(approveState.error); toast.success("Bağlantı onaylandı.");
}, [approveState]); router.refresh();
} else if (approveState.error) {
toast.error(approveState.error);
}
}, [approveState, router]);
useEffect(() => { useEffect(() => {
if (rejectState.ok) toast.success("Talep reddedildi."); if (rejectState.ok) {
else if (rejectState.error) toast.error(rejectState.error); toast.success("Talep reddedildi.");
}, [rejectState]); router.refresh();
} else if (rejectState.error) {
toast.error(rejectState.error);
}
}, [rejectState, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -94,22 +103,14 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<form <form action={approveAction}>
action={(fd) => {
startTransition(() => approveAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" disabled={approvePending || rejectPending}> <Button type="submit" size="sm" disabled={approvePending || rejectPending}>
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />} {approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla Onayla
</Button> </Button>
</form> </form>
<form <form action={rejectAction}>
action={(fd) => {
startTransition(() => rejectAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button <Button
type="submit" type="submit"
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useTransition } from "react"; import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -57,12 +58,16 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
cancelConnectionAction, cancelConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("Talep iptal edildi."); if (state.ok) {
else if (state.error) toast.error(state.error); toast.success("Talep iptal edildi.");
}, [state]); router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -81,11 +86,7 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
{dateFormatter.format(new Date(row.requestedAt))} {dateFormatter.format(new Date(row.requestedAt))}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}> <Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
+46 -5
View File
@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ArrowRight, FlaskConical, Link2, Plus, Stethoscope } from "lucide-react"; import { AlertCircle, ArrowRight, FlaskConical, Link2, Plus, Stethoscope } from "lucide-react";
import { DueBadge } from "@/components/due-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -109,6 +111,34 @@ export default async function DashboardPage() {
/> />
</div> </div>
{data.overdueJobs.length > 0 && (
<Card className="border-destructive/40 bg-destructive/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="text-destructive size-4" />
Geciken İşler ({data.overdueJobs.length})
</CardTitle>
<CardDescription>
Termin tarihi geçmiş ve henüz teslim edilmemiş işler.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y rounded-md border bg-background">
{data.overdueJobs.map((j) => (
<li key={j.$id} className="flex items-center gap-3 px-3 py-2 text-sm">
<Link href={`/jobs/${j.$id}`} className="flex-1 min-w-0 hover:underline">
<span className="font-medium">{j.counterpartName ?? "—"}</span>
<span className="text-muted-foreground"> · </span>
<span className="font-mono text-xs">{j.patientCode}</span>
</Link>
<DueBadge job={j} />
</li>
))}
</ul>
</CardContent>
</Card>
)}
{isClinic && data.approvedConnectionsCount === 0 && ( {isClinic && data.approvedConnectionsCount === 0 && (
<Card className="border-primary/20 bg-primary/5"> <Card className="border-primary/20 bg-primary/5">
<CardHeader> <CardHeader>
@@ -219,23 +249,34 @@ export default async function DashboardPage() {
</p> </p>
) : ( ) : (
<ul className="divide-y"> <ul className="divide-y">
{data.recentNotifications.map((n) => ( {data.recentNotifications.map((n) => {
const isWarning = n.severity === "warning";
return (
<li <li
key={n.$id} key={n.$id}
className={`flex items-start gap-3 py-2.5 ${n.read ? "opacity-70" : ""}`} className={`flex items-start gap-3 py-2.5 ${n.read ? "opacity-70" : ""}`}
> >
<span <span
className={`mt-1.5 size-2 shrink-0 rounded-full ${n.read ? "bg-muted" : "bg-primary"}`} className={`mt-1.5 size-2 shrink-0 rounded-full ${
n.read
? "bg-muted"
: isWarning
? "bg-amber-500"
: "bg-primary"
}`}
aria-hidden aria-hidden
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{n.message}</p> <p className={`text-sm leading-tight ${isWarning && !n.read ? "font-medium" : ""}`}>
{n.message}
</p>
<p className="text-muted-foreground mt-0.5 text-xs"> <p className="text-muted-foreground mt-0.5 text-xs">
{datetimeFormatter.format(new Date(n.$createdAt))} {datetimeFormatter.format(new Date(n.$createdAt))}
</p> </p>
</div> </div>
</li> </li>
))} );
})}
</ul> </ul>
)} )}
</CardContent> </CardContent>
@@ -0,0 +1,100 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { CounterpartBalance } from "@/lib/appwrite/payment-queries";
import { RecordPaymentDialog } from "./record-payment-dialog";
function formatMoney(amount: number, currency: string): string {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function BalancesCard({
balances,
counterpartNames,
selfKind,
defaultCurrency,
}: {
balances: CounterpartBalance[];
counterpartNames: Record<string, string>;
selfKind: "lab" | "clinic";
defaultCurrency: string;
}) {
const isLab = selfKind === "lab";
return (
<Card>
<CardHeader>
<CardTitle>{isLab ? "Klinik Bakiyeleri" : "Laboratuvar Bakiyeleri"}</CardTitle>
<CardDescription>
{isLab
? "Her klinik için açık bakiye ve son tahsilatlar. Toplu ödemeleri buradan girebilirsiniz."
: "Çalıştığınız her laboratuvar için açık bakiye ve son ödemeleriniz."}
</CardDescription>
</CardHeader>
<CardContent>
{balances.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bağlantılarınız için henüz finansal hareket yok.
</p>
) : (
<ul className="divide-y rounded-md border">
{balances.map((b) => {
const name = counterpartNames[b.counterpartTenantId] ?? "—";
const settled = b.open <= 0.01;
return (
<li
key={b.counterpartTenantId}
className="flex flex-wrap items-center gap-3 px-3 py-3"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{name}</p>
<p className="text-muted-foreground text-xs">
Fatura: {formatMoney(b.invoiced, b.currency)} · Ödenen:{" "}
{formatMoney(b.paid, b.currency)}
{b.lastPaymentAt && (
<>
{" "}· Son ödeme:{" "}
{dateFormatter.format(new Date(b.lastPaymentAt))}
</>
)}
</p>
</div>
<div className="text-right">
<p
className={`text-base font-semibold tabular-nums ${
settled
? "text-muted-foreground"
: isLab
? "text-emerald-600 dark:text-emerald-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
{formatMoney(b.open, b.currency)}
</p>
<p className="text-muted-foreground text-xs">
{settled ? "Kapalı" : isLab ? "Açık alacak" : "Açık borç"}
</p>
</div>
<RecordPaymentDialog
counterpartTenantId={b.counterpartTenantId}
counterpartName={name}
selfKind={selfKind}
defaultCurrency={b.currency || defaultCurrency}
openAmount={b.open > 0 ? b.open : undefined}
/>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useActionState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FileText, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { deletePaymentAction } from "@/lib/appwrite/payment-actions";
import {
PAYMENT_METHOD_LABELS,
initialPaymentActionState,
} from "@/lib/appwrite/payment-types";
import type { Payment } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function formatMoney(amount: number, currency: string): string {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
export function MyPendingPaymentsCard({
rows,
counterpartNames,
}: {
rows: Payment[];
counterpartNames: Record<string, string>;
}) {
if (rows.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle>Gönderdiğim Ödemeler</CardTitle>
<CardDescription>
Laboratuvar onayı bekleyen veya reddedilen bildirimleriniz. Onaylanan
ödemeler artık burada gözükmez açık bakiyenize işlenir.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y rounded-md border">
{rows.map((p) => (
<Row
key={p.$id}
payment={p}
counterpartName={counterpartNames[p.counterpartTenantId] ?? "—"}
/>
))}
</ul>
</CardContent>
</Card>
);
}
function Row({
payment,
counterpartName,
}: {
payment: Payment;
counterpartName: string;
}) {
const router = useRouter();
const [state, action, pending] = useActionState(
deletePaymentAction,
initialPaymentActionState,
);
useEffect(() => {
if (state.ok) {
toast.success("Bildirim silindi.");
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
const isPending = payment.status === "pending";
const isRejected = payment.status === "rejected";
return (
<li className="flex flex-wrap items-center gap-3 px-3 py-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{counterpartName}</p>
<p className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(payment.paymentDate))}
{payment.method && (
<> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method}</>
)}
{payment.notes && <> · {payment.notes}</>}
</p>
</div>
<div className="text-right">
<p className="text-base font-semibold tabular-nums">
{formatMoney(payment.amount, payment.currency)}
</p>
<Badge
variant="outline"
className={
isPending
? "text-amber-600 dark:text-amber-400"
: isRejected
? "text-destructive"
: ""
}
>
{isPending ? "Onay bekliyor" : isRejected ? "Reddedildi" : "Onaylandı"}
</Badge>
</div>
<div className="flex gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/finance/payments/${payment.$id}/receipt`}>
<FileText className="size-4" />
Makbuz
</Link>
</Button>
{isPending && (
<form action={action}>
<input type="hidden" name="id" value={payment.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Geri al
</Button>
</form>
)}
</div>
</li>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
confirmPaymentAction,
rejectPaymentAction,
} from "@/lib/appwrite/payment-actions";
import {
PAYMENT_METHOD_LABELS,
initialPaymentActionState,
} from "@/lib/appwrite/payment-types";
import type { Payment } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function formatMoney(amount: number, currency: string): string {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
export function PendingPaymentsCard({
rows,
counterpartNames,
}: {
rows: Payment[];
counterpartNames: Record<string, string>;
}) {
if (rows.length === 0) return null;
return (
<Card className="border-amber-300/50 dark:border-amber-500/30">
<CardHeader>
<CardTitle>Onay Bekleyen Ödemeler</CardTitle>
<CardDescription>
Klinikler aşağıdaki ödemeleri yaptıklarını bildirdi. Onayladığınızda
açık bakiyeden düşülür.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="divide-y rounded-md border">
{rows.map((p) => (
<PendingRow
key={p.$id}
payment={p}
counterpartName={counterpartNames[p.tenantId] ?? "—"}
/>
))}
</ul>
</CardContent>
</Card>
);
}
function PendingRow({
payment,
counterpartName,
}: {
payment: Payment;
counterpartName: string;
}) {
const router = useRouter();
const [confirmState, confirmAction, confirmPending] = useActionState(
confirmPaymentAction,
initialPaymentActionState,
);
const [rejectState, rejectAction, rejectPending] = useActionState(
rejectPaymentAction,
initialPaymentActionState,
);
useEffect(() => {
if (confirmState.ok) {
toast.success("Ödeme onaylandı.");
router.refresh();
} else if (confirmState.error) {
toast.error(confirmState.error);
}
}, [confirmState, router]);
useEffect(() => {
if (rejectState.ok) {
toast.success("Ödeme reddedildi.");
router.refresh();
} else if (rejectState.error) {
toast.error(rejectState.error);
}
}, [rejectState, router]);
const busy = confirmPending || rejectPending;
return (
<li className="flex flex-wrap items-center gap-3 px-3 py-3">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{counterpartName}</p>
<p className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(payment.paymentDate))}
{payment.method && (
<> · {PAYMENT_METHOD_LABELS[payment.method] ?? payment.method}</>
)}
{payment.notes && <> · {payment.notes}</>}
</p>
</div>
<div className="text-right">
<p className="text-base font-semibold tabular-nums">
{formatMoney(payment.amount, payment.currency)}
</p>
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
Onay bekliyor
</Badge>
</div>
<div className="flex gap-2">
<form action={confirmAction}>
<input type="hidden" name="id" value={payment.$id} />
<Button type="submit" size="sm" disabled={busy}>
{confirmPending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla
</Button>
</form>
<form action={rejectAction}>
<input type="hidden" name="id" value={payment.$id} />
<Button type="submit" size="sm" variant="outline" disabled={busy}>
{rejectPending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
Reddet
</Button>
</form>
</div>
</li>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Banknote, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { recordPaymentAction } from "@/lib/appwrite/payment-actions";
import {
PAYMENT_METHOD_OPTIONS,
initialPaymentFormState,
} from "@/lib/appwrite/payment-types";
export function RecordPaymentDialog({
counterpartTenantId,
counterpartName,
selfKind,
defaultCurrency,
openAmount,
triggerLabel,
}: {
counterpartTenantId: string;
counterpartName: string;
selfKind: "lab" | "clinic";
defaultCurrency: string;
openAmount?: number;
triggerLabel?: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [state, action, pending] = useActionState(
recordPaymentAction,
initialPaymentFormState,
);
useEffect(() => {
if (state.ok) {
toast.success("Ödeme kaydedildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
const label = triggerLabel ?? (selfKind === "lab" ? "Ödeme Al" : "Ödeme Yap");
const title = selfKind === "lab" ? "Tahsilat Kaydı" : "Ödeme Kaydı";
const today = new Date().toISOString().slice(0, 10);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button size="sm" onClick={() => setOpen(true)}>
<Banknote className="size-4" />
{label}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>{title} {counterpartName}</DialogTitle>
<DialogDescription>
{selfKind === "lab"
? "Bu kliniğin yaptığı toplu ödemeyi kaydedin. Açık bakiyeden otomatik düşülür."
: "Bu laboratuvara yaptığınız ödemeyi kaydedin."}
{typeof openAmount === "number" && (
<>
{" "}Açık bakiye:{" "}
<strong className="tabular-nums">
{openAmount.toLocaleString("tr-TR")} {defaultCurrency}
</strong>
</>
)}
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-3">
<input type="hidden" name="counterpartTenantId" value={counterpartTenantId} />
<div className="grid grid-cols-[minmax(0,1fr)_100px] gap-3">
<div className="grid gap-2">
<Label htmlFor="amount">Tutar *</Label>
<Input
id="amount"
name="amount"
type="number"
step="0.01"
min="0"
required
defaultValue={openAmount ?? undefined}
/>
{state.fieldErrors?.amount && (
<p className="text-destructive text-xs">{state.fieldErrors.amount}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Para</Label>
<Input
id="currency"
name="currency"
defaultValue={defaultCurrency}
maxLength={8}
style={{ textTransform: "uppercase" }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="paymentDate">Tarih</Label>
<Input
id="paymentDate"
name="paymentDate"
type="date"
defaultValue={today}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="method">Yöntem</Label>
<Select name="method" defaultValue="bank">
<SelectTrigger id="method">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Not (opsiyonel)</Label>
<Textarea
id="notes"
name="notes"
rows={2}
maxLength={1000}
placeholder="Örn. Ağustos toplu, dekont no 12345"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Banknote className="size-4" />}
Kaydet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+92 -11
View File
@@ -1,9 +1,19 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listApprovedConnections } from "@/lib/appwrite/connection-queries";
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries"; import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
import {
computeBalancesByCounterpart,
filterPendingForConfirmation,
listIncomingPayments,
listPayments,
} from "@/lib/appwrite/payment-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { BalancesCard } from "./components/balances-card";
import { FinanceTable } from "./components/finance-table"; import { FinanceTable } from "./components/finance-table";
import { MyPendingPaymentsCard } from "./components/my-pending-payments-card";
import { PendingPaymentsCard } from "./components/pending-payments-card";
export const metadata = { export const metadata = {
title: "DLS — Finans", title: "DLS — Finans",
@@ -24,26 +34,86 @@ export default async function FinancePage() {
} catch { } catch {
redirect("/onboarding"); redirect("/onboarding");
} }
if (!ctx.kind) redirect("/onboarding");
const kind = ctx.kind;
const [entries, ownPayments, incomingPayments, connections] = await Promise.all([
listFinanceEntries(ctx.tenantId),
listPayments(ctx.tenantId),
listIncomingPayments(ctx.tenantId),
listApprovedConnections(ctx.tenantId),
]);
// Same physical payment can show up in both lists for the same tenant in
// pathological cases; dedupe by $id to be safe.
const seenIds = new Set<string>();
const payments = [...ownPayments, ...incomingPayments].filter((p) =>
seenIds.has(p.$id) ? false : (seenIds.add(p.$id), true),
);
const entries = await listFinanceEntries(ctx.tenantId);
const stats = summarizeFinance(entries); const stats = summarizeFinance(entries);
const isLab = ctx.kind === "lab"; const isLab = kind === "lab";
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
const balances = computeBalancesByCounterpart({
kind,
selfTenantId: ctx.tenantId,
entries,
payments,
});
const pendingForApproval = filterPendingForConfirmation(payments, ctx.tenantId, kind);
// Clinic-side: payments this clinic submitted that are either still waiting
// for the lab to confirm, or were rejected. Both shapes are useful so the
// clinic can chase the lab or fix a wrong submission.
const myPendingOrRejected = payments
.filter(
(p) =>
p.tenantId === ctx.tenantId &&
(p.status === "pending" || p.status === "rejected"),
)
.sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1));
const counterpartNames: Record<string, string> = {};
for (const c of connections) {
const id = isLab ? c.clinicTenantId : c.labTenantId;
counterpartNames[id] = c.counterpart?.companyName ?? "—";
}
const totalPaid = payments.reduce((sum, p) => {
if (p.status && p.status !== "confirmed") return sum;
const ownInflow = p.tenantId === ctx.tenantId && p.direction === (isLab ? "inflow" : "outflow");
const incoming =
p.counterpartTenantId === ctx.tenantId &&
p.direction === (isLab ? "outflow" : "inflow");
return sum + (ownInflow || incoming ? p.amount : 0);
}, 0);
const totalOpen = balances.reduce(
(sum, b) => sum + (b.open > 0 ? b.open : 0),
0,
);
return ( return (
<div className="flex-1 space-y-6 px-6"> <div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Finans</h1> <h1 className="text-2xl font-bold tracking-tight">Finans</h1>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."} {isLab
? "Klinik bazlı açık bakiye ve toplu tahsilat."
: "Laboratuvar bazlı borç ve ödeme akışı."}
</p> </p>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"} label={isLab ? "Toplam Açık Alacak" : "Toplam Açık Borç"}
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)} value={formatMoney(totalOpen, defaultCurrency)}
tone={isLab ? "positive" : "negative"} tone={isLab ? "positive" : "negative"}
/> />
<StatCard
label={isLab ? "Tahsil Edilen" : "Ödenen"}
value={formatMoney(totalPaid, defaultCurrency)}
tone="neutral"
/>
<StatCard <StatCard
label="Bu Ay Gelir" label="Bu Ay Gelir"
value={formatMoney(stats.incomeThisMonth, stats.currency)} value={formatMoney(stats.incomeThisMonth, stats.currency)}
@@ -54,18 +124,29 @@ export default async function FinancePage() {
value={formatMoney(stats.expenseThisMonth, stats.currency)} value={formatMoney(stats.expenseThisMonth, stats.currency)}
tone="negative" tone="negative"
/> />
<StatCard
label="Toplam Kayıt"
value={String(entries.length)}
tone="neutral"
/>
</div> </div>
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
{!isLab && (
<MyPendingPaymentsCard
rows={myPendingOrRejected}
counterpartNames={counterpartNames}
/>
)}
<BalancesCard
balances={balances}
counterpartNames={counterpartNames}
selfKind={kind}
defaultCurrency={defaultCurrency}
/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Hareketler</CardTitle> <CardTitle>Hareketler</CardTitle>
<CardDescription> <CardDescription>
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde. Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -0,0 +1,23 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ReceiptControls() {
return (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 print:hidden">
<Button asChild variant="outline" size="sm">
<Link href="/finance">
<ArrowLeft className="size-4" />
Finansa dön
</Link>
</Button>
<Button onClick={() => window.print()} size="sm">
<Printer className="size-4" />
Yazdır / PDF
</Button>
</div>
);
}
@@ -0,0 +1,186 @@
import { notFound, redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { ReceiptControls } from "./components/receipt-controls";
import {
DATABASE_ID,
TABLES,
type Payment,
type TenantSettings,
} from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { PAYMENT_METHOD_LABELS } from "@/lib/appwrite/payment-types";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
export const metadata = {
title: "DLS — Makbuz",
};
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "long",
year: "numeric",
});
function formatMoney(amount: number, currency: string): string {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
async function loadTenantSettings(tenantId: string): Promise<TenantSettings | null> {
const { tablesDB } = createAdminClient();
try {
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
return (result.rows[0] as unknown as TenantSettings) ?? null;
} catch {
return null;
}
}
export default async function PaymentReceiptPage({
params,
}: {
params: Promise<{ paymentId: string }>;
}) {
const { paymentId } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const { tablesDB } = createAdminClient();
let payment: Payment;
try {
payment = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
paymentId,
)) as unknown as Payment;
} catch {
notFound();
}
// Only the two parties can see the receipt.
if (
payment.tenantId !== ctx.tenantId &&
payment.counterpartTenantId !== ctx.tenantId
) {
notFound();
}
// 'inflow' means the lab received money from the clinic.
// From the row alone we know whose tenantId is which side because the lab
// always issues inflow and the clinic outflow. Resolve them so the
// receipt header reads naturally regardless of who recorded the row.
const labId = payment.direction === "inflow" ? payment.tenantId : payment.counterpartTenantId;
const clinicId = payment.direction === "inflow" ? payment.counterpartTenantId : payment.tenantId;
const [lab, clinic] = await Promise.all([
loadTenantSettings(labId),
loadTenantSettings(clinicId),
]);
const statusLabel =
payment.status === "confirmed"
? "Onaylı"
: payment.status === "pending"
? "Onay bekliyor"
: payment.status === "rejected"
? "Reddedildi"
: "—";
return (
<div className="bg-muted/40 min-h-screen px-6 py-8 print:bg-white print:p-0">
<div className="mx-auto max-w-2xl">
<ReceiptControls />
<article className="bg-card text-card-foreground rounded-lg border p-8 shadow-sm print:rounded-none print:border-0 print:shadow-none">
<header className="border-b pb-4">
<p className="text-muted-foreground text-xs uppercase tracking-wide">
Tahsilat Makbuzu
</p>
<h1 className="text-2xl font-bold tracking-tight">
{lab?.companyName ?? "Laboratuvar"}
</h1>
{lab?.companyTaxId && (
<p className="text-muted-foreground text-sm">VKN: {lab.companyTaxId}</p>
)}
{lab?.companyAddress && (
<p className="text-muted-foreground whitespace-pre-wrap text-sm">
{lab.companyAddress}
</p>
)}
</header>
<section className="grid gap-4 py-6 sm:grid-cols-2">
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Tahsil edilen
</p>
<p className="font-medium">{clinic?.companyName ?? "Klinik"}</p>
{clinic?.companyTaxId && (
<p className="text-muted-foreground text-xs">VKN: {clinic.companyTaxId}</p>
)}
</div>
<div className="sm:text-right">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Ödeme tarihi
</p>
<p className="font-medium">
{dateFormatter.format(new Date(payment.paymentDate))}
</p>
</div>
</section>
<section className="border-y py-6">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Tutar
</p>
<p className="text-3xl font-semibold tabular-nums">
{formatMoney(payment.amount, payment.currency)}
</p>
</section>
<section className="grid gap-4 py-6 sm:grid-cols-2 text-sm">
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Ödeme yöntemi
</p>
<p>
{payment.method
? (PAYMENT_METHOD_LABELS[payment.method] ?? payment.method)
: "—"}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Durum
</p>
<p>{statusLabel}</p>
</div>
{payment.notes && (
<div className="sm:col-span-2">
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Not
</p>
<p className="whitespace-pre-wrap">{payment.notes}</p>
</div>
)}
</section>
<footer className="text-muted-foreground border-t pt-4 text-xs">
Makbuz no: {payment.$id} · Düzenlendi: {dateFormatter.format(new Date(payment.$createdAt))}
</footer>
</article>
</div>
</div>
);
}
@@ -1,13 +1,15 @@
"use client"; "use client";
import { useActionState, useEffect, useState, useTransition } from "react"; import { useActionState, useEffect, useState } from "react";
import { import {
ArrowRight, ArrowRight,
Check, Check,
CircleAlert, CircleAlert,
Loader2, Loader2,
Play,
PackageCheck, PackageCheck,
Play,
RotateCcw,
Send,
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -26,15 +28,13 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
acceptJobAction, acceptJobAction,
advanceStepAction, approveAtClinicAction,
cancelJobAction, cancelJobAction,
handToClinicAction,
markDeliveredAction, markDeliveredAction,
requestRevisionAction,
} from "@/lib/appwrite/job-actions"; } from "@/lib/appwrite/job-actions";
import { import { initialJobActionState } from "@/lib/appwrite/job-types";
JOB_STEP_LABELS,
JOB_STEP_ORDER,
initialJobActionState,
} from "@/lib/appwrite/job-types";
import type { Job, TenantKind } from "@/lib/appwrite/schema"; import type { Job, TenantKind } from "@/lib/appwrite/schema";
type Side = "clinic" | "lab"; type Side = "clinic" | "lab";
@@ -52,12 +52,32 @@ export function JobActionsPanel({
const isLab = side === "lab"; const isLab = side === "lab";
const isClinic = side === "clinic"; const isClinic = side === "clinic";
const location = job.location ?? "at_lab";
const isAtLab = location === "at_lab";
const isAtClinic = location === "at_clinic";
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{/* Pending pickup — lab accepts */}
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />} {isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
{/* Lab is producing — push to clinic for prova / final delivery */}
{isLab && job.status === "in_progress" && isAtLab && (
<HandToClinicButton job={job} />
)}
{/* Clinic finished the prova — approve and send back to lab */}
{isClinic && job.status === "in_progress" && isAtClinic && (
<>
<ApproveAtClinicButton job={job} />
<RequestRevisionButton job={job} />
</>
)}
{/* Final delivery — clinic took it from the lab */}
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />} {isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
{/* Cancel — only while the job hasn't started yet */}
{(isClinic || isLab) && job.status === "pending" && ( {(isClinic || isLab) && job.status === "pending" && (
<CancelButton jobId={job.$id} /> <CancelButton jobId={job.$id} />
)} )}
@@ -67,19 +87,15 @@ export function JobActionsPanel({
function AcceptButton({ jobId }: { jobId: string }) { function AcceptButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState); const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("İş işleme alındı."); // Success path redirects from the server action, so state.ok never
else if (state.error) toast.error(state.error); // shows up here — we only need to surface errors.
if (state.error) toast.error(state.error);
}, [state]); }, [state]);
return ( return (
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
@@ -89,50 +105,40 @@ function AcceptButton({ jobId }: { jobId: string }) {
); );
} }
function AdvanceButton({ job }: { job: Job }) { function HandToClinicButton({ job }: { job: Job }) {
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState); const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.error) toast.error(state.error);
toast.success("Aşama ilerletildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]); }, [state]);
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1; const isFinal = job.currentStep === "cila_bitim";
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1; const stageLabel =
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"; job.currentStep === "alt_yapi_prova"
const nextLabel = isFinal ? "alt yapı"
? "Gönderildi olarak işaretle" : job.currentStep === "ust_yapi_prova"
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]]; ? "üst yapı"
: "cila/bitim";
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />} {isFinal ? <PackageCheck className="size-4" /> : <Send className="size-4" />}
{isFinal ? "Gönderildi" : "Sonraki Aşama"} {isFinal ? "Cila Bitim — Nihai Teslime Gönder" : `${stageLabel === "alt yapı" ? "Alt Yapı" : "Üst Yapı"} Provaya Gönder`}
</Button> </Button>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`} {isFinal ? "Nihai teslime gönderilsin mi?" : "Kliniğe gönderilsin mi?"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{isFinal {isFinal
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek." ? "Cila ve bitim tamamlandı; iş 'Gönderildi' durumuna geçer. Klinik teslim aldığında nihai onay verecek."
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`} : `${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası için iş klinik tarafına geçer. Klinik provayı onayladığında size geri dönecek.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form action={action} className="grid gap-3">
action={(fd) => {
startTransition(() => action(fd));
}}
className="grid gap-3"
>
<input type="hidden" name="jobId" value={job.$id} /> <input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="note">Not (opsiyonel)</Label> <Label htmlFor="note">Not (opsiyonel)</Label>
@@ -141,7 +147,7 @@ function AdvanceButton({ job }: { job: Job }) {
name="note" name="note"
rows={3} rows={3}
maxLength={1000} maxLength={1000}
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı." placeholder="Örn. Renk A2, oklüzal kontak tamam"
/> />
</div> </div>
<DialogFooter> <DialogFooter>
@@ -151,8 +157,118 @@ function AdvanceButton({ job }: { job: Job }) {
</Button> </Button>
</DialogClose> </DialogClose>
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Onayla Gönder
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function ApproveAtClinicButton({ job }: { job: Job }) {
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.error) toast.error(state.error);
}, [state]);
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button onClick={() => setOpen(true)}>
<Check className="size-4" />
{stageLabel === "alt yapı" ? "Alt Yapı Provası Tamam" : "Üst Yapı Provası Tamam"}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{`${stageLabel === "alt yapı" ? "Alt yapı" : "Üst yapı"} provası onaylansın mı?`}
</DialogTitle>
<DialogDescription>
Prova başarılı işaretlendiğinde bir sonraki aşamaya geçer ve
laboratuvara geri döner.
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-3">
<input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2">
<Label htmlFor="note">Not (opsiyonel)</Label>
<Textarea
id="note"
name="note"
rows={3}
maxLength={1000}
placeholder="Örn. Renk uyumlu, oklüzyon tamam"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <ArrowRight className="size-4" />}
Onayla ve gönder
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function RequestRevisionButton({ job }: { job: Job }) {
const [state, action, pending] = useActionState(
requestRevisionAction,
initialJobActionState,
);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.error) toast.error(state.error);
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button variant="outline" onClick={() => setOpen(true)}>
<RotateCcw className="size-4" />
Düzeltme İste
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Provayı reddet, lab&apos;a geri gönder</DialogTitle>
<DialogDescription>
Bu aşamayı reddettiğinizde aynı adımda kalır ve laboratuvar
yeniden çalışır. Neyin düzeltilmesi gerektiğini lütfen yazın.
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-3">
<input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2">
<Label htmlFor="note">Düzeltme notu *</Label>
<Textarea
id="note"
name="note"
rows={4}
required
maxLength={1000}
placeholder="Örn. Distalde temas yok, oklüzyon yüksek geldi."
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" variant="destructive" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <RotateCcw className="size-4" />}
Düzeltme İste
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -163,19 +279,13 @@ function AdvanceButton({ job }: { job: Job }) {
function DeliverButton({ jobId }: { jobId: string }) { function DeliverButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState); const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("İş teslim alındı."); if (state.error) toast.error(state.error);
else if (state.error) toast.error(state.error);
}, [state]); }, [state]);
return ( return (
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
@@ -187,16 +297,10 @@ function DeliverButton({ jobId }: { jobId: string }) {
function CancelButton({ jobId }: { jobId: string }) { function CancelButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState); const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.error) toast.error(state.error);
toast.success("İş iptal edildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]); }, [state]);
return ( return (
@@ -215,11 +319,7 @@ function CancelButton({ jobId }: { jobId: string }) {
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useActionState, useEffect, useRef, useState, useTransition } from "react"; import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react"; import dynamic from "next/dynamic";
import { Download, Eye, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -24,6 +25,15 @@ import {
} from "@/lib/appwrite/job-file-types"; } from "@/lib/appwrite/job-file-types";
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries"; import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
// three.js + react-three is ~500KB minified; only load it when the user
// actually opens the viewer dialog.
const STLViewer = dynamic(
() => import("@/components/stl-viewer").then((m) => m.STLViewer),
{ ssr: false, loading: () => null },
);
const VIEWABLE_RE = /\.(stl|ply|obj)$/i;
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -240,35 +250,110 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
deleteJobFileAction, deleteJobFileAction,
initialJobFileActionState, initialJobFileActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
const isArchived = Boolean(file.archivedAt);
const isViewable = !isArchived && VIEWABLE_RE.test(file.name);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success("Dosya silindi."); toast.success("Dosya silindi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
function triggerDownload() {
// Use a programmatic anchor click — the server route streams the file
// with Content-Disposition: attachment, so the browser hands it straight
// to the download manager. Toast confirms it left our side.
const a = document.createElement("a");
a.href = file.url;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setDownloadOpen(false);
toast.success("İndirme başladı.", { description: file.name });
}
return ( return (
<li className="flex items-center gap-3 px-3 py-2"> <li className={`flex items-center gap-3 px-3 py-2 ${isArchived ? "opacity-60" : ""}`}>
<span className="text-muted-foreground">{kindIcon(file.kind)}</span> <span className="text-muted-foreground">{kindIcon(file.kind)}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p> <p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)} {JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)}
{isArchived && (
<>
{" · "}
<span className="text-amber-600 dark:text-amber-400">
Arşivlendi {new Date(file.archivedAt!).toLocaleDateString("tr-TR")}
</span>
</>
)}
</p> </p>
</div> </div>
<Badge variant="outline" className="hidden sm:inline-flex"> <Badge variant="outline" className="hidden sm:inline-flex">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} {JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
</Badge> </Badge>
<Button asChild size="sm" variant="outline"> {isViewable && (
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}> <Dialog open={viewerOpen} onOpenChange={setViewerOpen}>
<Button
size="sm"
variant="outline"
onClick={() => setViewerOpen(true)}
>
<Eye className="size-4" />
</Button>
<DialogContent className="h-[85vh] max-w-5xl gap-0 p-0">
<DialogHeader className="border-b px-4 py-3">
<DialogTitle className="truncate text-base">{file.name}</DialogTitle>
<DialogDescription className="text-xs">
{formatSize(file.size)} · Sürükleyerek döndürün, kaydırarak yaklaşın.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1">
{viewerOpen && <STLViewer url={file.url} filename={file.name} />}
</div>
</DialogContent>
</Dialog>
)}
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button
size="sm"
variant="outline"
onClick={() => setDownloadOpen(true)}
disabled={isArchived}
title={isArchived ? "Bu dosya arşivlendi; indirilebilir kopyası yok." : undefined}
>
<Download className="size-4" /> <Download className="size-4" />
</a> </Button>
</Button> <DialogContent>
<DialogHeader>
<DialogTitle>Dosya indirilsin mi?</DialogTitle>
<DialogDescription>
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground"> · {formatSize(file.size)}</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="button" onClick={triggerDownload}>
<Download className="size-4" />
İndir
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}> <Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@@ -284,11 +369,7 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
Vazgeç Vazgeç
</Button> </Button>
</DialogClose> </DialogClose>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="rowId" value={file.$id} /> <input type="hidden" name="rowId" value={file.$id} />
<Button type="submit" variant="destructive" disabled={pending}> <Button type="submit" variant="destructive" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
+65 -29
View File
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { DueBadge } from "@/components/due-badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listJobFiles } from "@/lib/appwrite/job-file-queries"; import { listJobFiles } from "@/lib/appwrite/job-file-queries";
@@ -10,6 +11,7 @@ import { listJobHistory } from "@/lib/appwrite/job-history-queries";
import { getPatient } from "@/lib/appwrite/patient-queries"; import { getPatient } from "@/lib/appwrite/patient-queries";
import { toPlain } from "@/lib/appwrite/serialize"; import { toPlain } from "@/lib/appwrite/serialize";
import { import {
JOB_LOCATION_LABELS,
JOB_STATUS_LABELS, JOB_STATUS_LABELS,
JOB_STEP_LABELS, JOB_STEP_LABELS,
JOB_STEP_ORDER, JOB_STEP_ORDER,
@@ -115,9 +117,12 @@ export default async function JobDetailPage({
</p> </p>
</div> </div>
<div className="flex flex-col items-end gap-3"> <div className="flex flex-col items-end gap-3">
<Badge variant="secondary" className="text-sm"> <div className="flex items-center gap-2">
{JOB_STATUS_LABELS[job.status]} <DueBadge job={job} />
</Badge> <Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
</div>
<JobActionsPanel job={job} side={side} kind={ctx.kind} /> <JobActionsPanel job={job} side={side} kind={ctx.kind} />
</div> </div>
</div> </div>
@@ -141,6 +146,15 @@ export default async function JobDetailPage({
<Info label="Mevcut Aşama"> <Info label="Mevcut Aşama">
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"} {job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
</Info> </Info>
<Info label="Şu An">
{job.status === "pending"
? "Klinikte (lab teslim alacak)"
: job.status === "delivered"
? "Hasta'ya teslim edildi"
: job.status === "cancelled"
? "İptal"
: JOB_LOCATION_LABELS[job.location ?? "at_lab"]}
</Info>
<div className="md:col-span-2"> <div className="md:col-span-2">
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide"> <p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
Dişler ({job.teeth?.length ?? job.memberCount}) Dişler ({job.teeth?.length ?? job.memberCount})
@@ -245,33 +259,55 @@ export default async function JobDetailPage({
</CardContent> </CardContent>
</Card> </Card>
{history.length > 0 && ( <Card>
<Card> <CardHeader>
<CardHeader> <CardTitle>Akış Geçmişi</CardTitle>
<CardTitle>Aşama Geçmişi</CardTitle> <CardDescription>
<CardDescription>Tamamlanan aşamaların kaydı.</CardDescription> İşin aşama transition&apos;ları, kim yaptı ve hangi notla.
</CardHeader> </CardDescription>
<CardContent> </CardHeader>
<ol className="space-y-3"> <CardContent>
{history.map((h) => ( {history.length === 0 ? (
<li key={h.$id} className="border-l-2 border-primary/30 pl-4"> <p className="text-muted-foreground text-sm">
<div className="flex flex-wrap items-baseline gap-2"> Henüz aşama tamamlanmadı.
<span className="font-medium">{JOB_STEP_LABELS[h.step]}</span> </p>
<span className="text-muted-foreground text-xs"> ) : (
{dateFormatter.format(new Date(h.completedAt))} <ol className="relative space-y-4 border-l-2 border-border pl-6">
</span> {history.map((h) => {
</div> const isRevision = h.note?.startsWith("[Düzeltme talebi]");
{h.note && ( return (
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm"> <li key={h.$id} className="relative">
{h.note} <span
</p> className={`absolute -left-[1.85rem] mt-1.5 size-3 rounded-full ring-2 ring-background ${
)} isRevision ? "bg-rose-500" : "bg-emerald-500"
</li> }`}
))} aria-hidden
/>
<div className="flex flex-wrap items-baseline gap-2">
<span className="font-medium">
{JOB_STEP_LABELS[h.step]}
</span>
{isRevision && (
<span className="rounded bg-rose-100 px-1.5 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-950 dark:text-rose-300">
Düzeltme talebi
</span>
)}
<span className="text-muted-foreground text-xs tabular-nums">
{dateFormatter.format(new Date(h.completedAt))}
</span>
</div>
{h.note && (
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
{h.note.replace(/^\[Düzeltme talebi\]\s*/, "")}
</p>
)}
</li>
);
})}
</ol> </ol>
</CardContent> )}
</Card> </CardContent>
)} </Card>
<div> <div>
<Button asChild variant="outline"> <Button asChild variant="outline">
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const STATUS_OPTIONS = [
{ value: "all", label: "Tüm durumlar" },
{ value: "pending", label: "Bekliyor" },
{ value: "in_progress", label: "İşlemde" },
{ value: "sent", label: "Gönderildi" },
{ value: "delivered", label: "Teslim alındı" },
{ value: "cancelled", label: "İptal" },
];
const LOCATION_OPTIONS = [
{ value: "all", label: "Her yerde" },
{ value: "at_lab", label: "Laboratuvarda" },
{ value: "at_clinic", label: "Klinikte" },
];
export function JobsFilterBar() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [q, setQ] = useState(params.get("q") ?? "");
const status = params.get("status") ?? "all";
const location = params.get("location") ?? "all";
// Debounce text input so we don't refetch on every keystroke.
useEffect(() => {
const current = params.get("q") ?? "";
if (current === q) return;
const t = setTimeout(() => commit({ q }), 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
function commit(patch: Record<string, string | undefined>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(patch)) {
if (!value || value === "all") next.delete(key);
else next.set(key, value);
}
startTransition(() => {
router.replace(`${pathname}?${next.toString()}`);
});
}
const isFiltering =
q.trim() !== "" || status !== "all" || location !== "all";
return (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_180px_auto]">
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Hasta kodu veya karşı taraf ara..."
className="pl-9"
/>
</div>
<Select value={status} onValueChange={(v) => commit({ status: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={location} onValueChange={(v) => commit({ location: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOCATION_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isFiltering && (
<Button
variant="outline"
onClick={() => {
setQ("");
commit({ q: undefined, status: undefined, location: undefined });
}}
>
<X className="size-4" />
Temizle
</Button>
)}
</div>
);
}
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DueBadge } from "@/components/due-badge";
import { import {
Table, Table,
TableBody, TableBody,
@@ -63,6 +64,7 @@ export function JobsTable({
<TableHead>Renk</TableHead> <TableHead>Renk</TableHead>
<TableHead>Tür</TableHead> <TableHead>Tür</TableHead>
<TableHead>Durum</TableHead> <TableHead>Durum</TableHead>
<TableHead>Termin</TableHead>
<TableHead>Tarih</TableHead> <TableHead>Tarih</TableHead>
<TableHead className="text-right">İşlem</TableHead> <TableHead className="text-right">İşlem</TableHead>
</TableRow> </TableRow>
@@ -80,6 +82,16 @@ export function JobsTable({
<TableCell> <TableCell>
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge> <Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-xs">
{j.dueDate ? (
<div className="flex flex-col gap-1">
<span>{dateFormatter.format(new Date(j.dueDate))}</span>
<DueBadge job={j} />
</div>
) : (
"—"
)}
</TableCell>
<TableCell className="text-muted-foreground text-xs"> <TableCell className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(j.$createdAt))} {dateFormatter.format(new Date(j.$createdAt))}
</TableCell> </TableCell>
@@ -0,0 +1,68 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CheckCheck, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { bulkAcceptPendingJobsAction } from "@/lib/appwrite/job-actions";
export function BulkAcceptButton({ count }: { count: number }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, startTransition] = useTransition();
if (count === 0) return null;
function onConfirm() {
startTransition(async () => {
const res = await bulkAcceptPendingJobsAction();
if (res.ok) {
toast.success(`${res.accepted ?? 0} iş işleme alındı.`);
setOpen(false);
router.refresh();
} else {
toast.error(res.error ?? "İşlem başarısız.");
}
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button size="sm" onClick={() => setOpen(true)}>
<CheckCheck className="size-4" />
Bekleyen {count} işi al
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>{count} işleme alınsın mı?</DialogTitle>
<DialogDescription>
Tüm bekleyen işler aynı anda işleme alınır; her birinde alt yapı
üretimine başlanmış sayılır. Klinikler ayrı ayrı bilgilendirilir.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button onClick={onConfirm} disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <CheckCheck className="size-4" />}
Hepsini al
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+33 -17
View File
@@ -3,13 +3,19 @@ import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listInboundJobs } from "@/lib/appwrite/job-queries"; import { listInboundJobs } from "@/lib/appwrite/job-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { JobsFilterBar } from "../_components/jobs-filter-bar";
import { JobsTable } from "../_components/jobs-table"; import { JobsTable } from "../_components/jobs-table";
import { BulkAcceptButton } from "./components/bulk-accept-button";
export const metadata = { export const metadata = {
title: "DLS — Gelen İşler", title: "DLS — Gelen İşler",
}; };
export default async function InboundJobsPage() { export default async function InboundJobsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; location?: string; q?: string }>;
}) {
let ctx; let ctx;
try { try {
ctx = await requireTenant(); ctx = await requireTenant();
@@ -17,18 +23,25 @@ export default async function InboundJobsPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
// Inbound = jobs where this tenant is the lab side. const sp = await searchParams;
// A clinic tenant can also receive jobs only via labTenantId match, which const filters = {
// would be unusual; we still surface whatever matches. status: sp.status && sp.status !== "all" ? sp.status : undefined,
const rows = ctx.kind === "lab" ? await listInboundJobs(ctx.tenantId) : []; location: sp.location && sp.location !== "all" ? sp.location : undefined,
q: sp.q,
};
const rows = ctx.kind === "lab" ? await listInboundJobs(ctx.tenantId, filters) : [];
const pendingCount = rows.filter((j) => j.status === "pending").length;
return ( return (
<div className="flex-1 space-y-6 px-6"> <div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1"> <div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm"> <h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
Bağlı kliniklerden size yönlendirilmiş protez işleri. <p className="text-muted-foreground text-sm">
</p> Bağlı kliniklerden size yönlendirilmiş protez işleri.
</p>
</div>
{ctx.kind === "lab" && <BulkAcceptButton count={pendingCount} />}
</div> </div>
<Card> <Card>
@@ -37,18 +50,21 @@ export default async function InboundJobsPage() {
<CardDescription> <CardDescription>
{ctx.kind === "lab" {ctx.kind === "lab"
? rows.length === 0 ? rows.length === 0
? "Henüz gelen iş yok." ? "Filtreye uyan iş yok."
: `${rows.length} kalem` : `${rows.length} kalem`
: "Bu sayfa laboratuvar hesapları içindir."} : "Bu sayfa laboratuvar hesapları içindir."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
{ctx.kind === "lab" ? ( {ctx.kind === "lab" ? (
<JobsTable <>
rows={rows} <JobsFilterBar />
counterpartLabel="Klinik" <JobsTable
emptyMessage="Henüz size gönderilmiş iş yok. Klinik tarafa Bağlantı Kodunuzu paylaşın." rows={rows}
/> counterpartLabel="Klinik"
emptyMessage="Filtreye uyan iş yok. Üstteki filtreleri değiştirin veya temizleyin."
/>
</>
) : ( ) : (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="text-muted-foreground py-6 text-center text-sm">
Klinik hesabıyla giriş yaptınız gelen listesi sadece laboratuvar tarafında görünür. Klinik hesabıyla giriş yaptınız gelen listesi sadece laboratuvar tarafında görünür.
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useActionState, useEffect, useMemo, useState } from "react"; import { useActionState, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Send, Sparkles, TrendingDown } from "lucide-react"; import { ArrowRight, CheckCircle2, FileUp, Loader2, Send, Sparkles, TrendingDown, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -78,6 +80,10 @@ export function NewJobForm({
const [prostheticId, setProstheticId] = useState<string>(""); const [prostheticId, setProstheticId] = useState<string>("");
const [quote, setQuote] = useState<Quote | null>(null); const [quote, setQuote] = useState<Quote | null>(null);
const [quoteLoading, setQuoteLoading] = useState(false); const [quoteLoading, setQuoteLoading] = useState(false);
// Wizard step — once the job is created we switch to the file upload step
// and keep the user on this page until they finish or skip.
const [step, setStep] = useState<"details" | "files">("details");
const [createdJobId, setCreatedJobId] = useState<string | null>(null);
const labProsthetics = prostheticsByLab[labTenantId] ?? []; const labProsthetics = prostheticsByLab[labTenantId] ?? [];
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId); const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
@@ -89,13 +95,14 @@ export function NewJobForm({
const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined; const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined;
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok && state.jobId) {
toast.success("İş yayınlandı."); toast.success("İş kaydedildi. Dosyaları ekleyebilirsiniz.");
router.push("/jobs/outbound"); setCreatedJobId(state.jobId);
setStep("files");
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state, router]); }, [state]);
// Reset prosthetic selection when the lab changes so we never carry the // Reset prosthetic selection when the lab changes so we never carry the
// previous lab's catalog ID over. // previous lab's catalog ID over.
@@ -134,8 +141,19 @@ export function NewJobForm({
}; };
}, [prostheticId, teeth.length]); }, [prostheticId, teeth.length]);
if (step === "files" && createdJobId) {
return (
<FilesStep
jobId={createdJobId}
onDone={() => router.push(`/jobs/${createdJobId}`)}
onSkip={() => router.push("/jobs/outbound")}
/>
);
}
return ( return (
<form action={action} className="grid gap-5"> <form action={action} className="grid gap-5">
<StepIndicator step="details" />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2"> <div className="grid gap-2 md:col-span-2">
<Label htmlFor="labTenantId">Laboratuvar *</Label> <Label htmlFor="labTenantId">Laboratuvar *</Label>
@@ -298,12 +316,12 @@ export function NewJobForm({
{pending ? ( {pending ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Gönderiliyor... Kaydediliyor...
</> </>
) : ( ) : (
<> <>
<Send className="size-4" /> <ArrowRight className="size-4" />
İşi Yayınla Devam Et Dosyalar
</> </>
)} )}
</Button> </Button>
@@ -312,6 +330,246 @@ export function NewJobForm({
); );
} }
function StepIndicator({ step }: { step: "details" | "files" }) {
const items: { id: "details" | "files"; label: string }[] = [
{ id: "details", label: "İş Bilgileri" },
{ id: "files", label: "Dosyalar" },
];
return (
<ol className="flex items-center gap-3 text-xs">
{items.map((it, idx) => {
const active = it.id === step;
const done = items.findIndex((x) => x.id === step) > idx;
return (
<li key={it.id} className="flex items-center gap-3">
<span
className={`flex size-6 items-center justify-center rounded-full text-[11px] font-semibold ${
active
? "bg-primary text-primary-foreground"
: done
? "bg-emerald-600 text-white"
: "bg-muted text-muted-foreground"
}`}
>
{done ? <CheckCircle2 className="size-3.5" /> : idx + 1}
</span>
<span className={active ? "font-medium" : "text-muted-foreground"}>{it.label}</span>
{idx < items.length - 1 && (
<span className="bg-border h-px w-6" aria-hidden />
)}
</li>
);
})}
</ol>
);
}
type PendingUpload = {
id: string;
file: File;
kind: "scan" | "image" | "document";
status: "queued" | "uploading" | "processing" | "done" | "error";
progress: number;
error?: string;
};
function inferKind(file: File): PendingUpload["kind"] {
const lower = file.name.toLowerCase();
if (
lower.endsWith(".stl") ||
lower.endsWith(".ply") ||
lower.endsWith(".obj") ||
lower.endsWith(".dcm")
)
return "scan";
if (file.type.startsWith("image/")) return "image";
return "document";
}
function FilesStep({
jobId,
onDone,
onSkip,
}: {
jobId: string;
onDone: () => void;
onSkip: () => void;
}) {
const [items, setItems] = useState<PendingUpload[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const allDone = items.length > 0 && items.every((i) => i.status === "done");
const anyBusy = items.some(
(i) => i.status === "uploading" || i.status === "processing" || i.status === "queued",
);
function addFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const additions: PendingUpload[] = Array.from(files).map((file) => ({
id: `${file.name}-${file.size}-${Math.random().toString(36).slice(2, 8)}`,
file,
kind: inferKind(file),
status: "queued",
progress: 0,
}));
setItems((prev) => [...prev, ...additions]);
additions.forEach(uploadOne);
}
function uploadOne(item: PendingUpload) {
const fd = new FormData();
fd.append("file", item.file);
fd.append("kind", item.kind);
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: "uploading" } : i)),
);
const xhr = new XMLHttpRequest();
xhr.open("POST", `/api/jobs/${jobId}/files`);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, progress: pct } : i)),
);
};
xhr.upload.onload = () => {
// Bytes are up — server is now writing to Appwrite (can take a while
// for big STL scans). Switch the row to a 'processing' state so the
// user doesn't think we hung.
setItems((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, status: "processing", progress: 100 } : i,
),
);
};
xhr.onerror = () => {
setItems((prev) =>
prev.map((i) =>
i.id === item.id
? { ...i, status: "error", error: "Ağ hatası" }
: i,
),
);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
setItems((prev) =>
prev.map((i) => (i.id === item.id ? { ...i, status: "done" } : i)),
);
} else {
let msg = `HTTP ${xhr.status}`;
try {
const d = JSON.parse(xhr.responseText);
if (d?.error) msg = d.error;
} catch {}
setItems((prev) =>
prev.map((i) =>
i.id === item.id ? { ...i, status: "error", error: msg } : i,
),
);
}
};
xhr.send(fd);
}
return (
<div className="grid gap-5">
<StepIndicator step="files" />
<div className="bg-muted/30 grid gap-2 rounded-md border p-4 text-sm">
<div className="flex items-center gap-2 font-medium">
<FileUp className="size-4" />
Tarama, görsel veya doküman ekleyin
</div>
<p className="text-muted-foreground text-xs">
STL/PLY/OBJ/DCM dosyaları tarama olarak; JPG/PNG görsel olarak;
diğerleri doküman olarak kaydedilir. Her dosya 200 MB&apos;a kadar olabilir.
</p>
<div>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
addFiles(e.target.files);
if (inputRef.current) inputRef.current.value = "";
}}
/>
<Button
type="button"
variant="outline"
onClick={() => inputRef.current?.click()}
disabled={anyBusy}
>
<Upload className="size-4" />
Dosya seç
</Button>
</div>
</div>
{items.length > 0 && (
<ul className="divide-y rounded-md border">
{items.map((i) => (
<li key={i.id} className="grid gap-1.5 px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm">{i.file.name}</span>
<span className="text-muted-foreground shrink-0 text-xs">
{(i.file.size / (1024 * 1024)).toFixed(1)} MB
</span>
</div>
{i.status === "done" && (
<p className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="size-3.5" />
Yüklendi
</p>
)}
{i.status === "error" && (
<p className="text-destructive text-xs">Hata: {i.error}</p>
)}
{(i.status === "uploading" || i.status === "processing") && (
<div className="flex items-center gap-2">
<Progress value={i.progress} className="flex-1" />
<span className="text-muted-foreground w-20 text-right text-xs">
{i.status === "processing"
? "İşleniyor..."
: `${i.progress}%`}
</span>
</div>
)}
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center justify-end gap-2">
<button
type="button"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground text-sm underline-offset-4 hover:underline"
>
Şimdilik atla
</button>
<Button onClick={onDone} disabled={anyBusy || (items.length > 0 && !allDone)}>
{anyBusy ? (
<>
<Loader2 className="size-4 animate-spin" />
Bekleyin...
</>
) : (
<>
<Send className="size-4" />
İlanı Tamamla
</>
)}
</Button>
</div>
</div>
);
}
function PriceQuoteCard({ function PriceQuoteCard({
quote, quote,
loading, loading,
+23 -9
View File
@@ -3,13 +3,18 @@ import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listOutboundJobs } from "@/lib/appwrite/job-queries"; import { listOutboundJobs } from "@/lib/appwrite/job-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { JobsFilterBar } from "../_components/jobs-filter-bar";
import { JobsTable } from "../_components/jobs-table"; import { JobsTable } from "../_components/jobs-table";
export const metadata = { export const metadata = {
title: "DLS — Giden İşler", title: "DLS — Giden İşler",
}; };
export default async function OutboundJobsPage() { export default async function OutboundJobsPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; location?: string; q?: string }>;
}) {
let ctx; let ctx;
try { try {
ctx = await requireTenant(); ctx = await requireTenant();
@@ -17,7 +22,13 @@ export default async function OutboundJobsPage() {
redirect("/onboarding"); redirect("/onboarding");
} }
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId) : []; const sp = await searchParams;
const filters = {
status: sp.status && sp.status !== "all" ? sp.status : undefined,
location: sp.location && sp.location !== "all" ? sp.location : undefined,
q: sp.q,
};
const rows = ctx.kind === "clinic" ? await listOutboundJobs(ctx.tenantId, filters) : [];
return ( return (
<div className="flex-1 space-y-6 px-6"> <div className="flex-1 space-y-6 px-6">
@@ -34,18 +45,21 @@ export default async function OutboundJobsPage() {
<CardDescription> <CardDescription>
{ctx.kind === "clinic" {ctx.kind === "clinic"
? rows.length === 0 ? rows.length === 0
? "Henüz iş göndermediniz." ? "Filtreye uyan iş yok."
: `${rows.length} kalem` : `${rows.length} kalem`
: "Bu sayfa klinik hesapları içindir."} : "Bu sayfa klinik hesapları içindir."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
{ctx.kind === "clinic" ? ( {ctx.kind === "clinic" ? (
<JobsTable <>
rows={rows} <JobsFilterBar />
counterpartLabel="Laboratuvar" <JobsTable
emptyMessage="Henüz iş göndermediniz. 'Yeni İş Yayınla' butonundan başlayabilirsiniz." rows={rows}
/> counterpartLabel="Laboratuvar"
emptyMessage="Filtreye uyan iş yok. Üstteki filtreleri değiştirin veya temizleyin."
/>
</>
) : ( ) : (
<p className="text-muted-foreground py-6 text-center text-sm"> <p className="text-muted-foreground py-6 text-center text-sm">
Laboratuvar hesabıyla giriş yaptınız giden listesi sadece klinik tarafında görünür. Laboratuvar hesabıyla giriş yaptınız giden listesi sadece klinik tarafında görünür.
+5
View File
@@ -1,5 +1,7 @@
import { Suspense } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { FlashToast } from "@/components/flash-toast";
import { getActiveContext } from "@/lib/appwrite/active-context"; import { getActiveContext } from "@/lib/appwrite/active-context";
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers"; import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
import { getLogoUrl } from "@/lib/appwrite/storage"; import { getLogoUrl } from "@/lib/appwrite/storage";
@@ -40,6 +42,9 @@ export default async function DashboardLayout({
unreadCount={unreadCount} unreadCount={unreadCount}
> >
{children} {children}
<Suspense fallback={null}>
<FlashToast />
</Suspense>
</DashboardShell> </DashboardShell>
); );
} }
@@ -66,10 +66,21 @@ function NotificationRow({ row }: { row: Notification }) {
? "/connections" ? "/connections"
: null; : null;
const isWarning = row.severity === "warning";
return ( return (
<li className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""}`}> <li
className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""} ${
isWarning && !row.read ? "bg-amber-50/60 dark:bg-amber-950/30" : ""
}`}
>
<span <span
className={`mt-1.5 size-2 shrink-0 rounded-full ${row.read ? "bg-muted" : "bg-primary"}`} className={`mt-1.5 size-2 shrink-0 rounded-full ${
row.read
? "bg-muted"
: isWarning
? "bg-amber-500"
: "bg-primary"
}`}
aria-hidden aria-hidden
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -79,8 +90,11 @@ function NotificationRow({ row }: { row: Notification }) {
</p> </p>
</div> </div>
{!row.read && ( {!row.read && (
<Badge variant="secondary" className="text-[10px] uppercase"> <Badge
Yeni variant={isWarning ? "destructive" : "secondary"}
className="text-[10px] uppercase"
>
{isWarning ? "Dikkat" : "Yeni"}
</Badge> </Badge>
)} )}
{link && ( {link && (
@@ -0,0 +1,168 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DueBadge } from "@/components/due-badge";
import {
JOB_STATUS_LABELS,
PROSTHETIC_TYPE_LABELS,
} from "@/lib/appwrite/job-types";
import { getPatient, listPatientJobs } from "@/lib/appwrite/patient-queries";
import type { JobStatus } from "@/lib/appwrite/schema";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
export const metadata = {
title: "DLS — Hasta",
};
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function statusVariant(s: JobStatus): "default" | "secondary" | "outline" | "destructive" {
if (s === "delivered") return "default";
if (s === "sent" || s === "in_progress") return "secondary";
if (s === "cancelled") return "destructive";
return "outline";
}
export default async function PatientDetailPage({
params,
}: {
params: Promise<{ patientId: string }>;
}) {
const { patientId } = await params;
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["clinic"]);
} catch {
redirect("/dashboard");
}
const patient = await getPatient(patientId, ctx.tenantId);
if (!patient) notFound();
const jobs = await listPatientJobs(patient.$id, patient.patientCode, ctx.tenantId);
const fullName =
[patient.firstName, patient.lastName].filter(Boolean).join(" ") ||
`Hasta ${patient.patientCode}`;
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm font-mono">
{patient.patientCode}
</p>
<h1 className="text-2xl font-bold tracking-tight">{fullName}</h1>
{patient.archived && (
<Badge variant="outline" className="w-fit">
Arşivlenmiş
</Badge>
)}
</div>
<Button asChild>
<Link href="/jobs/new">
<Plus className="size-4" />
Bu hastaya yeni
</Link>
</Button>
</div>
{patient.notes && (
<Card>
<CardHeader>
<CardTitle>Notlar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-wrap text-sm">
{patient.notes}
</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>İş Geçmişi</CardTitle>
<CardDescription>
{jobs.length === 0
? "Bu hastaya ait iş kaydı yok."
: `${jobs.length}`}
</CardDescription>
</CardHeader>
<CardContent>
{jobs.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz bu hasta için gönderilmemiş.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Üye</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Termin</TableHead>
<TableHead className="text-right">Detay</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((j) => (
<TableRow key={j.$id}>
<TableCell className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(j.$createdAt))}
</TableCell>
<TableCell className="text-muted-foreground">
{PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType}
</TableCell>
<TableCell className="tabular-nums">{j.memberCount}</TableCell>
<TableCell>
<Badge variant={statusVariant(j.status)}>
{JOB_STATUS_LABELS[j.status]}
</Badge>
</TableCell>
<TableCell>
<DueBadge job={j} />
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link href={`/jobs/${j.$id}`}></Link>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<div>
<Button asChild variant="outline">
<Link href="/patients">
<ArrowLeft className="size-4" />
Hasta listesine dön
</Link>
</Button>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useState, useTransition } from "react"; import { useActionState, useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react"; import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -83,11 +84,17 @@ function PatientRow({ row }: { row: Patient }) {
return ( return (
<TableRow className={row.archived ? "opacity-60" : ""}> <TableRow className={row.archived ? "opacity-60" : ""}>
<TableCell className="font-mono text-xs">{row.patientCode}</TableCell> <TableCell className="font-mono text-xs">
<Link href={`/patients/${row.$id}`} className="hover:underline">
{row.patientCode}
</Link>
</TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
{[row.firstName, row.lastName].filter(Boolean).join(" ") || ( <Link href={`/patients/${row.$id}`} className="hover:underline">
<span className="text-muted-foreground"></span> {[row.firstName, row.lastName].filter(Boolean).join(" ") || (
)} <span className="text-muted-foreground"></span>
)}
</Link>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground max-w-[280px] truncate"> <TableCell className="text-muted-foreground max-w-[280px] truncate">
{row.notes || "—"} {row.notes || "—"}
@@ -0,0 +1,149 @@
import { redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { listAuditLogs } from "@/lib/appwrite/audit-queries";
import { DATABASE_ID, TABLES, type Profile } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
export const metadata = {
title: "DLS — Hesap Aktivitesi",
};
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const ENTITY_LABELS: Record<string, string> = {
job: "İş",
patient: "Hasta",
prosthetic: "Ürün",
payment: "Ödeme",
clinic_pricing: "Klinik Fiyat",
job_file: "Dosya",
connection: "Bağlantı",
invite: "Davet",
tenant_settings: "Çalışma Alanı",
profile: "Profil",
};
const ACTION_VARIANTS = {
create: { label: "Eklendi", variant: "default" as const },
update: { label: "Güncellendi", variant: "secondary" as const },
delete: { label: "Silindi", variant: "destructive" as const },
};
export default async function ActivityPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const logs = await listAuditLogs(ctx.tenantId, 200);
// Resolve userId → display name in one go so the rows read naturally.
const userIds = Array.from(new Set(logs.map((l) => l.userId)));
const userMap = new Map<string, string>();
if (userIds.length > 0) {
try {
const { tablesDB } = createAdminClient();
const profiles = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.profiles,
queries: [Query.equal("userId", userIds), Query.limit(200)],
});
for (const p of profiles.rows as unknown as Profile[]) {
if (p.displayName) userMap.set(p.userId, p.displayName);
}
} catch {
// best-effort; rows just show the raw id
}
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Hesap Aktivitesi</h1>
<p className="text-muted-foreground text-sm">
Çalışma alanınızda yapılan tüm değişikliklerin kaydı. Son 200 işlem.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>İşlem Kaydı</CardTitle>
<CardDescription>
Otomatik tutulur, silinemez. Şüpheli bir aktivite görürseniz hesabınızı
güvenli olmayan bir cihazdan çıkarmayı düşünün.
</CardDescription>
</CardHeader>
<CardContent>
{logs.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz kayıtlı aktivite yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Zaman</TableHead>
<TableHead>Kullanıcı</TableHead>
<TableHead>İşlem</TableHead>
<TableHead>Nesne</TableHead>
<TableHead>Detay</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((l) => {
const v = ACTION_VARIANTS[l.action] ?? {
label: l.action,
variant: "outline" as const,
};
return (
<TableRow key={l.$id}>
<TableCell className="text-muted-foreground text-xs tabular-nums">
{dateFormatter.format(new Date(l.$createdAt))}
</TableCell>
<TableCell className="text-sm">
{userMap.get(l.userId) ?? (
<span className="text-muted-foreground font-mono text-xs">
{l.userId.slice(0, 8)}
</span>
)}
</TableCell>
<TableCell>
<Badge variant={v.variant}>{v.label}</Badge>
</TableCell>
<TableCell className="text-sm">
{ENTITY_LABELS[l.entityType] ?? l.entityType}
</TableCell>
<TableCell className="text-muted-foreground max-w-[360px] truncate text-xs">
{l.changes ? l.changes : <span></span>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const ITEMS: { href: string; label: string }[] = [
{ href: "/settings/workspace", label: "Çalışma Alanı" },
{ href: "/settings/account", label: "Profilim" },
{ href: "/settings/members", label: "Üyeler" },
{ href: "/settings/notifications", label: "Bildirimler" },
{ href: "/settings/appearance", label: "Görünüm" },
{ href: "/settings/security", label: "Güvenlik" },
{ href: "/settings/activity", label: "Hesap Aktivitesi" },
];
export function SettingsNav() {
const pathname = usePathname();
return (
<nav className="overflow-x-auto">
<ul className="border-border flex min-w-max gap-1 border-b">
{ITEMS.map((item) => {
const active = pathname === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"inline-block border-b-2 px-3 py-2 text-sm transition-colors",
active
? "border-primary text-foreground font-medium"
: "text-muted-foreground hover:text-foreground border-transparent",
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SettingsNav } from "./components/settings-nav";
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex-1 space-y-6">
<div className="px-6">
<SettingsNav />
</div>
{children}
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, KeyRound, Loader2, ShieldCheck, ShieldOff } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
disableMfaAction,
initialMfaActionState,
regenerateRecoveryCodesAction,
startMfaEnrollAction,
verifyMfaEnrollAction,
} from "@/lib/appwrite/mfa-actions";
type EnrollStage =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "verify"; uri: string; secret: string }
| { kind: "done"; recoveryCodes: string[] };
export function MfaPanel({ initiallyEnabled }: { initiallyEnabled: boolean }) {
const router = useRouter();
const [enabled, setEnabled] = useState(initiallyEnabled);
const [stage, setStage] = useState<EnrollStage>({ kind: "idle" });
const [verifyState, verifyAction, verifying] = useActionState(
verifyMfaEnrollAction,
initialMfaActionState,
);
const [busy, startTransition] = useTransition();
useEffect(() => {
if (verifyState.ok && verifyState.recoveryCodes) {
setEnabled(true);
setStage({ kind: "done", recoveryCodes: verifyState.recoveryCodes });
toast.success("2FA etkinleştirildi.");
router.refresh();
} else if (verifyState.error) {
toast.error(verifyState.error);
}
}, [verifyState, router]);
function beginEnroll() {
setStage({ kind: "loading" });
startTransition(async () => {
const res = await startMfaEnrollAction();
if (res.ok && res.uri && res.secret) {
setStage({ kind: "verify", uri: res.uri, secret: res.secret });
} else {
toast.error(res.error ?? "Başlatılamadı.");
setStage({ kind: "idle" });
}
});
}
function onDisable() {
if (
!window.confirm(
"2FA devre dışı bırakılsın mı? Hesabınız sadece şifre ile korunacak.",
)
)
return;
startTransition(async () => {
const res = await disableMfaAction();
if (res.ok) {
setEnabled(false);
setStage({ kind: "idle" });
toast.success("2FA devre dışı bırakıldı.");
router.refresh();
} else {
toast.error(res.error ?? "Devre dışı bırakılamadı.");
}
});
}
function onRegenerateCodes() {
startTransition(async () => {
const res = await regenerateRecoveryCodesAction();
if (res.ok && res.recoveryCodes) {
setStage({ kind: "done", recoveryCodes: res.recoveryCodes });
toast.success("Yeni yedek kodlar oluşturuldu — eskileri geçersiz.");
} else {
toast.error(res.error ?? "Üretilemedi.");
}
});
}
if (enabled && stage.kind !== "done") {
return (
<div className="grid gap-3">
<div className="flex items-center gap-2">
<Badge className="bg-emerald-600 text-white">
<ShieldCheck className="size-3.5" />
Aktif
</Badge>
<span className="text-muted-foreground text-sm">
Authenticator uygulaması ile giriş yapıyorsunuz.
</span>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={onRegenerateCodes} disabled={busy}>
<KeyRound className="size-4" />
Yedek kodları yenile
</Button>
<Button variant="destructive" onClick={onDisable} disabled={busy}>
{busy ? <Loader2 className="size-4 animate-spin" /> : <ShieldOff className="size-4" />}
Devre dışı bırak
</Button>
</div>
</div>
);
}
if (stage.kind === "done") {
return (
<div className="grid gap-3">
<div className="bg-emerald-50 dark:bg-emerald-950 rounded-md border border-emerald-200 dark:border-emerald-900 p-4">
<p className="flex items-center gap-2 font-medium text-emerald-700 dark:text-emerald-300">
<Check className="size-4" />
Yedek kodlarınız
</p>
<p className="text-muted-foreground mt-1 text-xs">
Telefonunuza erişiminizi kaybederseniz bu kodlardan biriyle giriş
yapabilirsiniz. Her kod tek seferlik. <strong>Şimdi güvenli bir yere kaydedin</strong>
bu sayfadan çıktığınızda tekrar gösterilmez.
</p>
<pre className="bg-background mt-3 grid grid-cols-2 gap-2 rounded-md border p-3 text-sm font-mono">
{stage.recoveryCodes.map((c) => (
<span key={c}>{c}</span>
))}
</pre>
</div>
<Button variant="outline" onClick={() => setStage({ kind: "idle" })}>
Tamamladım
</Button>
</div>
);
}
if (stage.kind === "verify") {
const otpauthQrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(stage.uri)}`;
return (
<form action={verifyAction} className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-[200px_1fr] sm:items-start">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={otpauthQrUrl}
alt="QR kodu"
className="size-[200px] rounded-md border bg-white p-2"
width={200}
height={200}
/>
<div className="grid gap-2 text-sm">
<p>Authenticator uygulamanızı açın, QR kodu tarayın.</p>
<p className="text-muted-foreground text-xs">
Tarayamıyorsanız bu kodu manuel girin:
</p>
<code className="bg-muted rounded-md p-2 font-mono text-xs">{stage.secret}</code>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="otp">Uygulamadaki 6 haneli kod</Label>
<Input
id="otp"
name="otp"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
placeholder="000000"
required
autoComplete="one-time-code"
className="font-mono text-lg tracking-widest"
/>
{verifyState.error && (
<p className="text-destructive text-xs">{verifyState.error}</p>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setStage({ kind: "idle" })}>
Vazgeç
</Button>
<Button type="submit" disabled={verifying}>
{verifying ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Doğrula ve etkinleştir
</Button>
</div>
</form>
);
}
return (
<div className="grid gap-3">
<div className="flex items-center gap-2">
<Badge variant="outline">Pasif</Badge>
<span className="text-muted-foreground text-sm">
Hesabınız yalnızca şifre ile korunuyor.
</span>
</div>
<Button onClick={beginEnroll} disabled={busy || stage.kind === "loading"}>
{(busy || stage.kind === "loading") ? <Loader2 className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
İki adımlı doğrulamayı
</Button>
</div>
);
}
@@ -0,0 +1,56 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { createSessionClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { MfaPanel } from "./components/mfa-panel";
export const metadata = {
title: "DLS — Güvenlik",
};
export default async function SecurityPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
// Look up the user's current MFA status straight from the session
// client so the panel knows whether to offer enroll or disable.
let mfaEnabled = false;
try {
const { account } = await createSessionClient();
const user = await account.get();
mfaEnabled = Boolean(user.mfa);
} catch {
// ignore — panel will treat as not enabled
}
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{ctx.settings?.companyName ?? "Çalışma alanı"}</p>
<h1 className="text-2xl font-bold tracking-tight">Güvenlik</h1>
<p className="text-muted-foreground text-sm">
Hesap erişiminizi koruyan ayarlar. İki adımlı doğrulamayı açtığınızda
giriş yaparken authenticator uygulamanızdaki 6 haneli kod istenir.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>İki Adımlı Doğrulama</CardTitle>
<CardDescription>
Authenticator uygulaması (Google Authenticator, 1Password, Authy, vs.)
ile TOTP. SMS desteklenmiyor.
</CardDescription>
</CardHeader>
<CardContent>
<MfaPanel initiallyEnabled={mfaEnabled} />
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteWorkspaceAction,
initialDeleteWorkspaceState,
} from "@/lib/appwrite/account-delete-actions";
export function DangerZone({ companyName }: { companyName: string }) {
const [downloadBusy, startDownload] = useTransition();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState("");
const [state, action, pending] = useActionState(
deleteWorkspaceAction,
initialDeleteWorkspaceState,
);
useEffect(() => {
if (state.error) toast.error(state.error);
}, [state]);
function downloadExport() {
startDownload(async () => {
try {
const res = await fetch("/api/account/export");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dls-export-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Veri dışa aktarıldı.");
} catch (e) {
toast.error(e instanceof Error ? e.message : "İndirilemedi.");
}
});
}
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="text-destructive size-4" />
Tehlikeli Bölge
</CardTitle>
<CardDescription>
Verinizi dışa aktarın veya çalışma alanını kalıcı olarak silin.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<p className="font-medium">Verilerimi indir</p>
<p className="text-muted-foreground text-xs">
Çalışma alanınızdaki tüm veriler (hastalar, işler, ödemeler, geçmiş)
JSON formatında dışa aktarılır. Silmeden önce yedek almanız önerilir.
</p>
</div>
<Button variant="outline" onClick={downloadExport} disabled={downloadBusy}>
{downloadBusy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
JSON indir
</Button>
</div>
<div className="border-destructive/40 flex flex-wrap items-center justify-between gap-3 rounded-md border p-3">
<div className="min-w-0">
<p className="font-medium">Çalışma alanını sil</p>
<p className="text-muted-foreground text-xs">
Tüm hastalar, işler, ödemeler, dosyalar ve geçmiş kalıcı olarak silinir.
Bu işlem geri alınamaz.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<Button variant="destructive" onClick={() => setOpen(true)}>
<Trash2 className="size-4" />
Sil
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Çalışma alanını kalıcı sil</DialogTitle>
<DialogDescription>
Onaylamak için aşağıya <strong>{companyName}</strong> yazın. Bu
işlem hastalar, işler, ödemeler, dosyalar ve tüm geçmişi içerir
ve geri alınamaz.
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="confirm">Çalışma alanı adı</Label>
<Input
id="confirm"
name="confirm"
autoComplete="off"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={companyName}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
disabled={pending || confirm.trim() !== companyName}
>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Kalıcı olarak sil
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
);
}
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getLogoUrl } from "@/lib/appwrite/storage"; import { getLogoUrl } from "@/lib/appwrite/storage";
import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { DangerZone } from "./components/danger-zone";
import { LogoUploader } from "./components/logo-uploader"; import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form"; import { WorkspaceSettingsForm } from "./components/workspace-form";
@@ -50,6 +51,10 @@ export default async function WorkspaceSettingsPage() {
memberNumber: ctx.settings?.memberNumber ?? "", memberNumber: ctx.settings?.memberNumber ?? "",
}} }}
/> />
{ctx.role === "owner" && (
<DangerZone companyName={ctx.settings?.companyName ?? ""} />
)}
</div> </div>
); );
} }
+103
View File
@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
const TENANT_TABLES = [
TABLES.tenantSettings,
TABLES.profiles,
TABLES.connections,
TABLES.patients,
TABLES.clinicPricing,
TABLES.jobs,
TABLES.jobFiles,
TABLES.jobStatusHistory,
TABLES.prosthetics,
TABLES.financeEntries,
TABLES.payments,
TABLES.notifications,
TABLES.auditLogs,
] as const;
const TENANT_FIELDS_BY_TABLE: Record<string, string[]> = {
// Most tables use 'tenantId' or 'clinicTenantId'/'labTenantId' for ownership.
[TABLES.tenantSettings]: ["tenantId"],
[TABLES.profiles]: ["tenantId"],
[TABLES.connections]: ["clinicTenantId", "labTenantId"],
[TABLES.patients]: ["clinicTenantId"],
[TABLES.clinicPricing]: ["labTenantId", "clinicTenantId"],
[TABLES.jobs]: ["clinicTenantId", "labTenantId"],
[TABLES.jobFiles]: ["clinicTenantId", "labTenantId"],
[TABLES.jobStatusHistory]: ["clinicTenantId", "labTenantId"],
[TABLES.prosthetics]: ["tenantId"],
[TABLES.financeEntries]: ["tenantId"],
[TABLES.payments]: ["tenantId", "counterpartTenantId"],
[TABLES.notifications]: ["tenantId"],
[TABLES.auditLogs]: ["tenantId"],
};
/**
* Return a JSON file containing every row this tenant has access to.
* Used for KVKK 'data portability' and as a sanity-check pre-delete.
*/
export async function GET() {
let ctx;
try {
ctx = await requireTenant();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { tablesDB } = createAdminClient();
const out: Record<string, unknown[]> = {};
for (const table of TENANT_TABLES) {
const fields = TENANT_FIELDS_BY_TABLE[table] ?? ["tenantId"];
try {
// Fetch each row where ANY of the ownership fields equals our tenantId.
// For tables with two fields (jobs, connections, ...) issue both queries
// and dedupe — Appwrite doesn't have OR across distinct equality terms.
const seen = new Set<string>();
const rows: unknown[] = [];
for (const field of fields) {
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: table,
queries: [Query.equal(field, ctx.tenantId), Query.limit(500)],
});
for (const r of result.rows) {
const id = (r as { $id?: string }).$id;
if (id && !seen.has(id)) {
seen.add(id);
rows.push(r);
}
}
}
out[table] = rows;
} catch (e) {
out[table] = [
{ error: e instanceof Error ? e.message : "fetch failed" },
];
}
}
const payload = {
exportedAt: new Date().toISOString(),
tenantId: ctx.tenantId,
tenantKind: ctx.kind,
requestedBy: { id: ctx.user.id, email: ctx.user.email, name: ctx.user.name },
data: out,
};
const fileName = `dls-export-${ctx.tenantId}-${Date.now()}.json`;
return new NextResponse(JSON.stringify(payload, null, 2), {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Cache-Control": "private, no-store",
},
});
}
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { BUCKETS, DATABASE_ID, TABLES, type Job, type JobFile } from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
/**
* Server-side download proxy. The Appwrite bucket files are scoped to the
* job's two teams (clinic + lab) and the lab's frontend domain doesn't carry
* an Appwrite session cookie, so a direct browser → Appwrite link 401s. We
* authenticate the caller via the lab session, verify they actually have
* access to the job, then stream the file out with a forced attachment
* disposition.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ jobId: string; fileId: string }> },
) {
const { jobId, fileId } = await params;
let ctx;
try {
ctx = await requireTenant();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { tablesDB, storage } = createAdminClient();
let job: Job;
let file: JobFile;
try {
const [j, f] = await Promise.all([
tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId) as Promise<unknown>,
tablesDB.getRow(DATABASE_ID, TABLES.jobFiles, fileId) as Promise<unknown>,
]);
job = j as Job;
file = f as JobFile;
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (file.jobId !== jobId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (ctx.tenantId !== job.clinicTenantId && ctx.tenantId !== job.labTenantId) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (file.archivedAt) {
return NextResponse.json(
{ error: "Dosya arşivlendi, indirilebilir kopya yok." },
{ status: 410 },
);
}
const buf = (await storage.getFileDownload(BUCKETS.jobFiles, file.fileId)) as
| ArrayBuffer
| Buffer;
const body =
buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
// Quote the filename so spaces / non-ASCII don't break the header.
const safeName = file.name.replace(/["\\]/g, "_");
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
"Cache-Control": "private, no-store",
},
});
}
+1 -1
View File
@@ -144,7 +144,7 @@ export async function POST(
createdRowIds.push(row.$id); createdRowIds.push(row.$id);
} }
await logAudit({ void logAudit({
tenantId: tenantCtx.tenantId, tenantId: tenantCtx.tenantId,
userId: tenantCtx.user.id, userId: tenantCtx.user.id,
action: "create", action: "create",
+27
View File
@@ -0,0 +1,27 @@
import { AlertCircle, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { dueLabel, dueState } from "@/lib/appwrite/due-date";
import type { Job } from "@/lib/appwrite/schema";
export function DueBadge({
job,
className,
}: {
job: Pick<Job, "dueDate" | "status">;
className?: string;
}) {
const state = dueState(job);
if (state.kind === "none" || state.kind === "future") return null;
const variant: "destructive" | "secondary" = state.kind === "overdue" ? "destructive" : "secondary";
return (
<Badge variant={variant} className={`gap-1 ${className ?? ""}`}>
{state.kind === "overdue" ? (
<AlertCircle className="size-3" />
) : (
<Clock className="size-3" />
)}
{dueLabel(state)}
</Badge>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "sonner";
const MESSAGES: Record<string, string> = {
accepted: "İş işleme alındı, alt yapı üretimi başladı.",
handed: "Klinik tarafına gönderildi.",
approved: "Prova onaylandı, lab tarafına geri gönderildi.",
revision: "Düzeltme talebi gönderildi.",
delivered: "İş teslim alındı.",
cancelled: "İş iptal edildi.",
};
/**
* Show a one-shot toast based on ?flash=<key>, then strip the param from
* the URL so a refresh doesn't replay it. Mounted in the dashboard layout
* so it works on every page that server actions might redirect to.
*/
export function FlashToast() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const fired = useRef<string | null>(null);
useEffect(() => {
const flash = params.get("flash");
if (!flash) return;
// Avoid double-firing under React Strict Mode in dev.
if (fired.current === flash) return;
fired.current = flash;
const message = MESSAGES[flash] ?? null;
if (message) toast.success(message);
const next = new URLSearchParams(params.toString());
next.delete("flash");
const query = next.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [params, pathname, router]);
return null;
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useEffect, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { Bounds, OrbitControls } from "@react-three/drei";
import { Loader2, RotateCcw } from "lucide-react";
import * as THREE from "three";
import { STLLoader } from "three/addons/loaders/STLLoader.js";
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
import { OBJLoader } from "three/addons/loaders/OBJLoader.js";
import { Button } from "@/components/ui/button";
type SupportedFormat = "stl" | "ply" | "obj";
function detectFormat(filename: string): SupportedFormat | null {
const lower = filename.toLowerCase();
if (lower.endsWith(".stl")) return "stl";
if (lower.endsWith(".ply")) return "ply";
if (lower.endsWith(".obj")) return "obj";
return null;
}
/**
* Loads a 3D scan from the given URL into a BufferGeometry. OBJ files come
* back from the loader as a THREE.Group, so we merge their child meshes'
* geometries into a single BufferGeometry for a uniform render path.
*/
async function loadGeometry(
url: string,
format: SupportedFormat,
): Promise<THREE.BufferGeometry> {
const res = await fetch(url);
if (!res.ok) throw new Error(`İndirilemedi (HTTP ${res.status})`);
const buffer = await res.arrayBuffer();
if (format === "stl") {
const g = new STLLoader().parse(buffer);
g.computeVertexNormals();
return g;
}
if (format === "ply") {
const g = new PLYLoader().parse(buffer);
g.computeVertexNormals();
return g;
}
// OBJ — text format, parse needs a string and yields a Group of meshes.
const text = new TextDecoder().decode(buffer);
const group = new OBJLoader().parse(text);
const geometries: THREE.BufferGeometry[] = [];
group.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
const g = (obj as THREE.Mesh).geometry as THREE.BufferGeometry;
if (g) geometries.push(g);
}
});
if (geometries.length === 0) throw new Error("OBJ içinde mesh bulunamadı.");
// For a single mesh we can avoid the merge dependency entirely.
if (geometries.length === 1) {
const g = geometries[0].clone();
g.computeVertexNormals();
return g;
}
// Cheap concat: positions only. Good enough for previewing scans.
const positions: number[] = [];
for (const g of geometries) {
const pos = g.getAttribute("position");
if (!pos) continue;
for (let i = 0; i < pos.count * 3; i++) {
positions.push((pos.array as ArrayLike<number>)[i]);
}
}
const merged = new THREE.BufferGeometry();
merged.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3),
);
merged.computeVertexNormals();
return merged;
}
export function STLViewer({
url,
filename,
}: {
url: string;
filename: string;
}) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
const [error, setError] = useState<string | null>(null);
const [resetKey, setResetKey] = useState(0);
const format = detectFormat(filename);
useEffect(() => {
if (!format) {
setError("Bu dosya türü görüntüleyici tarafından desteklenmiyor.");
return;
}
let cancelled = false;
setGeometry(null);
setError(null);
loadGeometry(url, format)
.then((g) => {
if (cancelled) {
g.dispose();
return;
}
setGeometry(g);
})
.catch((e: unknown) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Yüklenemedi.");
});
return () => {
cancelled = true;
};
}, [url, format]);
// Dispose old geometry when it gets replaced or the component unmounts.
useEffect(() => {
return () => {
geometry?.dispose();
};
}, [geometry]);
if (error) {
return (
<div className="bg-muted/40 text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
{error}
</div>
);
}
if (!geometry) {
return (
<div className="bg-muted/40 text-muted-foreground flex h-full items-center justify-center gap-2 p-6 text-sm">
<Loader2 className="size-4 animate-spin" />
Tarama yükleniyor...
</div>
);
}
return (
<div className="relative h-full w-full">
<Canvas camera={{ position: [0, 0, 100], fov: 45, near: 0.1, far: 5000 }} dpr={[1, 2]}>
<color attach="background" args={["#0b1220"]} />
<ambientLight intensity={0.45} />
<directionalLight position={[10, 10, 10]} intensity={0.7} />
<directionalLight position={[-10, -10, -10]} intensity={0.35} />
<Bounds key={resetKey} fit clip observe margin={1.25}>
<mesh geometry={geometry}>
<meshStandardMaterial
color="#e2e8f0"
roughness={0.55}
metalness={0.05}
/>
</mesh>
</Bounds>
<OrbitControls makeDefault enableDamping dampingFactor={0.1} />
</Canvas>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setResetKey((k) => k + 1)}
className="absolute right-3 top-3 bg-background/80 backdrop-blur"
>
<RotateCcw className="size-4" />
Sığdır
</Button>
</div>
);
}
export default STLViewer;
+172
View File
@@ -0,0 +1,172 @@
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { AppwriteException, Query } from "node-appwrite";
import {
BUCKETS,
DATABASE_ID,
TABLES,
type JobFile,
type TenantSettings,
} from "./schema";
import { APPWRITE_SESSION_COOKIE, createAdminClient } from "./server";
import { ACTIVE_TENANT_COOKIE } from "./tenant-types";
import { requireRole, requireTenant } from "./tenant-guard";
export type DeleteWorkspaceState = {
ok: boolean;
error?: string;
};
export const initialDeleteWorkspaceState: DeleteWorkspaceState = { ok: false };
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
if (e instanceof AppwriteException) return e.message || fallback;
return process.env.NODE_ENV !== "production" && e instanceof Error
? `${fallback} (${e.message})`
: fallback;
}
/** Best-effort delete every row where any of `fields` equals tenantId. */
async function purgeTable(table: string, fields: string[], tenantId: string) {
const { tablesDB } = createAdminClient();
const ids = new Set<string>();
for (const field of fields) {
let offset = 0;
// Page through to handle tables with more than 500 rows.
while (true) {
const result = await tablesDB
.listRows({
databaseId: DATABASE_ID,
tableId: table,
queries: [Query.equal(field, tenantId), Query.limit(500), Query.offset(offset)],
})
.catch(() => ({ rows: [] }));
for (const row of result.rows) {
const id = (row as { $id?: string }).$id;
if (id) ids.add(id);
}
if (result.rows.length < 500) break;
offset += 500;
}
}
await Promise.allSettled(
Array.from(ids).map((id) =>
tablesDB.deleteRow(DATABASE_ID, table, id),
),
);
}
/**
* Hard-delete an entire workspace and everything it owns. Reversible only
* via your own backup — Appwrite has no undo. Caller must be owner of the
* tenant and must confirm by typing the company name back to us.
*/
export async function deleteWorkspaceAction(
_prev: DeleteWorkspaceState,
formData: FormData,
): Promise<DeleteWorkspaceState> {
const confirm = String(formData.get("confirm") ?? "").trim();
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner"]);
} catch {
return { ok: false, error: "Yalnızca sahip silebilir." };
}
const expected = ctx.settings?.companyName?.trim() ?? "";
if (!expected || confirm !== expected) {
return {
ok: false,
error: "Onaylamak için çalışma alanı adını birebir yazmanız gerekiyor.",
};
}
const tenantId = ctx.tenantId;
const { tablesDB, storage, teams } = createAdminClient();
// 1) Wipe Storage objects we still own (logo + any non-archived job files).
try {
const settingsRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", tenantId), Query.limit(1)],
});
const settings = (settingsRes.rows[0] as unknown as TenantSettings | undefined) ?? null;
if (settings?.logo) {
try {
await storage.deleteFile(BUCKETS.tenantLogos, settings.logo);
} catch {
/* ignore */
}
}
const files = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobFiles,
queries: [
Query.or([
Query.equal("clinicTenantId", tenantId),
Query.equal("labTenantId", tenantId),
]),
Query.limit(1000),
],
});
await Promise.allSettled(
(files.rows as unknown as JobFile[]).map(async (f) => {
if (f.archivedAt) return;
try {
await storage.deleteFile(BUCKETS.jobFiles, f.fileId);
} catch {
/* ignore */
}
}),
);
} catch {
/* best-effort */
}
// 2) Purge all DB tables tied to this tenant. Order doesn't matter
// because everything is hard-deleted.
const tablePurges: Array<[string, string[]]> = [
[TABLES.notifications, ["tenantId"]],
[TABLES.auditLogs, ["tenantId"]],
[TABLES.payments, ["tenantId", "counterpartTenantId"]],
[TABLES.financeEntries, ["tenantId"]],
[TABLES.jobStatusHistory, ["clinicTenantId", "labTenantId"]],
[TABLES.jobFiles, ["clinicTenantId", "labTenantId"]],
[TABLES.jobs, ["clinicTenantId", "labTenantId"]],
[TABLES.clinicPricing, ["labTenantId", "clinicTenantId"]],
[TABLES.prosthetics, ["tenantId"]],
[TABLES.patients, ["clinicTenantId"]],
[TABLES.connections, ["clinicTenantId", "labTenantId"]],
[TABLES.profiles, ["tenantId"]],
[TABLES.tenantSettings, ["tenantId"]],
];
for (const [table, fields] of tablePurges) {
await purgeTable(table, fields, tenantId);
}
// 3) Finally delete the Appwrite Team itself. This boots every member's
// permission to read anything that might have slipped through above.
try {
await teams.delete({ teamId: tenantId });
} catch (e) {
return { ok: false, error: appwriteError(e, "Çalışma alanı silindi ama takım kaldı.") };
}
// Drop session/active-tenant cookies — the user is also effectively
// signed out of this workspace.
const cookieStore = await cookies();
cookieStore.delete(ACTIVE_TENANT_COOKIE);
// Keep the Appwrite session itself so the user can still re-onboard or
// hop to another workspace they own. If they want to drop the session,
// there's already a 'Çıkış yap' button.
void APPWRITE_SESSION_COOKIE; // referenced to avoid unused import
revalidatePath("/");
redirect("/onboarding");
}
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type AuditLog } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listAuditLogs(
tenantId: string,
limit = 100,
): Promise<AuditLog[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.auditLogs,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(limit),
],
});
return toPlain(result.rows as unknown as AuditLog[]);
}
+47 -1
View File
@@ -2,7 +2,7 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AppwriteException, ID, Query } from "node-appwrite"; import { AppwriteException, AuthenticationFactor, ID, Query } from "node-appwrite";
import { APPWRITE_SESSION_COOKIE, createAdminClient, createSessionClient } from "./server"; import { APPWRITE_SESSION_COOKIE, createAdminClient, createSessionClient } from "./server";
import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema"; import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema";
@@ -85,6 +85,7 @@ async function resolveTenantOnLogin(
export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> { export async function signInAction(_prev: AuthState, formData: FormData): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim(); const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? ""); const password = String(formData.get("password") ?? "");
const otp = String(formData.get("otp") ?? "").trim();
const inviteCode = String(formData.get("inviteCode") ?? "").trim(); const inviteCode = String(formData.get("inviteCode") ?? "").trim();
const rawKind = String(formData.get("kind") ?? "").trim(); const rawKind = String(formData.get("kind") ?? "").trim();
const kind: TenantKind | null = const kind: TenantKind | null =
@@ -107,6 +108,51 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis
return { ok: false, error: appwriteError(e) }; return { ok: false, error: appwriteError(e) };
} }
// MFA: if the user has TOTP enabled, the session above is half-confirmed.
// Either pass the OTP they typed in this submission or ask for it.
try {
const { users } = createAdminClient();
const user = await users.get({ userId: sessionUserId });
if (user.mfa) {
if (!otp) {
return {
ok: false,
mfaRequired: true,
error: "Hesabınız 2FA korumalı. Authenticator uygulamasındaki 6 haneli kodu girin.",
};
}
try {
const { account: sessionAccount } = await createSessionClient();
const challenge = await sessionAccount.createMfaChallenge({
factor: AuthenticationFactor.Totp,
});
await sessionAccount.updateMfaChallenge({
challengeId: challenge.$id,
otp,
});
} catch (e) {
// Wrong code or expired challenge — kill the partial session and ask
// them to start over with the OTP visible.
try {
if (sessionId) await users.deleteSession({ userId: sessionUserId, sessionId });
} catch {
/* best-effort */
}
(await cookies()).delete(APPWRITE_SESSION_COOKIE);
return {
ok: false,
mfaRequired: true,
error: "Kod doğrulanamadı, yeniden deneyin.",
};
}
}
} catch (e) {
console.error("[signInAction] MFA check", e);
// Fail-open on MFA check errors only when the user has no MFA configured;
// for safety, surface a generic error here.
return { ok: false, error: "Oturum doğrulanamadı." };
}
// Invite flow short-circuits the kind check — invite code drives team membership // Invite flow short-circuits the kind check — invite code drives team membership
if (inviteCode) { if (inviteCode) {
redirect(`/d/${inviteCode}`); redirect(`/d/${inviteCode}`);
+2
View File
@@ -1,6 +1,8 @@
export type AuthState = { export type AuthState = {
ok: boolean; ok: boolean;
error?: string; error?: string;
/** Set when the account has MFA enabled and the OTP field was empty. */
mfaRequired?: boolean;
}; };
export const initialAuthState: AuthState = { ok: false }; export const initialAuthState: AuthState = { ok: false };
+17 -8
View File
@@ -110,7 +110,7 @@ export async function requestConnectionAction(
approvedAt: null, approvedAt: null,
rejectedAt: null, rejectedAt: null,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -132,7 +132,7 @@ export async function requestConnectionAction(
}, },
connectionPermissions(clinicTenantId, labTenantId), connectionPermissions(clinicTenantId, labTenantId),
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -141,7 +141,7 @@ export async function requestConnectionAction(
changes: { clinicTenantId, labTenantId, status: "pending" }, changes: { clinicTenantId, labTenantId, status: "pending" },
}); });
const counterpartId = counterpart.tenantId; const counterpartId = counterpart.tenantId;
await createNotification({ void createNotification({
tenantId: counterpartId, tenantId: counterpartId,
connectionId: created.$id, connectionId: created.$id,
message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`, message: `${ctx.settings?.companyName ?? "Bir hesap"} bağlantı talebi gönderdi.`,
@@ -217,7 +217,7 @@ export async function approveConnectionAction(
approvedAt: new Date().toISOString(), approvedAt: new Date().toISOString(),
rejectedAt: null, rejectedAt: null,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -227,7 +227,7 @@ export async function approveConnectionAction(
}); });
const requesterTenant = const requesterTenant =
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId; conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
await createNotification({ void createNotification({
tenantId: requesterTenant, tenantId: requesterTenant,
connectionId, connectionId,
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`, message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi onayladı.`,
@@ -270,7 +270,7 @@ export async function rejectConnectionAction(
status: "rejected", status: "rejected",
rejectedAt: new Date().toISOString(), rejectedAt: new Date().toISOString(),
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -278,6 +278,15 @@ export async function rejectConnectionAction(
entityId: connectionId, entityId: connectionId,
changes: { status: "rejected" }, changes: { status: "rejected" },
}); });
// Tell the requester their request was turned down — warning, not info.
const requesterTenant =
conn.clinicTenantId === ctx.tenantId ? conn.labTenantId : conn.clinicTenantId;
void createNotification({
tenantId: requesterTenant,
connectionId,
severity: "warning",
message: `${ctx.settings?.companyName ?? "Karşı taraf"} bağlantı talebinizi reddetti.`,
});
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") }; return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
} }
@@ -313,7 +322,7 @@ export async function cancelConnectionAction(
try { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId); await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
@@ -350,7 +359,7 @@ export async function deleteConnectionAction(
try { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId); await tablesDB.deleteRow(DATABASE_ID, TABLES.connections, connectionId);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
+33 -3
View File
@@ -28,6 +28,8 @@ export type DashboardData = {
approvedConnectionsCount: number; approvedConnectionsCount: number;
recentJobs: DashboardJob[]; recentJobs: DashboardJob[];
recentNotifications: Notification[]; recentNotifications: Notification[];
/** Open jobs whose dueDate has already passed. */
overdueJobs: DashboardJob[];
}; };
export async function getDashboardData( export async function getDashboardData(
@@ -41,8 +43,16 @@ export async function getDashboardData(
// count separately for the stat card. // count separately for the stat card.
const jobsField = isLab ? "labTenantId" : "clinicTenantId"; const jobsField = isLab ? "labTenantId" : "clinicTenantId";
const [recentJobsRes, openJobsRes, pendingActionRes, financeRes, notifRes, unreadRes, connRes] = const [
await Promise.all([ recentJobsRes,
openJobsRes,
pendingActionRes,
financeRes,
notifRes,
unreadRes,
connRes,
overdueRes,
] = await Promise.all([
tablesDB.listRows({ tablesDB.listRows({
databaseId: DATABASE_ID, databaseId: DATABASE_ID,
tableId: TABLES.jobs, tableId: TABLES.jobs,
@@ -110,12 +120,27 @@ export async function getDashboardData(
Query.limit(1), Query.limit(1),
], ],
}), }),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal(jobsField, tenantId),
Query.notEqual("status", "delivered"),
Query.notEqual("status", "cancelled"),
Query.lessThan("dueDate", new Date().toISOString()),
Query.orderAsc("dueDate"),
Query.limit(10),
],
}),
]); ]);
const recentJobs = recentJobsRes.rows as unknown as Job[]; const recentJobs = recentJobsRes.rows as unknown as Job[];
const overdueJobs = overdueRes.rows as unknown as Job[];
const counterpartIds = Array.from( const counterpartIds = Array.from(
new Set( new Set(
recentJobs.map((j) => (isLab ? j.clinicTenantId : j.labTenantId)).filter(Boolean), [...recentJobs, ...overdueJobs]
.map((j) => (isLab ? j.clinicTenantId : j.labTenantId))
.filter(Boolean),
), ),
); );
@@ -155,5 +180,10 @@ export async function getDashboardData(
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null, counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
})), })),
recentNotifications: notifRes.rows as unknown as Notification[], recentNotifications: notifRes.rows as unknown as Notification[],
overdueJobs: overdueJobs.map((j) => ({
...j,
counterpartName:
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
})),
}); });
} }
+56
View File
@@ -0,0 +1,56 @@
import type { Job } from "./schema";
export type DueState =
| { kind: "none" }
| { kind: "future"; days: number }
| { kind: "soon"; days: number }
| { kind: "today" }
| { kind: "overdue"; days: number };
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
* Bucket a job's due date into a UI-friendly label.
* - none → no due date
* - future → more than 3 days away
* - soon → 1-3 days away (warn)
* - today → today (warn)
* - overdue → past, work isn't delivered (error)
*
* Cancelled or delivered jobs always resolve to 'none' — nothing to warn
* about once the case is closed.
*/
export function dueState(
job: Pick<Job, "dueDate" | "status">,
now: Date = new Date(),
): DueState {
if (!job.dueDate) return { kind: "none" };
if (job.status === "delivered" || job.status === "cancelled") {
return { kind: "none" };
}
const due = new Date(job.dueDate);
// Compare at day granularity so a deadline at 23:59 isn't 'overdue' a
// few seconds in.
const dueDay = Date.UTC(due.getUTCFullYear(), due.getUTCMonth(), due.getUTCDate());
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const diffDays = Math.round((dueDay - today) / MS_PER_DAY);
if (diffDays < 0) return { kind: "overdue", days: Math.abs(diffDays) };
if (diffDays === 0) return { kind: "today" };
if (diffDays <= 3) return { kind: "soon", days: diffDays };
return { kind: "future", days: diffDays };
}
export function dueLabel(state: DueState): string {
switch (state.kind) {
case "overdue":
return state.days === 1 ? "1 gün gecikti" : `${state.days} gün gecikti`;
case "today":
return "Bugün teslim";
case "soon":
return state.days === 1 ? "Yarın teslim" : `${state.days} gün kaldı`;
case "future":
return `${state.days} gün kaldı`;
case "none":
return "";
}
}
+2 -2
View File
@@ -55,7 +55,7 @@ export async function markFinancePaidAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, { await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
status: "paid", status: "paid",
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -95,7 +95,7 @@ export async function reopenFinanceAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, { await tablesDB.updateRow(DATABASE_ID, TABLES.financeEntries, id, {
status: "pending", status: "pending",
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
+330 -56
View File
@@ -1,11 +1,13 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod"; import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync"; import { syncFinanceForJob } from "./finance-sync";
import { archiveJobFiles } from "./job-file-archive";
import { createNotification } from "./notification-helpers"; import { createNotification } from "./notification-helpers";
import { calculateJobPriceForProsthetic } from "./pricing"; import { calculateJobPriceForProsthetic } from "./pricing";
import { import {
@@ -201,7 +203,7 @@ export async function createJobAction(
}, },
jobPermissions(ctx.tenantId, parsed.data.labTenantId), jobPermissions(ctx.tenantId, parsed.data.labTenantId),
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -209,7 +211,7 @@ export async function createJobAction(
entityId: created.$id, entityId: created.$id,
changes: { labTenantId: parsed.data.labTenantId, patientCode }, changes: { labTenantId: parsed.data.labTenantId, patientCode },
}); });
await createNotification({ void createNotification({
tenantId: parsed.data.labTenantId, tenantId: parsed.data.labTenantId,
jobId: created.$id, jobId: created.$id,
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`, message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
@@ -297,23 +299,30 @@ export async function acceptJobAction(
try { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
// Accepting the job = lab took the impression, started substructure work.
// Step jumps straight to alt_yapi_prova; location flips to at_lab.
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "in_progress", status: "in_progress",
currentStep: "olcu", currentStep: "alt_yapi_prova",
location: "at_lab",
}); });
await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id }); await appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
entityType: "job", entityType: "job",
entityId: jobId, entityId: jobId,
changes: { status: "in_progress", currentStep: "olcu" }, changes: {
status: "in_progress",
currentStep: "alt_yapi_prova",
location: "at_lab",
},
}); });
await createNotification({ void createNotification({
tenantId: job.clinicTenantId, tenantId: job.clinicTenantId,
jobId, jobId,
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı.`, message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
}); });
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "Kabul edilemedi.") }; return { ok: false, error: appwriteError(e, "Kabul edilemedi.") };
@@ -322,10 +331,81 @@ export async function acceptJobAction(
revalidatePath(`/jobs/${jobId}`); revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound"); revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound"); revalidatePath("/jobs/outbound");
return { ok: true }; // Redirect forces a full RSC payload reload — bypasses any client-side
// cache that router.refresh() might otherwise miss.
redirect(`/jobs/${jobId}?flash=accepted`);
} }
export async function advanceStepAction( /**
* Lab takes all currently-pending jobs in one go. Same effect as calling
* acceptJobAction for each row individually — status flips to in_progress,
* step jumps to alt_yapi_prova, location lands at_lab. Partial failures
* are tolerated; we return how many actually moved.
*/
export async function bulkAcceptPendingJobsAction(): Promise<
JobActionState & { accepted?: number }
> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlemi yalnızca laboratuvar yapabilir." };
}
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("labTenantId", ctx.tenantId),
Query.equal("status", "pending"),
Query.limit(200),
],
});
const rows = result.rows as unknown as Job[];
if (rows.length === 0) return { ok: true, accepted: 0 };
const outcomes = await Promise.allSettled(
rows.map(async (job) => {
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, job.$id, {
status: "in_progress",
currentStep: "alt_yapi_prova",
location: "at_lab",
});
void appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
void createNotification({
tenantId: job.clinicTenantId,
jobId: job.$id,
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
});
}),
);
const accepted = outcomes.filter((o) => o.status === "fulfilled").length;
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: "bulk",
changes: { bulk: "accept_pending", count: accepted },
});
revalidatePath("/jobs/inbound");
revalidatePath("/dashboard");
return { ok: true, accepted };
}
/**
* Lab hands the work back to the clinic for the next physical step
* (prova or final delivery). The current step stays the same — only the
* location flips at_lab → at_clinic. If the lab is finishing the last
* production step (cila_bitim), that's the final delivery and the job
* status becomes "sent".
*/
export async function handToClinicAction(
_prev: JobActionState, _prev: JobActionState,
formData: FormData, formData: FormData,
): Promise<JobActionState> { ): Promise<JobActionState> {
@@ -339,7 +419,7 @@ export async function advanceStepAction(
requireRole(ctx, ["owner", "admin", "member"]); requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["lab"]); requireTenantKind(ctx, ["lab"]);
} catch { } catch {
return { ok: false, error: "Sadece laboratuvar aşama ilerletebilir." }; return { ok: false, error: "Sadece laboratuvar kliniğe gönderebilir." };
} }
const job = await loadJobForTenant(jobId, ctx.tenantId); const job = await loadJobForTenant(jobId, ctx.tenantId);
@@ -347,73 +427,254 @@ export async function advanceStepAction(
return { ok: false, error: "İş bulunamadı." }; return { ok: false, error: "İş bulunamadı." };
} }
if (job.status !== "in_progress") { if (job.status !== "in_progress") {
return { ok: false, error: "Yalnızca işleme alınmış işler ilerletilebilir." }; return { ok: false, error: "Sadece işlemdeki işler kliniğe gönderilebilir." };
}
if (job.location !== "at_lab") {
return { ok: false, error: "İş zaten kliniğe gönderilmiş." };
}
if (!job.currentStep) {
return { ok: false, error: "Mevcut aşama bilinmiyor." };
} }
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
if (currentIdx < 0) return { ok: false, error: "Mevcut aşama bilinmiyor." };
const nextIdx = currentIdx + 1; const isFinalStep = job.currentStep === "cila_bitim";
const isFinalStepComplete = currentIdx === JOB_STEP_ORDER.length - 1;
try { try {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
if (isFinalStepComplete) { if (isFinalStep) {
// Final delivery — production is done, status moves to sent.
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "sent", status: "sent",
}); location: "at_clinic",
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { status: "sent" },
});
} else {
const nextStep = JOB_STEP_ORDER[nextIdx];
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
currentStep: nextStep,
}); });
await appendJobHistory({ await appendJobHistory({
job, job,
step: job.currentStep!, step: "cila_bitim",
completedBy: ctx.user.id, completedBy: ctx.user.id,
note, note,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
entityType: "job", entityType: "job",
entityId: jobId, entityId: jobId,
changes: { currentStep: nextStep, completedStep: job.currentStep }, changes: { status: "sent", location: "at_clinic" },
});
void syncFinanceForJob({ ...job, status: "sent" });
void createNotification({
tenantId: job.clinicTenantId,
jobId,
message: `Hasta ${job.patientCode} cila/bitim tamamlandı, nihai teslime gönderildi.`,
});
} else {
// Prova için klinike geçici teslim — step aynı, location değişti.
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
location: "at_clinic",
});
await appendJobHistory({
job,
step: job.currentStep,
completedBy: ctx.user.id,
note,
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: { location: "at_clinic", handedOffStep: job.currentStep },
});
const stepLabel =
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
void createNotification({
tenantId: job.clinicTenantId,
jobId,
message: `Hasta ${job.patientCode} ${stepLabel} provasına hazır, kliniğe gönderildi.`,
}); });
} }
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "İlerletilemedi.") }; return { ok: false, error: appwriteError(e, "Gönderilemedi.") };
}
if (isFinalStepComplete) {
// Record completion of the last step too, then mark sent.
await appendJobHistory({
job,
step: job.currentStep!,
completedBy: ctx.user.id,
note,
});
await syncFinanceForJob({ ...job, status: "sent" });
await createNotification({
tenantId: job.clinicTenantId,
jobId,
message: `Hasta ${job.patientCode} işi gönderildi. Teslim alındığında onaylayın.`,
});
} }
revalidatePath(`/jobs/${jobId}`); revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound"); revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound"); revalidatePath("/jobs/outbound");
revalidatePath("/finance"); revalidatePath("/finance");
return { ok: true }; redirect(`/jobs/${jobId}?flash=handed`);
}
/**
* Clinic confirms the prova was successful. Step advances to the next
* production stage and location flips back at_clinic → at_lab so the
* lab can pick the work back up.
*/
export async function approveAtClinicAction(
_prev: JobActionState,
formData: FormData,
): Promise<JobActionState> {
const jobId = String(formData.get("jobId") ?? "").trim();
if (!jobId) return { ok: false, error: "İş bulunamadı." };
const note = String(formData.get("note") ?? "").trim() || undefined;
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["clinic"]);
} catch {
return { ok: false, error: "Sadece klinik provayı onaylayabilir." };
}
const job = await loadJobForTenant(jobId, ctx.tenantId);
if (!job || job.clinicTenantId !== ctx.tenantId) {
return { ok: false, error: "İş bulunamadı." };
}
if (job.status !== "in_progress") {
return { ok: false, error: "Yalnızca işlemdeki provalar onaylanabilir." };
}
if (job.location !== "at_clinic") {
return { ok: false, error: "İş şu an klinikte değil." };
}
if (!job.currentStep) {
return { ok: false, error: "Mevcut aşama bilinmiyor." };
}
const currentIdx = JOB_STEP_ORDER.indexOf(job.currentStep);
const nextStep = JOB_STEP_ORDER[currentIdx + 1];
if (!nextStep) {
return { ok: false, error: "Bu aşamadan ileri gidilemez." };
}
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
currentStep: nextStep,
location: "at_lab",
});
await appendJobHistory({
job,
step: job.currentStep,
completedBy: ctx.user.id,
note,
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: {
currentStep: nextStep,
location: "at_lab",
completedStep: job.currentStep,
},
});
const stepLabel =
job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
void createNotification({
tenantId: job.labTenantId,
jobId,
message: `Hasta ${job.patientCode} ${stepLabel} provası onaylandı, lab tarafına geri döndü.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
}
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
redirect(`/jobs/${jobId}?flash=approved`);
}
/**
* Clinic rejects the prova and asks the lab to redo this stage. The job
* goes back to the lab without advancing the step, so the same prova
* stage will repeat after the lab finishes the rework. A note explaining
* what's wrong is required — there's no point bouncing a case back
* without telling the lab what to fix.
*/
export async function requestRevisionAction(
_prev: JobActionState,
formData: FormData,
): Promise<JobActionState> {
const jobId = String(formData.get("jobId") ?? "").trim();
if (!jobId) return { ok: false, error: "İş bulunamadı." };
const note = String(formData.get("note") ?? "").trim();
if (!note) {
return {
ok: false,
error: "Düzeltme talebi için neyin yanlış olduğunu yazmanız gerek.",
};
}
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["clinic"]);
} catch {
return { ok: false, error: "Düzeltme talebini yalnızca klinik açabilir." };
}
const job = await loadJobForTenant(jobId, ctx.tenantId);
if (!job || job.clinicTenantId !== ctx.tenantId) {
return { ok: false, error: "İş bulunamadı." };
}
if (job.status !== "in_progress") {
return { ok: false, error: "Yalnızca işlemdeki provalar için düzeltme istenebilir." };
}
if (job.location !== "at_clinic") {
return { ok: false, error: "İş şu an klinikte değil." };
}
if (!job.currentStep) {
return { ok: false, error: "Mevcut aşama bilinmiyor." };
}
try {
const { tablesDB } = createAdminClient();
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
location: "at_lab",
// currentStep stays the same — the lab will rework this stage.
});
await appendJobHistory({
job,
step: job.currentStep,
completedBy: ctx.user.id,
note: `[Düzeltme talebi] ${note}`,
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: jobId,
changes: {
location: "at_lab",
revisionRequestedAtStep: job.currentStep,
note,
},
});
const stepLabel =
job.currentStep === "alt_yapi_prova"
? "alt yapı"
: job.currentStep === "ust_yapi_prova"
? "üst yapı"
: "cila/bitim";
void createNotification({
tenantId: job.labTenantId,
jobId,
severity: "warning",
message: `Hasta ${job.patientCode} ${stepLabel} provası için düzeltme istendi: ${note.slice(0, 120)}`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Düzeltme talebi gönderilemedi.") };
}
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
redirect(`/jobs/${jobId}?flash=revision`);
} }
export async function markDeliveredAction( export async function markDeliveredAction(
@@ -445,7 +706,7 @@ export async function markDeliveredAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "delivered", status: "delivered",
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -453,12 +714,15 @@ export async function markDeliveredAction(
entityId: jobId, entityId: jobId,
changes: { status: "delivered" }, changes: { status: "delivered" },
}); });
await syncFinanceForJob({ ...job, status: "delivered" }); void syncFinanceForJob({ ...job, status: "delivered" });
await createNotification({ void createNotification({
tenantId: job.labTenantId, tenantId: job.labTenantId,
jobId, jobId,
message: `Hasta ${job.patientCode} işi teslim alındı.`, message: `Hasta ${job.patientCode} işi teslim alındı.`,
}); });
// Free up Storage now that the case is closed. Metadata rows stay for
// the audit trail; only the binaries go.
void archiveJobFiles(jobId);
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") }; return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
} }
@@ -467,7 +731,7 @@ export async function markDeliveredAction(
revalidatePath("/jobs/outbound"); revalidatePath("/jobs/outbound");
revalidatePath("/jobs/inbound"); revalidatePath("/jobs/inbound");
revalidatePath("/finance"); revalidatePath("/finance");
return { ok: true }; redirect(`/jobs/${jobId}?flash=delivered`);
} }
export async function cancelJobAction( export async function cancelJobAction(
@@ -502,7 +766,7 @@ export async function cancelJobAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, { await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, jobId, {
status: "cancelled", status: "cancelled",
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -510,6 +774,16 @@ export async function cancelJobAction(
entityId: jobId, entityId: jobId,
changes: { status: "cancelled" }, changes: { status: "cancelled" },
}); });
// Notify the other side — cancellation is a warning, not normal traffic.
const otherTenantId =
ctx.tenantId === job.clinicTenantId ? job.labTenantId : job.clinicTenantId;
const actor = ctx.kind === "lab" ? "Laboratuvar" : "Klinik";
void createNotification({
tenantId: otherTenantId,
jobId,
severity: "warning",
message: `${actor} hasta ${job.patientCode} işini iptal etti.`,
});
} catch (e) { } catch (e) {
return { ok: false, error: appwriteError(e, "İptal edilemedi.") }; return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
} }
@@ -517,5 +791,5 @@ export async function cancelJobAction(
revalidatePath(`/jobs/${jobId}`); revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound"); revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound"); revalidatePath("/jobs/outbound");
return { ok: true }; redirect(`/jobs/${jobId}?flash=cancelled`);
} }
+2 -2
View File
@@ -138,7 +138,7 @@ export async function uploadJobFilesAction(
createdRowIds.push(row.$id); createdRowIds.push(row.$id);
} }
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -206,7 +206,7 @@ export async function deleteJobFileAction(
// File may already be gone; row is the source of truth. // File may already be gone; row is the source of truth.
} }
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
+48
View File
@@ -0,0 +1,48 @@
import "server-only";
import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server";
/**
* Purge the binary scan/image/document objects backing a finished job from
* Appwrite Storage and stamp archivedAt on the corresponding rows. The row
* itself stays — the lab and clinic still need the audit trail (which file
* was uploaded, by whom, when) long after delivery.
*
* Best-effort: a single Storage error must not block the calling action.
* The function never throws.
*/
export async function archiveJobFiles(jobId: string): Promise<void> {
const { tablesDB, storage } = createAdminClient();
try {
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobFiles,
queries: [Query.equal("jobId", jobId), Query.limit(500)],
});
const rows = result.rows as unknown as JobFile[];
const now = new Date().toISOString();
await Promise.all(
rows.map(async (r) => {
if (r.archivedAt) return;
try {
await storage.deleteFile(BUCKETS.jobFiles, r.fileId);
} catch {
// file already gone, or storage unreachable — still flip archivedAt
// so the UI doesn't keep teasing a download button.
}
try {
await tablesDB.updateRow(DATABASE_ID, TABLES.jobFiles, r.$id, {
archivedAt: now,
});
} catch {
// row update failed; leave it for the next call to retry.
}
}),
);
} catch {
// List itself failed — nothing to do.
}
}
+3 -3
View File
@@ -2,12 +2,12 @@ import "server-only";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { BUCKETS, DATABASE_ID, TABLES, type JobFile } from "./schema"; import { DATABASE_ID, TABLES, type JobFile } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize"; import { toPlain } from "./serialize";
import { getFileViewUrl } from "./storage";
export type JobFileWithUrl = JobFile & { export type JobFileWithUrl = JobFile & {
/** Server-side download proxy. Browser → our app → admin SDK → bucket. */
url: string; url: string;
}; };
@@ -26,7 +26,7 @@ export async function listJobFiles(jobId: string): Promise<JobFileWithUrl[]> {
return toPlain( return toPlain(
rows.map((r) => ({ rows.map((r) => ({
...r, ...r,
url: getFileViewUrl(BUCKETS.jobFiles, r.fileId), url: `/api/jobs/${jobId}/files/${r.$id}/download`,
})), })),
); );
} }
+53 -23
View File
@@ -45,38 +45,68 @@ function enrichJob(j: Job, counterpartId: string, map: Map<string, JobCounterpar
return { ...j, counterpart: map.get(counterpartId) ?? null }; return { ...j, counterpart: map.get(counterpartId) ?? null };
} }
/** Inbound for a lab tenant — jobs the lab has received. */ export type JobListFilters = {
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> { status?: string;
location?: string;
/** Free-text matched client-side against patientCode + counterpart name. */
q?: string;
};
async function listJobsFor(
side: "lab" | "clinic",
tenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient(); const { tablesDB } = createAdminClient();
const sideField = side === "lab" ? "labTenantId" : "clinicTenantId";
const queries = [
Query.equal(sideField, tenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
];
if (filters.status) queries.unshift(Query.equal("status", filters.status));
if (filters.location) queries.unshift(Query.equal("location", filters.location));
const result = await tablesDB.listRows({ const result = await tablesDB.listRows({
databaseId: DATABASE_ID, databaseId: DATABASE_ID,
tableId: TABLES.jobs, tableId: TABLES.jobs,
queries: [ queries,
Query.equal("labTenantId", labTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
}); });
const jobs = result.rows as unknown as Job[]; const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId)))); const counterpartField = side === "lab" ? "clinicTenantId" : "labTenantId";
return toPlain(jobs.map((j) => enrichJob(j, j.clinicTenantId, map))); const map = await fetchTenants(
Array.from(new Set(jobs.map((j) => j[counterpartField]))),
);
const enriched = jobs.map((j) => enrichJob(j, j[counterpartField], map));
// Free-text filter applied after fetch — only against the fields a user
// would actually type (patient code, counterpart company name).
const q = filters.q?.trim().toLocaleLowerCase("tr-TR");
const filtered = q
? enriched.filter((j) => {
const hay = `${j.patientCode} ${j.counterpart?.companyName ?? ""}`
.toLocaleLowerCase("tr-TR");
return hay.includes(q);
})
: enriched;
return toPlain(filtered);
}
/** Inbound for a lab tenant — jobs the lab has received. */
export async function listInboundJobs(
labTenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
return listJobsFor("lab", labTenantId, filters);
} }
/** Outbound for a clinic tenant — jobs the clinic has sent. */ /** Outbound for a clinic tenant — jobs the clinic has sent. */
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> { export async function listOutboundJobs(
const { tablesDB } = createAdminClient(); clinicTenantId: string,
const result = await tablesDB.listRows({ filters: JobListFilters = {},
databaseId: DATABASE_ID, ): Promise<JobWithCounterpart[]> {
tableId: TABLES.jobs, return listJobsFor("clinic", clinicTenantId, filters);
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.labTenantId))));
return toPlain(jobs.map((j) => enrichJob(j, j.labTenantId, map)));
} }
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */ /** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
+6 -1
View File
@@ -1,4 +1,4 @@
import type { JobStatus, JobStep, ProstheticType } from "./schema"; import type { JobLocation, JobStatus, JobStep, ProstheticType } from "./schema";
export type JobFormState = { export type JobFormState = {
ok: boolean; ok: boolean;
@@ -38,6 +38,11 @@ export const JOB_STEP_ORDER: JobStep[] = [
"cila_bitim", "cila_bitim",
]; ];
export const JOB_LOCATION_LABELS: Record<JobLocation, string> = {
at_clinic: "Klinikte",
at_lab: "Laboratuvarda",
};
export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = { export const PROSTHETIC_TYPE_LABELS: Record<ProstheticType, string> = {
metal_porselen: "Metal Porselen", metal_porselen: "Metal Porselen",
zirkonyum: "Zirkonyum", zirkonyum: "Zirkonyum",
+2 -2
View File
@@ -87,7 +87,7 @@ export async function uploadLogoAction(
} }
} }
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -151,7 +151,7 @@ export async function removeLogoAction(): Promise<LogoActionState> {
/* file already gone, fine */ /* file already gone, fine */
} }
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, AuthenticatorType } from "node-appwrite";
import { createSessionClient } from "./server";
export type MfaEnrollState = {
ok: boolean;
error?: string;
/** otpauth:// URI for QR; only set on enroll start. */
uri?: string;
/** Plain TOTP secret as a fallback if the QR can't be scanned. */
secret?: string;
};
export const initialMfaEnrollState: MfaEnrollState = { ok: false };
export type MfaActionState = {
ok: boolean;
error?: string;
/** Recovery codes returned right after enable; show once, never stored again. */
recoveryCodes?: string[];
};
export const initialMfaActionState: MfaActionState = { ok: false };
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
if (e instanceof AppwriteException) return e.message || fallback;
return process.env.NODE_ENV !== "production" && e instanceof Error
? `${fallback} (${e.message})`
: fallback;
}
/**
* Step 1 of TOTP enroll: produce a fresh secret and otpauth URI for the
* user's authenticator app. Calling this when an authenticator already
* exists yields the same secret back.
*/
export async function startMfaEnrollAction(): Promise<MfaEnrollState> {
try {
const { account } = await createSessionClient();
const res = await account.createMFAAuthenticator(AuthenticatorType.Totp);
return { ok: true, uri: res.uri, secret: res.secret };
} catch (e) {
return { ok: false, error: appwriteError(e, "MFA başlatılamadı.") };
}
}
/**
* Step 2 of TOTP enroll: user scanned the QR, opened their authenticator,
* typed the 6-digit code. We verify, then flip account.mfa = true so
* future sign-ins require the second factor. Returns recovery codes —
* shown once.
*/
export async function verifyMfaEnrollAction(
_prev: MfaActionState,
formData: FormData,
): Promise<MfaActionState> {
const otp = String(formData.get("otp") ?? "").trim();
if (!otp || otp.length < 6) {
return { ok: false, error: "6 haneli kodu girin." };
}
try {
const { account } = await createSessionClient();
await account.updateMFAAuthenticator(AuthenticatorType.Totp, otp);
await account.updateMFA(true);
const codes = await account.createMFARecoveryCodes();
return { ok: true, recoveryCodes: codes.recoveryCodes };
} catch (e) {
return { ok: false, error: appwriteError(e, "Doğrulanamadı.") };
}
}
/**
* Disable MFA: turn the account flag off and remove the TOTP authenticator
* so the user can re-enroll later with a fresh secret. Requires a current
* authenticated session.
*/
export async function disableMfaAction(): Promise<MfaActionState> {
try {
const { account } = await createSessionClient();
await account.updateMFA(false);
try {
await account.deleteMFAAuthenticator(AuthenticatorType.Totp);
} catch {
// Already removed — ignore.
}
revalidatePath("/settings/security");
return { ok: true };
} catch (e) {
return { ok: false, error: appwriteError(e, "Devre dışı bırakılamadı.") };
}
}
export async function regenerateRecoveryCodesAction(): Promise<MfaActionState> {
try {
const { account } = await createSessionClient();
const codes = await account.updateMFARecoveryCodes();
return { ok: true, recoveryCodes: codes.recoveryCodes };
} catch (e) {
return { ok: false, error: appwriteError(e, "Yedek kodlar üretilemedi.") };
}
}
+10 -1
View File
@@ -2,7 +2,12 @@ import "server-only";
import { ID, Permission, Query, Role } from "node-appwrite"; import { ID, Permission, Query, Role } from "node-appwrite";
import { DATABASE_ID, TABLES, type Notification } from "./schema"; import {
DATABASE_ID,
TABLES,
type Notification,
type NotificationSeverity,
} from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize"; import { toPlain } from "./serialize";
@@ -12,6 +17,9 @@ type CreateNotificationInput = {
jobId?: string; jobId?: string;
connectionId?: string; connectionId?: string;
message: string; message: string;
/** Defaults to 'info'. Use 'warning' for things that need the user's
* attention (revision, cancellation, rejections). */
severity?: NotificationSeverity;
}; };
/** /**
@@ -32,6 +40,7 @@ export async function createNotification(input: CreateNotificationInput): Promis
connectionId: input.connectionId, connectionId: input.connectionId,
message: input.message.slice(0, 500), message: input.message.slice(0, 500),
read: false, read: false,
severity: input.severity ?? "info",
}, },
[ [
Permission.read(Role.team(input.tenantId)), Permission.read(Role.team(input.tenantId)),
+3 -3
View File
@@ -137,7 +137,7 @@ export async function createPatientAction(
}, },
patientPermissions(ctx.tenantId), patientPermissions(ctx.tenantId),
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -191,7 +191,7 @@ export async function updatePatientAction(
lastName: parsed.data.lastName, lastName: parsed.data.lastName,
notes: parsed.data.notes, notes: parsed.data.notes,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -236,7 +236,7 @@ export async function archivePatientAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, { await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
archived: !row.archived, archived: !row.archived,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
+45 -1
View File
@@ -2,7 +2,7 @@ import "server-only";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Patient } from "./schema"; import { DATABASE_ID, TABLES, type Job, type Patient } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { toPlain } from "./serialize"; import { toPlain } from "./serialize";
@@ -44,3 +44,47 @@ export async function getPatient(
return null; return null;
} }
} }
/**
* Every job linked to this patient — by explicit patientId on newer jobs,
* or by matching patientCode on legacy rows that pre-date the relation
* (we still want to surface that history).
*/
export async function listPatientJobs(
patientId: string,
patientCode: string,
clinicTenantId: string,
): Promise<Job[]> {
const { tablesDB } = createAdminClient();
const [byId, byCode] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.equal("patientId", patientId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.equal("patientCode", patientCode),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
}),
]);
const seen = new Set<string>();
const merged: Job[] = [];
for (const row of [...byId.rows, ...byCode.rows] as unknown as Job[]) {
if (seen.has(row.$id)) continue;
seen.add(row.$id);
merged.push(row);
}
merged.sort((a, b) => (a.$createdAt < b.$createdAt ? 1 : -1));
return toPlain(merged);
}
+322
View File
@@ -0,0 +1,322 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { createNotification } from "./notification-helpers";
import {
DATABASE_ID,
TABLES,
type Connection,
type Payment,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant } from "./tenant-guard";
import type {
PaymentActionState,
PaymentFormState,
} from "./payment-types";
import { paymentSchema } from "@/lib/validation/payment";
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
if (e instanceof AppwriteException) return e.message || fallback;
return process.env.NODE_ENV !== "production" && e instanceof Error
? `${fallback} (${e.message})`
: fallback;
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function paymentPermissions(tenantId: string, counterpartTenantId: string): string[] {
return [
Permission.read(Role.team(tenantId)),
Permission.read(Role.team(counterpartTenantId)),
Permission.update(Role.team(tenantId, "owner")),
Permission.update(Role.team(tenantId, "admin")),
Permission.delete(Role.team(tenantId, "owner")),
Permission.delete(Role.team(tenantId, "admin")),
];
}
function pickFields(formData: FormData) {
return {
counterpartTenantId: String(formData.get("counterpartTenantId") ?? "").trim(),
amount: String(formData.get("amount") ?? "").trim(),
currency: String(formData.get("currency") ?? "").trim(),
paymentDate: String(formData.get("paymentDate") ?? "").trim(),
method: String(formData.get("method") ?? "").trim(),
notes: String(formData.get("notes") ?? "").trim(),
};
}
/**
* Record a payment between this tenant and a counterpart. Direction is
* determined by the caller's tenant kind:
* - lab → inflow (the lab received money from a clinic)
* - clinic → outflow (the clinic paid a lab)
*
* A single payment can cover many invoices at once — we do NOT walk back
* into finance_entries to settle individual rows. The open balance per
* connection is always computed live as (sum of receivables for that
* counterpart) - (sum of payments inflow from that counterpart) for the
* lab side, and the symmetric formula for the clinic side.
*/
export async function recordPaymentAction(
_prev: PaymentFormState,
formData: FormData,
): Promise<PaymentFormState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Ödeme kaydı için yetkiniz yok." };
}
if (!ctx.kind) {
return { ok: false, error: "Tenant türü bilinmiyor." };
}
const direction = ctx.kind === "lab" ? "inflow" : "outflow";
const parsed = paymentSchema.safeParse(pickFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
const { tablesDB } = createAdminClient();
// Counterpart must be an approved connection — we never let a tenant
// record payments against an unconnected workspace.
const labId = ctx.kind === "lab" ? ctx.tenantId : parsed.data.counterpartTenantId;
const clinicId = ctx.kind === "lab" ? parsed.data.counterpartTenantId : ctx.tenantId;
const connRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.connections,
queries: [
Query.equal("labTenantId", labId),
Query.equal("clinicTenantId", clinicId),
Query.equal("status", "approved"),
Query.limit(1),
],
});
if (!(connRes.rows[0] as unknown as Connection | undefined)) {
return {
ok: false,
error: "Onaylı bir bağlantı bulunamadı.",
fieldErrors: { counterpartTenantId: "Bağlantı yok." },
};
}
// Lab-recorded payments are self-confirmed (the lab knows it received
// the money). Clinic-recorded payments enter as pending and require the
// lab to confirm before they're counted in the open balance.
const status = ctx.kind === "lab" ? "confirmed" : "pending";
try {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.payments,
ID.unique(),
{
tenantId: ctx.tenantId,
counterpartTenantId: parsed.data.counterpartTenantId,
direction,
amount: parsed.data.amount,
currency: parsed.data.currency,
paymentDate: parsed.data.paymentDate,
method: parsed.data.method,
notes: parsed.data.notes,
status,
recordedBy: ctx.user.id,
},
paymentPermissions(ctx.tenantId, parsed.data.counterpartTenantId),
);
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "payment",
entityId: created.$id,
changes: {
direction,
amount: parsed.data.amount,
counterpartTenantId: parsed.data.counterpartTenantId,
status,
},
});
// Notify the lab when a clinic submits a payment for approval.
if (status === "pending") {
void createNotification({
tenantId: parsed.data.counterpartTenantId,
message: `Yeni ödeme bildirimi: ${parsed.data.amount.toLocaleString("tr-TR")} ${parsed.data.currency} — onayınızı bekliyor.`,
});
}
} catch (e) {
return { ok: false, error: appwriteError(e, "Ödeme kaydedilemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
/**
* Lab confirms a payment the clinic claimed to have made. The row stays
* with the clinic as the recorder (tenantId), only its status flips so
* the balance computation starts counting it.
*/
export async function confirmPaymentAction(
_prev: PaymentActionState,
formData: FormData,
): Promise<PaymentActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
if (ctx.kind !== "lab") {
return { ok: false, error: "Onayı yalnızca laboratuvar verebilir." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
id,
)) as unknown as Payment;
// Lab can only confirm payments where IT is the counterpart and the
// clinic was the recorder. Anything else is a permission error.
if (row.counterpartTenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ödeme size ait değil." };
}
if (row.status === "confirmed") {
return { ok: true };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
status: "confirmed",
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "payment",
entityId: id,
changes: { status: "confirmed" },
});
void createNotification({
tenantId: row.tenantId,
message: `Ödemeniz onaylandı: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Onaylanamadı.") };
}
revalidatePath("/finance");
return { ok: true };
}
export async function rejectPaymentAction(
_prev: PaymentActionState,
formData: FormData,
): Promise<PaymentActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Ödeme kaydı bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
if (ctx.kind !== "lab") {
return { ok: false, error: "Reddi yalnızca laboratuvar verebilir." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
id,
)) as unknown as Payment;
if (row.counterpartTenantId !== ctx.tenantId) {
return { ok: false, error: "Bu ödeme size ait değil." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.payments, id, {
status: "rejected",
});
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "payment",
entityId: id,
changes: { status: "rejected" },
});
void createNotification({
tenantId: row.tenantId,
severity: "warning",
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
export async function deletePaymentAction(
_prev: PaymentActionState,
formData: FormData,
): Promise<PaymentActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Kayıt bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.payments,
id,
)) as unknown as Payment;
if (row.tenantId !== ctx.tenantId) {
return { ok: false, error: "Yetkiniz yok." };
}
await tablesDB.deleteRow(DATABASE_ID, TABLES.payments, id);
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "delete",
entityType: "payment",
entityId: id,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Silinemedi.") };
}
revalidatePath("/finance");
return { ok: true };
}
+146
View File
@@ -0,0 +1,146 @@
import "server-only";
import { Query } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type FinanceEntry,
type Payment,
type TenantKind,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
/** Payments this tenant recorded itself. */
export async function listPayments(tenantId: string): Promise<Payment[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.payments,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("paymentDate"),
Query.limit(500),
],
});
return toPlain(result.rows as unknown as Payment[]);
}
/** Payments the counterpart recorded that involve this tenant. */
export async function listIncomingPayments(tenantId: string): Promise<Payment[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.payments,
queries: [
Query.equal("counterpartTenantId", tenantId),
Query.orderDesc("paymentDate"),
Query.limit(500),
],
});
return toPlain(result.rows as unknown as Payment[]);
}
export type CounterpartBalance = {
counterpartTenantId: string;
currency: string;
/** sum of receivables (lab) or debts (clinic) from finance_entries */
invoiced: number;
/** sum of *confirmed* payments — whoever recorded them */
paid: number;
/** invoiced - paid; positive means money is still owed to this tenant */
open: number;
/** most recent confirmed payment date if any, useful for sorting */
lastPaymentAt?: string;
};
/**
* For a given payment row, figure out whether it represents money flowing
* *toward* `selfTenantId` from a given counterpart. Same physical payment
* looks like inflow from one side and outflow from the other — we
* normalise both shapes into 'who is the counterpart?' here.
*/
function inflowFor(
p: Payment,
selfTenantId: string,
kind: TenantKind,
): { counterpartTenantId: string } | null {
const inflowDir = kind === "lab" ? "inflow" : "outflow";
const outflowDir = kind === "lab" ? "outflow" : "inflow";
if (p.tenantId === selfTenantId && p.direction === inflowDir) {
return { counterpartTenantId: p.counterpartTenantId };
}
if (p.counterpartTenantId === selfTenantId && p.direction === outflowDir) {
return { counterpartTenantId: p.tenantId };
}
return null;
}
export function computeBalancesByCounterpart(args: {
kind: TenantKind;
selfTenantId: string;
entries: FinanceEntry[];
payments: Payment[];
}): CounterpartBalance[] {
const isLab = args.kind === "lab";
const invoiceType = isLab ? "receivable" : "debt";
const acc = new Map<string, CounterpartBalance>();
const ensure = (id: string, currency: string): CounterpartBalance => {
const existing = acc.get(id);
if (existing) return existing;
const fresh: CounterpartBalance = {
counterpartTenantId: id,
currency,
invoiced: 0,
paid: 0,
open: 0,
};
acc.set(id, fresh);
return fresh;
};
for (const e of args.entries) {
if (e.type !== invoiceType) continue;
if (!e.counterpartTenantId) continue;
const row = ensure(e.counterpartTenantId, e.currency ?? "TRY");
row.invoiced += e.amount;
}
for (const p of args.payments) {
// Only confirmed payments count toward the open balance.
if (p.status && p.status !== "confirmed") continue;
const mapped = inflowFor(p, args.selfTenantId, args.kind);
if (!mapped) continue;
const row = ensure(mapped.counterpartTenantId, p.currency);
row.paid += p.amount;
if (!row.lastPaymentAt || p.paymentDate > row.lastPaymentAt) {
row.lastPaymentAt = p.paymentDate;
}
}
for (const row of acc.values()) {
row.open = row.invoiced - row.paid;
}
return Array.from(acc.values()).sort((a, b) => b.open - a.open);
}
/**
* Pending payments that the *other* side recorded and are waiting on this
* tenant's confirmation. Only applicable on the lab side in our current
* model (clinics record, labs confirm), but the query is symmetrical.
*/
export function filterPendingForConfirmation(
payments: Payment[],
selfTenantId: string,
kind: TenantKind,
): Payment[] {
const expectedDirection = kind === "lab" ? "outflow" : "inflow";
return payments
.filter(
(p) =>
p.status === "pending" &&
p.counterpartTenantId === selfTenantId &&
p.direction === expectedDirection,
)
.sort((a, b) => (a.paymentDate < b.paymentDate ? 1 : -1));
}
+26
View File
@@ -0,0 +1,26 @@
export type PaymentFormState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialPaymentFormState: PaymentFormState = { ok: false };
export type PaymentActionState = {
ok: boolean;
error?: string;
};
export const initialPaymentActionState: PaymentActionState = { ok: false };
export const PAYMENT_METHOD_OPTIONS = [
{ value: "cash", label: "Nakit" },
{ value: "bank", label: "Banka / Havale" },
{ value: "card", label: "Kart" },
{ value: "check", label: "Çek / Senet" },
{ value: "other", label: "Diğer" },
] as const;
export const PAYMENT_METHOD_LABELS: Record<string, string> = Object.fromEntries(
PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]),
);
+1 -1
View File
@@ -28,7 +28,7 @@ async function audit(action: "update", entityType: string, changes: Record<strin
const session = await createSessionClient(); const session = await createSessionClient();
const user = await session.account.get(); const user = await session.account.get();
const tenantId = (await getActiveTenantId()) ?? "global"; const tenantId = (await getActiveTenantId()) ?? "global";
await logAudit({ void logAudit({
tenantId, tenantId,
userId: user.$id, userId: user.$id,
action, action,
+4 -4
View File
@@ -81,7 +81,7 @@ export async function createProstheticAction(
}, },
prostheticPermissions(ctx.tenantId), prostheticPermissions(ctx.tenantId),
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -134,7 +134,7 @@ export async function updateProstheticAction(
unitPrice: parsed.data.unitPrice, unitPrice: parsed.data.unitPrice,
currency: parsed.data.currency, currency: parsed.data.currency,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -179,7 +179,7 @@ export async function archiveProstheticAction(
await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, { await tablesDB.updateRow(DATABASE_ID, TABLES.prosthetics, id, {
archived: !row.archived, archived: !row.archived,
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -222,7 +222,7 @@ export async function deleteProstheticAction(
return { ok: false, error: "Bu ürünü silme yetkiniz yok." }; return { ok: false, error: "Bu ürünü silme yetkiniz yok." };
} }
await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id); await tablesDB.deleteRow(DATABASE_ID, TABLES.prosthetics, id);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
+27
View File
@@ -11,6 +11,7 @@ export const TABLES = {
connections: "connections", connections: "connections",
patients: "patients", patients: "patients",
clinicPricing: "clinic_pricing", clinicPricing: "clinic_pricing",
payments: "payments",
jobs: "jobs", jobs: "jobs",
jobFiles: "job_files", jobFiles: "job_files",
jobStatusHistory: "job_status_history", jobStatusHistory: "job_status_history",
@@ -75,6 +76,7 @@ export interface Connection extends Row {
export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled"; export type JobStatus = "pending" | "in_progress" | "sent" | "delivered" | "cancelled";
export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim"; export type JobStep = "olcu" | "alt_yapi_prova" | "ust_yapi_prova" | "cila_bitim";
export type JobLocation = "at_clinic" | "at_lab";
export type ProstheticType = export type ProstheticType =
| "metal_porselen" | "metal_porselen"
| "zirkonyum" | "zirkonyum"
@@ -109,6 +111,7 @@ export interface Job extends Row {
currency?: string; currency?: string;
status: JobStatus; status: JobStatus;
currentStep?: JobStep; currentStep?: JobStep;
location?: JobLocation;
dueDate?: string; dueDate?: string;
} }
@@ -134,6 +137,9 @@ export interface JobFile extends Row {
name: string; name: string;
size: number; size: number;
mimeType?: string; mimeType?: string;
/** Set when the binary is purged from object storage after a job closes.
* The row stays for audit; downloads/previews are disabled past this point. */
archivedAt?: string;
} }
export interface JobStatusHistory extends Row { export interface JobStatusHistory extends Row {
@@ -172,6 +178,24 @@ export interface FinanceEntry extends Row {
description?: string; description?: string;
} }
export type PaymentDirection = "inflow" | "outflow";
export type PaymentStatus = "pending" | "confirmed" | "rejected";
export interface Payment extends Row {
tenantId: string;
counterpartTenantId: string;
direction: PaymentDirection;
amount: number;
currency: string;
paymentDate: string;
method?: string;
notes?: string;
status?: PaymentStatus;
recordedBy: string;
}
export type NotificationSeverity = "info" | "warning";
export interface Notification extends Row { export interface Notification extends Row {
tenantId: string; tenantId: string;
userId?: string; userId?: string;
@@ -179,6 +203,9 @@ export interface Notification extends Row {
connectionId?: string; connectionId?: string;
message: string; message: string;
read: boolean; read: boolean;
/** Visual + filtering hint. 'warning' for things requiring attention
* (revision request, cancellation, payment / connection rejection). */
severity?: NotificationSeverity;
} }
export type AuditAction = "create" | "update" | "delete"; export type AuditAction = "create" | "update" | "delete";
+6 -6
View File
@@ -139,7 +139,7 @@ export async function inviteMemberAction(
], ],
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
@@ -182,7 +182,7 @@ export async function cancelInviteAction(
status: "cancelled", status: "cancelled",
}); });
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -228,7 +228,7 @@ export async function removeMemberAction(
await teams.deleteMembership(ctx.tenantId, membershipId); await teams.deleteMembership(ctx.tenantId, membershipId);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
@@ -280,7 +280,7 @@ export async function leaveWorkspaceAction(): Promise<MemberActionState> {
await admin.teams.deleteMembership(ctx.tenantId, me.$id); await admin.teams.deleteMembership(ctx.tenantId, me.$id);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "delete", action: "delete",
@@ -334,7 +334,7 @@ export async function updateMemberRoleAction(
const { teams } = createAdminClient(); const { teams } = createAdminClient();
await teams.updateMembership(ctx.tenantId, membershipId, [role]); await teams.updateMembership(ctx.tenantId, membershipId, [role]);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -456,7 +456,7 @@ export async function acceptInviteAction(code: string): Promise<MemberActionStat
acceptedBy: user.$id, acceptedBy: user.$id,
}); });
await logAudit({ void logAudit({
tenantId: invite.tenantId, tenantId: invite.tenantId,
userId: user.$id, userId: user.$id,
action: "create", action: "create",
+2 -2
View File
@@ -65,7 +65,7 @@ export async function updateWorkspaceSettingsAction(
if (row) { if (row) {
await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data); await tablesDB.updateRow(DATABASE_ID, TABLES.tenantSettings, row.$id, parsed.data);
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "update", action: "update",
@@ -86,7 +86,7 @@ export async function updateWorkspaceSettingsAction(
Permission.delete(Role.team(ctx.tenantId, "owner")), Permission.delete(Role.team(ctx.tenantId, "owner")),
], ],
); );
await logAudit({ void logAudit({
tenantId: ctx.tenantId, tenantId: ctx.tenantId,
userId: ctx.user.id, userId: ctx.user.id,
action: "create", action: "create",
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod";
function toNumber(v: unknown): number {
if (typeof v === "number") return v;
const n = Number(String(v ?? "").replace(",", "."));
return Number.isFinite(n) ? n : NaN;
}
export const paymentSchema = z.object({
counterpartTenantId: z.string().min(1, "Karşı taraf seçin."),
amount: z
.union([z.string(), z.number()])
.transform(toNumber)
.pipe(z.number().positive("Tutar 0'dan büyük olmalı.")),
currency: z
.string()
.trim()
.max(8)
.optional()
.transform((v) => (v ? v.toUpperCase() : "TRY")),
paymentDate: z
.string()
.trim()
.optional()
.transform((v) => (v ? new Date(v).toISOString() : new Date().toISOString())),
method: z
.string()
.trim()
.max(30)
.optional()
.transform((v) => (v ? v : undefined)),
notes: z
.string()
.trim()
.max(1000)
.optional()
.transform((v) => (v ? v : undefined)),
});
export type PaymentInput = z.infer<typeof paymentSchema>;