feat(dashboard): wire Anasayfa to live data

- getDashboardData aggregates open jobs, pending-action jobs, unread
  notifications, pending finance totals, approved connection count, recent
  jobs (up to 8) and recent notifications (up to 5) — single Promise.all so
  the dashboard renders in one round-trip.
- Four stat cards, each a Link to the relevant module; tone (positive /
  negative) flips between clinic (payable) and lab (receivable).
- Clinic users with zero approved connections see a 'Bağlantı Kur' prompt
  card so they don't get stuck on /jobs/new.
- Recent jobs table is role-aware: lab sees Klinik column + 'Son Gelen
  İşler' header, clinic sees Laboratuvar column + 'Son Giden İşler' header.
- Recent notifications panel with read/unread dot, clickable header arrow
  to /notifications.
- ActiveContext now carries 'kind' (mirror of TenantSettings.kind) so we no
  longer reach into ctx.settings?.kind in callers.
This commit is contained in:
kovakmedya
2026-05-21 20:41:39 +03:00
parent 97f397d2dd
commit c980ce1d8d
3 changed files with 402 additions and 19 deletions
+241 -18
View File
@@ -1,56 +1,279 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowRight, FlaskConical, Link2, Plus, Stethoscope } 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 { getActiveContext } from "@/lib/appwrite/active-context";
import { getDashboardData } from "@/lib/appwrite/dashboard-queries";
import { JOB_STATUS_LABELS, PROSTHETIC_TYPE_LABELS } from "@/lib/appwrite/job-types";
import type { JobStatus } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
});
const datetimeFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
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 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 DashboardPage() {
const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding");
const data = await getDashboardData(ctx.tenantId, ctx.kind);
const firstName = ctx.user.name?.split(" ")[0] ?? "";
const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
const isLab = ctx.kind === "lab";
const isClinic = ctx.kind === "clinic";
return (
<div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-sm">{companyName}</p>
<p className="text-muted-foreground flex items-center gap-2 text-sm">
{isLab ? <FlaskConical className="size-3.5" /> : <Stethoscope className="size-3.5" />}
{companyName}
</p>
<h1 className="text-2xl font-bold tracking-tight">
{firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"}
</h1>
<p className="text-muted-foreground text-sm">
Açık işleri, bildirimleri ve istatistikleri buradan takip edin.
Açık işleri, finansal akışı ve bildirimleri buradan takip edin.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label="Açık işler"
value={String(data.openJobsCount)}
hint={
data.pendingActionCount > 0
? `${data.pendingActionCount} kalem sizden eylem bekliyor`
: "Hepsi yolunda"
}
href={isLab ? "/jobs/inbound" : "/jobs/outbound"}
/>
<StatCard
label={isLab ? "Bekleyen alacak" : "Bekleyen borç"}
value={formatMoney(
isLab ? data.receivablePending : data.payablePending,
data.currency,
)}
hint="Tahsilat / ödeme bekliyor"
href="/finance"
tone={isLab ? "positive" : "negative"}
/>
<StatCard
label="Okunmamış bildirim"
value={String(data.unreadCount)}
hint={data.unreadCount > 0 ? "Yeni etkinlikler var" : "Hepsi okundu"}
href="/notifications"
/>
<StatCard
label="Bağlantı"
value={String(data.approvedConnectionsCount)}
hint={
data.approvedConnectionsCount > 0
? `Onaylı ${isLab ? "klinik" : "laboratuvar"}`
: "Henüz bağlantınız yok"
}
href="/connections"
/>
</div>
{isClinic && data.approvedConnectionsCount === 0 && (
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle>Açık işler</CardTitle>
<CardDescription>Gelen ve giden özetleri burada listelenecek.</CardDescription>
<CardTitle className="text-base">Bir laboratuvarla bağlantı kurun</CardTitle>
<CardDescription>
İş gönderebilmeniz için en az bir onaylı laboratuvar bağlantınız olmalı.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
<CardContent>
<Button asChild>
<Link href="/connections">
<Link2 className="size-4" />
Bağlantı Kur
</Link>
</Button>
</CardContent>
</Card>
)}
<div className="grid gap-6 xl:grid-cols-[2fr_1fr]">
<Card>
<CardHeader>
<CardTitle>İşlem bekleyen</CardTitle>
<CardDescription>Onay/işlem bekleyen kalemler.</CardDescription>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>{isLab ? "Son Gelen İşler" : "Son Giden İşler"}</CardTitle>
<CardDescription>En son 8 kayıt.</CardDescription>
</div>
<div className="flex gap-2">
{isClinic && (
<Button asChild size="sm">
<Link href="/jobs/new">
<Plus className="size-4" />
Yeni İş
</Link>
</Button>
)}
<Button asChild size="sm" variant="outline">
<Link href={isLab ? "/jobs/inbound" : "/jobs/outbound"}>
Tümü
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
<CardContent>
{data.recentJobs.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz kaydı yok.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{isLab ? "Klinik" : "Laboratuvar"}</TableHead>
<TableHead>Hasta</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Durum</TableHead>
<TableHead>Tarih</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentJobs.map((j) => (
<TableRow
key={j.$id}
className="cursor-pointer"
onClick={undefined}
>
<TableCell className="font-medium">
<Link href={`/jobs/${j.$id}`} className="hover:underline">
{j.counterpartName ?? "—"}
</Link>
</TableCell>
<TableCell className="font-mono text-xs">{j.patientCode}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{PROSTHETIC_TYPE_LABELS[j.prostheticType]}
</TableCell>
<TableCell>
<Badge variant={statusVariant(j.status)}>
{JOB_STATUS_LABELS[j.status]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{dateFormatter.format(new Date(j.$createdAt))}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bildirimler</CardTitle>
<CardDescription>Bağlantılarınızdan gelen son bildirimler.</CardDescription>
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle>Son Bildirimler</CardTitle>
<CardDescription>Bağlantı ve etkinlikleri.</CardDescription>
</div>
<Button asChild size="sm" variant="outline">
<Link href="/notifications">
<ArrowRight className="size-4" />
</Link>
</Button>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Modül yapım aşamasında.
<CardContent>
{data.recentNotifications.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
Bildirim yok.
</p>
) : (
<ul className="divide-y">
{data.recentNotifications.map((n) => (
<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"}`}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{n.message}</p>
<p className="text-muted-foreground mt-0.5 text-xs">
{datetimeFormatter.format(new Date(n.$createdAt))}
</p>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StatCard({
label,
value,
hint,
href,
tone = "neutral",
}: {
label: string;
value: string;
hint?: string;
href: 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 (
<Link
href={href}
className="hover:bg-muted/40 group block rounded-lg border bg-card p-4 transition-colors"
>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
{label}
</p>
<p className={`mt-1 text-2xl font-semibold tabular-nums ${color}`}>{value}</p>
{hint && <p className="text-muted-foreground mt-1 text-xs">{hint}</p>}
</Link>
);
}