Compare commits

...

14 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
44 changed files with 2613 additions and 150 deletions
@@ -88,6 +88,24 @@ export function LoginForm1({
/>
</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 && (
<p className="text-destructive text-sm text-center" role="alert">
{state.error}
+46 -5
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
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 { Button } from "@/components/ui/button";
@@ -109,6 +111,34 @@ export default async function DashboardPage() {
/>
</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 && (
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
@@ -219,23 +249,34 @@ export default async function DashboardPage() {
</p>
) : (
<ul className="divide-y">
{data.recentNotifications.map((n) => (
{data.recentNotifications.map((n) => {
const isWarning = n.severity === "warning";
return (
<li
key={n.$id}
className={`flex items-start gap-3 py-2.5 ${n.read ? "opacity-70" : ""}`}
>
<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
/>
<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">
{datetimeFormatter.format(new Date(n.$createdAt))}
</p>
</div>
</li>
))}
);
})}
</ul>
)}
</CardContent>
@@ -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>
);
}
+18
View File
@@ -12,6 +12,7 @@ import {
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { BalancesCard } from "./components/balances-card";
import { FinanceTable } from "./components/finance-table";
import { MyPendingPaymentsCard } from "./components/my-pending-payments-card";
import { PendingPaymentsCard } from "./components/pending-payments-card";
export const metadata = {
@@ -61,6 +62,16 @@ export default async function FinancePage() {
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) {
@@ -117,6 +128,13 @@ export default async function FinancePage() {
<PendingPaymentsCard rows={pendingForApproval} counterpartNames={counterpartNames} />
{!isLab && (
<MyPendingPaymentsCard
rows={myPendingOrRejected}
counterpartNames={counterpartNames}
/>
)}
<BalancesCard
balances={balances}
counterpartNames={counterpartNames}
@@ -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,7 +1,6 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
ArrowRight,
Check,
@@ -9,6 +8,7 @@ import {
Loader2,
PackageCheck,
Play,
RotateCcw,
Send,
X,
} from "lucide-react";
@@ -32,6 +32,7 @@ import {
cancelJobAction,
handToClinicAction,
markDeliveredAction,
requestRevisionAction,
} from "@/lib/appwrite/job-actions";
import { initialJobActionState } from "@/lib/appwrite/job-types";
import type { Job, TenantKind } from "@/lib/appwrite/schema";
@@ -67,7 +68,10 @@ export function JobActionsPanel({
{/* Clinic finished the prova — approve and send back to lab */}
{isClinic && job.status === "in_progress" && isAtClinic && (
<ApproveAtClinicButton job={job} />
<>
<ApproveAtClinicButton job={job} />
<RequestRevisionButton job={job} />
</>
)}
{/* Final delivery — clinic took it from the lab */}
@@ -82,17 +86,13 @@ export function JobActionsPanel({
}
function AcceptButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
useEffect(() => {
if (state.ok) {
toast.success("İş işleme alındı, alt yapı üretimi başladı.");
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
// Success path redirects from the server action, so state.ok never
// shows up here — we only need to surface errors.
if (state.error) toast.error(state.error);
}, [state]);
return (
<form action={action}>
@@ -106,19 +106,12 @@ function AcceptButton({ jobId }: { jobId: string }) {
}
function HandToClinicButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(handToClinicAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Klinik tarafına gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
const isFinal = job.currentStep === "cila_bitim";
const stageLabel =
@@ -175,19 +168,12 @@ function HandToClinicButton({ job }: { job: Job }) {
}
function ApproveAtClinicButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(approveAtClinicAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Prova onaylandı, lab tarafına gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
const stageLabel = job.currentStep === "alt_yapi_prova" ? "alt yapı" : "üst yapı";
@@ -236,18 +222,67 @@ function ApproveAtClinicButton({ job }: { job: Job }) {
);
}
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>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function DeliverButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
useEffect(() => {
if (state.ok) {
toast.success("İş teslim alındı.");
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
return (
<form action={action}>
@@ -261,19 +296,12 @@ function DeliverButton({ jobId }: { jobId: string }) {
}
function CancelButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("İş iptal edildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
if (state.error) toast.error(state.error);
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -254,7 +254,8 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
const isViewable = VIEWABLE_RE.test(file.name);
const isArchived = Boolean(file.archivedAt);
const isViewable = !isArchived && VIEWABLE_RE.test(file.name);
useEffect(() => {
if (state.ok) {
@@ -281,12 +282,20 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
}
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>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="text-muted-foreground text-xs">
{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>
</div>
<Badge variant="outline" className="hidden sm:inline-flex">
@@ -315,7 +324,13 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
</Dialog>
)}
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button size="sm" variant="outline" onClick={() => setDownloadOpen(true)}>
<Button
size="sm"
variant="outline"
onClick={() => setDownloadOpen(true)}
disabled={isArchived}
title={isArchived ? "Bu dosya arşivlendi; indirilebilir kopyası yok." : undefined}
>
<Download className="size-4" />
</Button>
<DialogContent>
+55 -29
View File
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { Badge } from "@/components/ui/badge";
import { DueBadge } from "@/components/due-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listJobFiles } from "@/lib/appwrite/job-file-queries";
@@ -116,9 +117,12 @@ export default async function JobDetailPage({
</p>
</div>
<div className="flex flex-col items-end gap-3">
<Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
<div className="flex items-center gap-2">
<DueBadge job={job} />
<Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
</div>
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
</div>
</div>
@@ -255,33 +259,55 @@ export default async function JobDetailPage({
</CardContent>
</Card>
{history.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Aşama Geçmişi</CardTitle>
<CardDescription>Tamamlanan aşamaların kaydı.</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3">
{history.map((h) => (
<li key={h.$id} className="border-l-2 border-primary/30 pl-4">
<div className="flex flex-wrap items-baseline gap-2">
<span className="font-medium">{JOB_STEP_LABELS[h.step]}</span>
<span className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(h.completedAt))}
</span>
</div>
{h.note && (
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
{h.note}
</p>
)}
</li>
))}
<Card>
<CardHeader>
<CardTitle>Akış Geçmişi</CardTitle>
<CardDescription>
İşin aşama transition&apos;ları, kim yaptı ve hangi notla.
</CardDescription>
</CardHeader>
<CardContent>
{history.length === 0 ? (
<p className="text-muted-foreground text-sm">
Henüz aşama tamamlanmadı.
</p>
) : (
<ol className="relative space-y-4 border-l-2 border-border pl-6">
{history.map((h) => {
const isRevision = h.note?.startsWith("[Düzeltme talebi]");
return (
<li key={h.$id} className="relative">
<span
className={`absolute -left-[1.85rem] mt-1.5 size-3 rounded-full ring-2 ring-background ${
isRevision ? "bg-rose-500" : "bg-emerald-500"
}`}
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>
</CardContent>
</Card>
)}
)}
</CardContent>
</Card>
<div>
<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 { Button } from "@/components/ui/button";
import { DueBadge } from "@/components/due-badge";
import {
Table,
TableBody,
@@ -63,6 +64,7 @@ export function JobsTable({
<TableHead>Renk</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Termin</TableHead>
<TableHead>Tarih</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
@@ -80,6 +82,16 @@ export function JobsTable({
<TableCell>
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
</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">
{dateFormatter.format(new Date(j.$createdAt))}
</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 { listInboundJobs } from "@/lib/appwrite/job-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { JobsFilterBar } from "../_components/jobs-filter-bar";
import { JobsTable } from "../_components/jobs-table";
import { BulkAcceptButton } from "./components/bulk-accept-button";
export const metadata = {
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;
try {
ctx = await requireTenant();
@@ -17,18 +23,25 @@ export default async function InboundJobsPage() {
redirect("/onboarding");
}
// Inbound = jobs where this tenant is the lab side.
// A clinic tenant can also receive jobs only via labTenantId match, which
// would be unusual; we still surface whatever matches.
const rows = ctx.kind === "lab" ? await listInboundJobs(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 === "lab" ? await listInboundJobs(ctx.tenantId, filters) : [];
const pendingCount = rows.filter((j) => j.status === "pending").length;
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">Gelen İşler</h1>
<p className="text-muted-foreground text-sm">
Bağlı kliniklerden size yönlendirilmiş protez işleri.
</p>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Gelen İşler</h1>
<p className="text-muted-foreground text-sm">
Bağlı kliniklerden size yönlendirilmiş protez işleri.
</p>
</div>
{ctx.kind === "lab" && <BulkAcceptButton count={pendingCount} />}
</div>
<Card>
@@ -37,18 +50,21 @@ export default async function InboundJobsPage() {
<CardDescription>
{ctx.kind === "lab"
? rows.length === 0
? "Henüz gelen iş yok."
? "Filtreye uyan iş yok."
: `${rows.length} kalem`
: "Bu sayfa laboratuvar hesapları içindir."}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{ctx.kind === "lab" ? (
<JobsTable
rows={rows}
counterpartLabel="Klinik"
emptyMessage="Henüz size gönderilmiş iş yok. Klinik tarafa Bağlantı Kodunuzu paylaşın."
/>
<>
<JobsFilterBar />
<JobsTable
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">
Klinik hesabıyla giriş yaptınız gelen listesi sadece laboratuvar tarafında görünür.
+23 -9
View File
@@ -3,13 +3,18 @@ import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listOutboundJobs } from "@/lib/appwrite/job-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { JobsFilterBar } from "../_components/jobs-filter-bar";
import { JobsTable } from "../_components/jobs-table";
export const metadata = {
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;
try {
ctx = await requireTenant();
@@ -17,7 +22,13 @@ export default async function OutboundJobsPage() {
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 (
<div className="flex-1 space-y-6 px-6">
@@ -34,18 +45,21 @@ export default async function OutboundJobsPage() {
<CardDescription>
{ctx.kind === "clinic"
? rows.length === 0
? "Henüz iş göndermediniz."
? "Filtreye uyan iş yok."
: `${rows.length} kalem`
: "Bu sayfa klinik hesapları içindir."}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{ctx.kind === "clinic" ? (
<JobsTable
rows={rows}
counterpartLabel="Laboratuvar"
emptyMessage="Henüz iş göndermediniz. 'Yeni İş Yayınla' butonundan başlayabilirsiniz."
/>
<>
<JobsFilterBar />
<JobsTable
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">
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 { FlashToast } from "@/components/flash-toast";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
import { getLogoUrl } from "@/lib/appwrite/storage";
@@ -40,6 +42,9 @@ export default async function DashboardLayout({
unreadCount={unreadCount}
>
{children}
<Suspense fallback={null}>
<FlashToast />
</Suspense>
</DashboardShell>
);
}
@@ -66,10 +66,21 @@ function NotificationRow({ row }: { row: Notification }) {
? "/connections"
: null;
const isWarning = row.severity === "warning";
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
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
/>
<div className="min-w-0 flex-1">
@@ -79,8 +90,11 @@ function NotificationRow({ row }: { row: Notification }) {
</p>
</div>
{!row.read && (
<Badge variant="secondary" className="text-[10px] uppercase">
Yeni
<Badge
variant={isWarning ? "destructive" : "secondary"}
className="text-[10px] uppercase"
>
{isWarning ? "Dikkat" : "Yeni"}
</Badge>
)}
{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";
import { useActionState, useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
@@ -83,11 +84,17 @@ function PatientRow({ row }: { row: Patient }) {
return (
<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">
{[row.firstName, row.lastName].filter(Boolean).join(" ") || (
<span className="text-muted-foreground"></span>
)}
<Link href={`/patients/${row.$id}`} className="hover:underline">
{[row.firstName, row.lastName].filter(Boolean).join(" ") || (
<span className="text-muted-foreground"></span>
)}
</Link>
</TableCell>
<TableCell className="text-muted-foreground max-w-[280px] truncate">
{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 { requireTenant } from "@/lib/appwrite/tenant-guard";
import { DangerZone } from "./components/danger-zone";
import { LogoUploader } from "./components/logo-uploader";
import { WorkspaceSettingsForm } from "./components/workspace-form";
@@ -50,6 +51,10 @@ export default async function WorkspaceSettingsPage() {
memberNumber: ctx.settings?.memberNumber ?? "",
}}
/>
{ctx.role === "owner" && (
<DangerZone companyName={ctx.settings?.companyName ?? ""} />
)}
</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",
},
});
}
@@ -46,6 +46,12 @@ export async function GET(
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
+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;
}
+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 { 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 { 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> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const otp = String(formData.get("otp") ?? "").trim();
const inviteCode = String(formData.get("inviteCode") ?? "").trim();
const rawKind = String(formData.get("kind") ?? "").trim();
const kind: TenantKind | null =
@@ -107,6 +108,51 @@ export async function signInAction(_prev: AuthState, formData: FormData): Promis
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
if (inviteCode) {
redirect(`/d/${inviteCode}`);
+2
View File
@@ -1,6 +1,8 @@
export type AuthState = {
ok: boolean;
error?: string;
/** Set when the account has MFA enabled and the OTP field was empty. */
mfaRequired?: boolean;
};
export const initialAuthState: AuthState = { ok: false };
+9
View File
@@ -278,6 +278,15 @@ export async function rejectConnectionAction(
entityId: connectionId,
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) {
return { ok: false, error: appwriteError(e, "Reddedilemedi.") };
}
+33 -3
View File
@@ -28,6 +28,8 @@ export type DashboardData = {
approvedConnectionsCount: number;
recentJobs: DashboardJob[];
recentNotifications: Notification[];
/** Open jobs whose dueDate has already passed. */
overdueJobs: DashboardJob[];
};
export async function getDashboardData(
@@ -41,8 +43,16 @@ export async function getDashboardData(
// count separately for the stat card.
const jobsField = isLab ? "labTenantId" : "clinicTenantId";
const [recentJobsRes, openJobsRes, pendingActionRes, financeRes, notifRes, unreadRes, connRes] =
await Promise.all([
const [
recentJobsRes,
openJobsRes,
pendingActionRes,
financeRes,
notifRes,
unreadRes,
connRes,
overdueRes,
] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
@@ -110,12 +120,27 @@ export async function getDashboardData(
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 overdueJobs = overdueRes.rows as unknown as Job[];
const counterpartIds = Array.from(
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,
})),
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 "";
}
}
+174 -5
View File
@@ -1,11 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync";
import { archiveJobFiles } from "./job-file-archive";
import { createNotification } from "./notification-helpers";
import { calculateJobPriceForProsthetic } from "./pricing";
import {
@@ -329,7 +331,71 @@ export async function acceptJobAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
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`);
}
/**
* 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 };
}
/**
@@ -435,7 +501,7 @@ export async function handToClinicAction(
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
revalidatePath("/finance");
return { ok: true };
redirect(`/jobs/${jobId}?flash=handed`);
}
/**
@@ -518,7 +584,97 @@ export async function approveAtClinicAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
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(
@@ -564,6 +720,9 @@ export async function markDeliveredAction(
jobId,
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) {
return { ok: false, error: appwriteError(e, "Teslim alınamadı.") };
}
@@ -572,7 +731,7 @@ export async function markDeliveredAction(
revalidatePath("/jobs/outbound");
revalidatePath("/jobs/inbound");
revalidatePath("/finance");
return { ok: true };
redirect(`/jobs/${jobId}?flash=delivered`);
}
export async function cancelJobAction(
@@ -615,6 +774,16 @@ export async function cancelJobAction(
entityId: jobId,
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) {
return { ok: false, error: appwriteError(e, "İptal edilemedi.") };
}
@@ -622,5 +791,5 @@ export async function cancelJobAction(
revalidatePath(`/jobs/${jobId}`);
revalidatePath("/jobs/inbound");
revalidatePath("/jobs/outbound");
return { ok: true };
redirect(`/jobs/${jobId}?flash=cancelled`);
}
+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.
}
}
+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 };
}
/** Inbound for a lab tenant — jobs the lab has received. */
export async function listInboundJobs(labTenantId: string): Promise<JobWithCounterpart[]> {
export type JobListFilters = {
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 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({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("labTenantId", labTenantId),
Query.orderDesc("$createdAt"),
Query.limit(200),
],
queries,
});
const jobs = result.rows as unknown as Job[];
const map = await fetchTenants(Array.from(new Set(jobs.map((j) => j.clinicTenantId))));
return toPlain(jobs.map((j) => enrichJob(j, j.clinicTenantId, map)));
const counterpartField = side === "lab" ? "clinicTenantId" : "labTenantId";
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. */
export async function listOutboundJobs(clinicTenantId: string): Promise<JobWithCounterpart[]> {
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
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)));
export async function listOutboundJobs(
clinicTenantId: string,
filters: JobListFilters = {},
): Promise<JobWithCounterpart[]> {
return listJobsFor("clinic", clinicTenantId, filters);
}
/** For clinic's "Yeni İş Yayınla" form: list approved labs they can send to. */
+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 { DATABASE_ID, TABLES, type Notification } from "./schema";
import {
DATABASE_ID,
TABLES,
type Notification,
type NotificationSeverity,
} from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
@@ -12,6 +17,9 @@ type CreateNotificationInput = {
jobId?: string;
connectionId?: 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,
message: input.message.slice(0, 500),
read: false,
severity: input.severity ?? "info",
},
[
Permission.read(Role.team(input.tenantId)),
+45 -1
View File
@@ -2,7 +2,7 @@ import "server-only";
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 { toPlain } from "./serialize";
@@ -44,3 +44,47 @@ export async function getPatient(
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);
}
+1
View File
@@ -269,6 +269,7 @@ export async function rejectPaymentAction(
});
void createNotification({
tenantId: row.tenantId,
severity: "warning",
message: `Ödeme bildiriminiz reddedildi: ${row.amount.toLocaleString("tr-TR")} ${row.currency}.`,
});
} catch (e) {
+8
View File
@@ -137,6 +137,9 @@ export interface JobFile extends Row {
name: string;
size: number;
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 {
@@ -191,6 +194,8 @@ export interface Payment extends Row {
recordedBy: string;
}
export type NotificationSeverity = "info" | "warning";
export interface Notification extends Row {
tenantId: string;
userId?: string;
@@ -198,6 +203,9 @@ export interface Notification extends Row {
connectionId?: string;
message: string;
read: boolean;
/** Visual + filtering hint. 'warning' for things requiring attention
* (revision request, cancellation, payment / connection rejection). */
severity?: NotificationSeverity;
}
export type AuditAction = "create" | "update" | "delete";