Compare commits

...

9 Commits

Author SHA1 Message Date
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
24 changed files with 1392 additions and 91 deletions
+31 -1
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>
@@ -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>
);
}
@@ -9,6 +9,7 @@ import {
Loader2,
PackageCheck,
Play,
RotateCcw,
Send,
X,
} from "lucide-react";
@@ -32,6 +33,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 +69,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 */}
@@ -236,6 +241,68 @@ function ApproveAtClinicButton({ job }: { job: Job }) {
);
}
function RequestRevisionButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(
requestRevisionAction,
initialJobActionState,
);
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Düzeltme talebi gönderildi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
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);
@@ -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.
@@ -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 || "—"}
@@ -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>
);
}
+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 "";
}
}
+155
View File
@@ -6,6 +6,7 @@ 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 {
@@ -332,6 +333,68 @@ export async function acceptJobAction(
return { ok: true };
}
/**
* Lab takes all currently-pending jobs in one go. Same effect as calling
* acceptJobAction for each row individually — status flips to in_progress,
* step jumps to alt_yapi_prova, location lands at_lab. Partial failures
* are tolerated; we return how many actually moved.
*/
export async function bulkAcceptPendingJobsAction(): Promise<
JobActionState & { accepted?: number }
> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["lab"]);
} catch {
return { ok: false, error: "Bu işlemi yalnızca laboratuvar yapabilir." };
}
const { tablesDB } = createAdminClient();
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal("labTenantId", ctx.tenantId),
Query.equal("status", "pending"),
Query.limit(200),
],
});
const rows = result.rows as unknown as Job[];
if (rows.length === 0) return { ok: true, accepted: 0 };
const outcomes = await Promise.allSettled(
rows.map(async (job) => {
await tablesDB.updateRow(DATABASE_ID, TABLES.jobs, job.$id, {
status: "in_progress",
currentStep: "alt_yapi_prova",
location: "at_lab",
});
void appendJobHistory({ job, step: "olcu", completedBy: ctx.user.id });
void createNotification({
tenantId: job.clinicTenantId,
jobId: job.$id,
message: `${ctx.settings?.companyName ?? "Lab"} hasta ${job.patientCode} işini işleme aldı, alt yapı üretiminde.`,
});
}),
);
const accepted = outcomes.filter((o) => o.status === "fulfilled").length;
void logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "job",
entityId: "bulk",
changes: { bulk: "accept_pending", count: accepted },
});
revalidatePath("/jobs/inbound");
revalidatePath("/dashboard");
return { ok: true, accepted };
}
/**
* Lab hands the work back to the clinic for the next physical step
* (prova or final delivery). The current step stays the same — only the
@@ -521,6 +584,95 @@ export async function approveAtClinicAction(
return { ok: true };
}
/**
* 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,
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");
return { ok: true };
}
export async function markDeliveredAction(
_prev: JobActionState,
formData: FormData,
@@ -564,6 +716,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ı.") };
}
+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. */
+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);
}
+3
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 {