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 { 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 { 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 { 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() { export default async function DashboardPage() {
const ctx = await getActiveContext(); const ctx = await getActiveContext();
if (!ctx) redirect("/onboarding"); if (!ctx) redirect("/onboarding");
const data = await getDashboardData(ctx.tenantId, ctx.kind);
const firstName = ctx.user.name?.split(" ")[0] ?? ""; const firstName = ctx.user.name?.split(" ")[0] ?? "";
const companyName = ctx.settings?.companyName ?? "Çalışma alanı"; const companyName = ctx.settings?.companyName ?? "Çalışma alanı";
const isLab = ctx.kind === "lab";
const isClinic = ctx.kind === "clinic";
return ( return (
<div className="flex-1 space-y-6 px-6 pt-0"> <div className="flex-1 space-y-6 px-6 pt-0">
<div className="flex flex-col gap-1"> <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"> <h1 className="text-2xl font-bold tracking-tight">
{firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"} {firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"}
</h1> </h1>
<p className="text-muted-foreground text-sm"> <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> </p>
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card> <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> <CardHeader>
<CardTitle>Açık işler</CardTitle> <CardTitle className="text-base">Bir laboratuvarla bağlantı kurun</CardTitle>
<CardDescription>Gelen ve giden özetleri burada listelenecek.</CardDescription> <CardDescription>
İş gönderebilmeniz için en az bir onaylı laboratuvar bağlantınız olmalı.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-muted-foreground text-sm"> <CardContent>
Modül yapım aşamasında. <Button asChild>
<Link href="/connections">
<Link2 className="size-4" />
Bağlantı Kur
</Link>
</Button>
</CardContent> </CardContent>
</Card> </Card>
)}
<div className="grid gap-6 xl:grid-cols-[2fr_1fr]">
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-start justify-between">
<CardTitle>İşlem bekleyen</CardTitle> <div>
<CardDescription>Onay/işlem bekleyen kalemler.</CardDescription> <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> </CardHeader>
<CardContent className="text-muted-foreground text-sm"> <CardContent>
Modül yapım aşamasında. {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> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader className="flex flex-row items-start justify-between">
<CardTitle>Bildirimler</CardTitle> <div>
<CardDescription>Bağlantılarınızdan gelen son bildirimler.</CardDescription> <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> </CardHeader>
<CardContent className="text-muted-foreground text-sm"> <CardContent>
Modül yapım aşamasında. {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> </CardContent>
</Card> </Card>
</div> </div>
</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>
);
}
+3 -1
View File
@@ -3,12 +3,13 @@ import "server-only";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { createAdminClient, getCurrentUser } from "./server"; import { createAdminClient, getCurrentUser } from "./server";
import { DATABASE_ID, TABLES, type TenantSettings } from "./schema"; import { DATABASE_ID, TABLES, type TenantKind, type TenantSettings } from "./schema";
import { getActiveTenantId, getUserTeams } from "./tenant"; import { getActiveTenantId, getUserTeams } from "./tenant";
export type ActiveContext = { export type ActiveContext = {
user: { id: string; name: string; email: string }; user: { id: string; name: string; email: string };
tenantId: string; tenantId: string;
kind: TenantKind | null;
settings: TenantSettings | null; settings: TenantSettings | null;
}; };
@@ -39,6 +40,7 @@ export async function getActiveContext(): Promise<ActiveContext | null> {
return { return {
user: { id: user.$id, name: user.name, email: user.email }, user: { id: user.$id, name: user.name, email: user.email },
tenantId, tenantId,
kind: settings?.kind ?? null,
settings, settings,
}; };
} }
+158
View File
@@ -0,0 +1,158 @@
import "server-only";
import { Query } from "node-appwrite";
import {
DATABASE_ID,
TABLES,
type FinanceEntry,
type Job,
type Notification,
type TenantKind,
type TenantSettings,
} from "./schema";
import { createAdminClient } from "./server";
export type DashboardJob = Job & {
counterpartName: string | null;
};
export type DashboardData = {
openJobsCount: number;
pendingActionCount: number; // jobs awaiting MY action
unreadCount: number;
receivablePending: number; // lab perspective
payablePending: number; // clinic perspective
currency: string;
approvedConnectionsCount: number;
recentJobs: DashboardJob[];
recentNotifications: Notification[];
};
export async function getDashboardData(
tenantId: string,
kind: TenantKind | null,
): Promise<DashboardData> {
const { tablesDB } = createAdminClient();
const isLab = kind === "lab";
// Jobs that involve this tenant — limit to 10 most recent for the list,
// count separately for the stat card.
const jobsField = isLab ? "labTenantId" : "clinicTenantId";
const [recentJobsRes, openJobsRes, pendingActionRes, financeRes, notifRes, unreadRes, connRes] =
await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal(jobsField, tenantId),
Query.orderDesc("$createdAt"),
Query.limit(8),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal(jobsField, tenantId),
Query.notEqual("status", "delivered"),
Query.notEqual("status", "cancelled"),
Query.limit(1),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.jobs,
queries: [
Query.equal(jobsField, tenantId),
Query.equal("status", isLab ? "pending" : "sent"),
Query.limit(1),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.financeEntries,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("status", "pending"),
Query.limit(200),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.notifications,
queries: [
Query.equal("tenantId", tenantId),
Query.orderDesc("$createdAt"),
Query.limit(5),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.notifications,
queries: [
Query.equal("tenantId", tenantId),
Query.equal("read", false),
Query.limit(1),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.connections,
queries: [
Query.or([
Query.equal("clinicTenantId", tenantId),
Query.equal("labTenantId", tenantId),
]),
Query.equal("status", "approved"),
Query.limit(1),
],
}),
]);
const recentJobs = recentJobsRes.rows as unknown as Job[];
const counterpartIds = Array.from(
new Set(
recentJobs.map((j) => (isLab ? j.clinicTenantId : j.labTenantId)).filter(Boolean),
),
);
const counterpartMap = new Map<string, string>();
if (counterpartIds.length > 0) {
const counterpartsRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", counterpartIds), Query.limit(200)],
});
for (const s of counterpartsRes.rows as unknown as TenantSettings[]) {
counterpartMap.set(s.tenantId, s.companyName);
}
}
const finance = financeRes.rows as unknown as FinanceEntry[];
let receivablePending = 0;
let payablePending = 0;
let currency = "TRY";
for (const e of finance) {
if (e.currency) currency = e.currency;
if (e.type === "receivable") receivablePending += e.amount;
if (e.type === "payable") payablePending += e.amount;
}
return {
openJobsCount: openJobsRes.total,
pendingActionCount: pendingActionRes.total,
unreadCount: unreadRes.total,
receivablePending,
payablePending,
currency,
approvedConnectionsCount: connRes.total,
recentJobs: recentJobs.map((j) => ({
...j,
counterpartName:
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
})),
recentNotifications: notifRes.rows as unknown as Notification[],
};
}