feat: job status/step flow, file upload, finance sync, notifications

Job lifecycle
  - acceptJobAction (lab): pending → in_progress + currentStep=olcu
  - advanceStepAction (lab): step ilerletir, son adım sonrası status=sent
  - markDeliveredAction (clinic): sent → delivered
  - cancelJobAction: pending iş iptali (her iki taraf)
  - job_status_history her step transition'da idempotent kayıt
  - Detay sayfası interactive panel + Aşama Geçmişi kartı

Job files (Appwrite Storage job-files bucket, 30MB/file)
  - uploadJobFilesAction: çoklu dosya, mimeType'tan kind sınıflandırma
    (scan/image/document), her iki team'e read permission, partial-fail
    rollback (storage + row temizliği)
  - deleteJobFileAction: yetkilendirilmiş silme, file + row birlikte
  - JobFilesPanel: client-side select + upload + liste + indir + sil
  - next.config bodySizeLimit 3mb → 100mb (toplu yükleme için)

Finance sync (idempotent)
  - syncFinanceForJob helper: sent/delivered transition'larında klinik
    payable + lab receivable rows (jobId+tenantId+type unique kontrolü,
    her tarafta tek satır garanti)
  - markFinancePaidAction / reopenFinanceAction: manuel ödendi/geri al
  - /finance sayfası: stat kartlar (bekleyen alacak/borç, aylık gelir/gider)
    + hareketler tablosu, role-aware kopyalar
  - Memory rule [[feedback_cross_entity_sync_helpers]]: best-effort, never
    re-throws

Notifications
  - createNotification helper, connection (request/approve) ve job
    (create/accept/sent/delivered) eventlerinde tetikleniyor
  - /notifications sayfası + tek tek / hepsi okundu işaretle
  - Header'a Bell ikonu + okunmamış count badge (layout SSR'de besler)
  - Middleware PROTECTED_PREFIXES'e /notifications ekli
This commit is contained in:
kovakmedya
2026-05-21 20:17:33 +03:00
parent 76e02754b8
commit 2c6c074a06
24 changed files with 2066 additions and 21 deletions
+4 -2
View File
@@ -31,11 +31,13 @@ export function DashboardShell({
company,
children,
initialPrefs,
unreadCount = 0,
}: {
user: ShellUser;
company: ShellCompany;
children: React.ReactNode;
initialPrefs: ThemePrefs;
unreadCount?: number;
}) {
const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false);
const { config } = useSidebarConfig();
@@ -63,7 +65,7 @@ export function DashboardShell({
side={config.side}
/>
<SidebarInset>
<SiteHeader company={company} />
<SiteHeader company={company} unreadCount={unreadCount} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
@@ -75,7 +77,7 @@ export function DashboardShell({
) : (
<>
<SidebarInset>
<SiteHeader company={company} />
<SiteHeader company={company} unreadCount={unreadCount} />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">{children}</div>
@@ -0,0 +1,171 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useTransition } from "react";
import { CheckCircle2, Loader2, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
markFinancePaidAction,
reopenFinanceAction,
} from "@/lib/appwrite/finance-actions";
import {
FINANCE_STATUS_LABELS,
FINANCE_TYPE_LABELS,
initialFinanceActionState,
} from "@/lib/appwrite/finance-types";
import type { FinanceEntryWithCounterpart } from "@/lib/appwrite/finance-queries";
import type { FinanceStatus, FinanceType } 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}`;
}
}
function typeVariant(t: FinanceType): "default" | "secondary" | "outline" | "destructive" {
if (t === "income" || t === "receivable") return "secondary";
return "outline";
}
function statusVariant(s: FinanceStatus): "default" | "secondary" | "outline" | "destructive" {
if (s === "paid") return "default";
if (s === "cancelled") return "destructive";
return "outline";
}
export function FinanceTable({ rows }: { rows: FinanceEntryWithCounterpart[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz finansal hareket yok. İşler gönderildiğinde otomatik kayıt oluşur.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tarih</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Karşı Taraf</TableHead>
<TableHead>Açıklama</TableHead>
<TableHead className="text-right">Tutar</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<FinanceRow key={r.$id} row={r} />
))}
</TableBody>
</Table>
);
}
function FinanceRow({ row }: { row: FinanceEntryWithCounterpart }) {
const [paidState, paidAction, paidPending] = useActionState(
markFinancePaidAction,
initialFinanceActionState,
);
const [reopenState, reopenAction, reopenPending] = useActionState(
reopenFinanceAction,
initialFinanceActionState,
);
const [, startTransition] = useTransition();
useEffect(() => {
if (paidState.ok) toast.success("Ödendi olarak işaretlendi.");
else if (paidState.error) toast.error(paidState.error);
}, [paidState]);
useEffect(() => {
if (reopenState.ok) toast.success("Tekrar bekleyene alındı.");
else if (reopenState.error) toast.error(reopenState.error);
}, [reopenState]);
return (
<TableRow>
<TableCell className="text-muted-foreground">
{dateFormatter.format(new Date(row.date))}
</TableCell>
<TableCell>
<Badge variant={typeVariant(row.type)}>{FINANCE_TYPE_LABELS[row.type]}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{row.counterpart?.companyName ?? "—"}
</TableCell>
<TableCell className="max-w-[280px] truncate text-sm">
{row.jobId ? (
<Link href={`/jobs/${row.jobId}`} className="hover:underline">
{row.description ?? "—"}
</Link>
) : (
row.description ?? "—"
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatMoney(row.amount, row.currency || "TRY")}
</TableCell>
<TableCell>
<Badge variant={statusVariant(row.status)}>{FINANCE_STATUS_LABELS[row.status]}</Badge>
</TableCell>
<TableCell className="text-right">
{row.status === "pending" ? (
<form
action={(fd) => {
startTransition(() => paidAction(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button type="submit" size="sm" disabled={paidPending}>
{paidPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<CheckCircle2 className="size-4" />
)}
Ödendi
</Button>
</form>
) : row.status === "paid" ? (
<form
action={(fd) => {
startTransition(() => reopenAction(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={reopenPending}>
{reopenPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCcw className="size-4" />
)}
Geri al
</Button>
</form>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
</TableRow>
);
}
+91 -6
View File
@@ -1,21 +1,106 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listFinanceEntries, summarizeFinance } from "@/lib/appwrite/finance-queries";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { FinanceTable } from "./components/finance-table";
export const metadata = {
title: "DLS — Finans",
};
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 default async function FinancePage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const entries = await listFinanceEntries(ctx.tenantId);
const stats = summarizeFinance(entries);
const isLab = ctx.kind === "lab";
export default function FinancePage() {
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">Finans</h1>
<p className="text-muted-foreground text-sm">
Gelen ödemeler, ödenen hesaplar ve bekleyen tahsilatlar.
İş bazlı tahsilat ve ödeme akışı. {isLab ? "Alacaklarınız ve gelirleriniz." : "Ödenecek ve harcamalarınız."}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={isLab ? "Bekleyen Alacak" : "Bekleyen Borç"}
value={formatMoney(isLab ? stats.receivablePending : stats.payablePending, stats.currency)}
tone={isLab ? "positive" : "negative"}
/>
<StatCard
label="Bu Ay Gelir"
value={formatMoney(stats.incomeThisMonth, stats.currency)}
tone="positive"
/>
<StatCard
label="Bu Ay Gider"
value={formatMoney(stats.expenseThisMonth, stats.currency)}
tone="negative"
/>
<StatCard
label="Toplam Kayıt"
value={String(entries.length)}
tone="neutral"
/>
</div>
<Card>
<CardHeader>
<CardTitle>Yapım aşamasında</CardTitle>
<CardDescription>Finans hareketleri, durum takibi ve raporlar sonraki sürümde.</CardDescription>
<CardTitle>Hareketler</CardTitle>
<CardDescription>
Tamamlanan işlerden otomatik oluşturulan finansal kayıtlar. Manuel kayıt eklemek sonraki sürümde.
</CardDescription>
</CardHeader>
<CardContent />
<CardContent>
<FinanceTable rows={entries} />
</CardContent>
</Card>
</div>
);
}
function StatCard({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "positive" | "negative" | "neutral";
}) {
const color =
tone === "positive"
? "text-emerald-600 dark:text-emerald-400"
: tone === "negative"
? "text-rose-600 dark:text-rose-400"
: "text-foreground";
return (
<Card>
<CardHeader className="pb-2">
<CardDescription className="text-xs uppercase tracking-wide">
{label}
</CardDescription>
</CardHeader>
<CardContent>
<p className={`text-2xl font-semibold tabular-nums ${color}`}>{value}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,239 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import {
ArrowRight,
Check,
CircleAlert,
Loader2,
Play,
PackageCheck,
X,
} 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 { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
acceptJobAction,
advanceStepAction,
cancelJobAction,
markDeliveredAction,
} from "@/lib/appwrite/job-actions";
import {
JOB_STEP_LABELS,
JOB_STEP_ORDER,
initialJobActionState,
} from "@/lib/appwrite/job-types";
import type { Job, TenantKind } from "@/lib/appwrite/schema";
type Side = "clinic" | "lab";
export function JobActionsPanel({
job,
side,
kind,
}: {
job: Job;
side: Side;
kind: TenantKind | null;
}) {
if (!kind) return null;
const isLab = side === "lab";
const isClinic = side === "clinic";
return (
<div className="flex flex-wrap items-center gap-2">
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
{(isClinic || isLab) && job.status === "pending" && (
<CancelButton jobId={job.$id} />
)}
</div>
);
}
function AcceptButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.ok) toast.success("İş işleme alındı.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
İşleme Al
</Button>
</form>
);
}
function AdvanceButton({ job }: { job: Job }) {
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Aşama ilerletildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—";
const nextLabel = isFinal
? "Gönderildi olarak işaretle"
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]];
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button onClick={() => setOpen(true)}>
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />}
{isFinal ? "Gönderildi" : "Sonraki Aşama"}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
</DialogTitle>
<DialogDescription>
{isFinal
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek."
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
</DialogDescription>
</DialogHeader>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
className="grid gap-3"
>
<input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2">
<Label htmlFor="note">Not (opsiyonel)</Label>
<Textarea
id="note"
name="note"
rows={3}
maxLength={1000}
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı."
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function DeliverButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.ok) toast.success("İş teslim alındı.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
Teslim Aldım
</Button>
</form>
);
}
function CancelButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("İş iptal edildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button variant="outline" onClick={() => setOpen(true)}>
<X className="size-4" />
İptal Et
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>İş iptal edilsin mi?</DialogTitle>
<DialogDescription className="flex items-start gap-2">
<CircleAlert className="text-destructive size-4 shrink-0" />
<span>
İş geri alınamaz şekilde iptal duruma geçer. Karşı taraf da bu işi iptal edilmiş olarak görür.
</span>
</DialogDescription>
</DialogHeader>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<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" /> : <X className="size-4" />}
İptal Et
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,200 @@
"use client";
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
deleteJobFileAction,
uploadJobFilesAction,
} from "@/lib/appwrite/job-file-actions";
import {
initialJobFileActionState,
initialJobFileUploadState,
JOB_FILE_KIND_LABELS,
} from "@/lib/appwrite/job-file-types";
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function kindIcon(kind: string) {
if (kind === "image") return <ImageIcon className="size-4" />;
if (kind === "scan") return <Layers className="size-4" />;
return <FileText className="size-4" />;
}
export function JobFilesPanel({
jobId,
files,
}: {
jobId: string;
files: JobFileWithUrl[];
}) {
return (
<div className="space-y-4">
<UploadForm jobId={jobId} />
{files.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
Henüz dosya yok.
</p>
) : (
<ul className="divide-y rounded-md border">
{files.map((f) => (
<FileRow key={f.$id} file={f} />
))}
</ul>
)}
</div>
);
}
function UploadForm({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(
uploadJobFilesAction,
initialJobFileUploadState,
);
const formRef = useRef<HTMLFormElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [selected, setSelected] = useState<File[]>([]);
useEffect(() => {
if (state.ok && state.uploaded) {
toast.success(`${state.uploaded} dosya yüklendi.`);
formRef.current?.reset();
setSelected([]);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<form
ref={formRef}
action={action}
className="bg-muted/30 flex flex-wrap items-center gap-3 rounded-md border p-3"
>
<input type="hidden" name="jobId" value={jobId} />
<input
ref={inputRef}
type="file"
name="files"
multiple
className="hidden"
accept=".pdf,.jpg,.jpeg,.png,.webp,.tiff,.tif,.bmp,.heic,.heif,.stl,.obj,.ply,.3mf,.zip,.rar,.7z,.dcm,.stm"
onChange={(e) => {
const list = e.target.files ? Array.from(e.target.files) : [];
setSelected(list);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
>
<Upload className="size-4" />
Dosya seç
</Button>
<span className="text-muted-foreground text-xs flex-1">
{selected.length > 0
? `${selected.length} dosya seçildi (${formatSize(selected.reduce((s, f) => s + f.size, 0))})`
: "Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya"}
</span>
<Button type="submit" size="sm" disabled={pending || selected.length === 0}>
{pending ? (
<>
<Loader2 className="size-4 animate-spin" />
Yükleniyor...
</>
) : (
<>
<Upload className="size-4" />
Yükle
</>
)}
</Button>
</form>
);
}
function FileRow({ file }: { file: JobFileWithUrl }) {
const [state, action, pending] = useActionState(
deleteJobFileAction,
initialJobFileActionState,
);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Dosya silindi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<li className="flex items-center gap-3 px-3 py-2">
<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)}
</p>
</div>
<Badge variant="outline" className="hidden sm:inline-flex">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
</Badge>
<Button asChild size="sm" variant="outline">
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}>
<Download className="size-4" />
</a>
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Trash2 className="size-4" />
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Dosya silinsin mi?</DialogTitle>
<DialogDescription>{file.name}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="rowId" value={file.$id} />
<Button type="submit" variant="destructive" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Sil
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</li>
);
}
+59 -7
View File
@@ -1,19 +1,23 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { Query } from "node-appwrite";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
import { Query } from "node-appwrite";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { listJobFiles } from "@/lib/appwrite/job-file-queries";
import { listJobHistory } from "@/lib/appwrite/job-history-queries";
import {
JOB_STATUS_LABELS,
JOB_STEP_LABELS,
JOB_STEP_ORDER,
PROSTHETIC_TYPE_LABELS,
} from "@/lib/appwrite/job-types";
import { createAdminClient } from "@/lib/appwrite/server";
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { JobActionsPanel } from "./components/job-actions-panel";
import { JobFilesPanel } from "./components/job-files-panel";
export const metadata = {
title: "DLS — İş Detay",
@@ -73,7 +77,12 @@ export default async function JobDetailPage({
});
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
const [history, files] = await Promise.all([
listJobHistory(jobId),
listJobFiles(jobId),
]);
const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
const side = job.clinicTenantId === ctx.tenantId ? "clinic" : "lab";
return (
<div className="flex-1 space-y-6 px-6">
@@ -89,9 +98,12 @@ export default async function JobDetailPage({
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
</p>
</div>
<Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
<div className="flex flex-col items-end gap-3">
<Badge variant="secondary" className="text-sm">
{JOB_STATUS_LABELS[job.status]}
</Badge>
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
@@ -167,6 +179,46 @@ export default async function JobDetailPage({
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Taranan Dosyalar ve Görseller</CardTitle>
<CardDescription>
Hem klinik hem laboratuvar dosya yükleyip indirebilir.
</CardDescription>
</CardHeader>
<CardContent>
<JobFilesPanel jobId={job.$id} files={files} />
</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>
))}
</ol>
</CardContent>
</Card>
)}
<div>
<Button asChild variant="outline">
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
+11 -2
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { getActiveContext } from "@/lib/appwrite/active-context";
import { countUnreadNotifications } from "@/lib/appwrite/notification-helpers";
import { getLogoUrl } from "@/lib/appwrite/storage";
import { getUserPrefs } from "@/lib/appwrite/user-prefs-actions";
import type { UserPrefs as ThemePrefs } from "@/lib/appwrite/user-prefs-actions";
@@ -14,7 +15,10 @@ export default async function DashboardLayout({
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const themePrefs: ThemePrefs = await getUserPrefs();
const [themePrefs, unreadCount] = (await Promise.all([
getUserPrefs(),
countUnreadNotifications(ctx.tenantId),
])) as [ThemePrefs, number];
const company = {
id: ctx.tenantId,
@@ -29,7 +33,12 @@ export default async function DashboardLayout({
};
return (
<DashboardShell user={user} company={company} initialPrefs={themePrefs}>
<DashboardShell
user={user}
company={company}
initialPrefs={themePrefs}
unreadCount={unreadCount}
>
{children}
</DashboardShell>
);
@@ -0,0 +1,129 @@
"use client";
import Link from "next/link";
import { useActionState, useEffect, useTransition } from "react";
import { ArrowRight, Check, CheckCheck, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
initialNotificationActionState,
markAllNotificationsReadAction,
markNotificationReadAction,
} from "@/lib/appwrite/notification-actions";
import type { Notification } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
export function NotificationsList({ rows }: { rows: Notification[] }) {
const unread = rows.filter((r) => !r.read).length;
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz bildirim yok.
</p>
);
}
return (
<div className="space-y-3">
{unread > 0 && (
<div className="flex justify-end">
<MarkAllReadButton />
</div>
)}
<ul className="divide-y rounded-md border">
{rows.map((n) => (
<NotificationRow key={n.$id} row={n} />
))}
</ul>
</div>
);
}
function NotificationRow({ row }: { row: Notification }) {
const [state, action, pending] = useActionState(
markNotificationReadAction,
initialNotificationActionState,
);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.error) toast.error(state.error);
}, [state]);
const link = row.jobId
? `/jobs/${row.jobId}`
: row.connectionId
? "/connections"
: null;
return (
<li className={`flex items-start gap-3 px-3 py-3 ${row.read ? "opacity-70" : ""}`}>
<span
className={`mt-1.5 size-2 shrink-0 rounded-full ${row.read ? "bg-muted" : "bg-primary"}`}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-sm">{row.message}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{dateFormatter.format(new Date(row.$createdAt))}
</p>
</div>
{!row.read && (
<Badge variant="secondary" className="text-[10px] uppercase">
Yeni
</Badge>
)}
{link && (
<Button asChild variant="ghost" size="sm">
<Link href={link}>
<ArrowRight className="size-4" />
</Link>
</Button>
)}
{!row.read && (
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
</Button>
</form>
)}
</li>
);
}
function MarkAllReadButton() {
const [pending, startTransition] = useTransition();
return (
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() =>
startTransition(async () => {
const res = await markAllNotificationsReadAction();
if (res.ok) toast.success("Tümü okundu olarak işaretlendi.");
else if (res.error) toast.error(res.error);
})
}
>
{pending ? <Loader2 className="size-4 animate-spin" /> : <CheckCheck className="size-4" />}
Hepsini okundu işaretle
</Button>
);
}
@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listNotifications } from "@/lib/appwrite/notification-helpers";
import { requireTenant } from "@/lib/appwrite/tenant-guard";
import { NotificationsList } from "./components/notifications-list";
export const metadata = {
title: "DLS — Bildirimler",
};
export default async function NotificationsPage() {
let ctx;
try {
ctx = await requireTenant();
} catch {
redirect("/onboarding");
}
const rows = await listNotifications(ctx.tenantId);
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">Bildirimler</h1>
<p className="text-muted-foreground text-sm">
Bağlantı talepleri, güncellemeleri ve diğer olaylar.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Son Bildirimler</CardTitle>
<CardDescription>
{rows.length === 0 ? "Henüz bildirim yok." : `${rows.length} kayıt`}
</CardDescription>
</CardHeader>
<CardContent>
<NotificationsList rows={rows} />
</CardContent>
</Card>
</div>
);
}