From c980ce1d8d12510c9902b4b2a280253b7cd8e390 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 20:41:39 +0300 Subject: [PATCH] feat(dashboard): wire Anasayfa to live data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/app/(dashboard)/dashboard/page.tsx | 259 +++++++++++++++++++++++-- src/lib/appwrite/active-context.ts | 4 +- src/lib/appwrite/dashboard-queries.ts | 158 +++++++++++++++ 3 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 src/lib/appwrite/dashboard-queries.ts diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 315bfb0..22c316d 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 (
-

{companyName}

+

+ {isLab ? : } + {companyName} +

{firstName ? `Hoş geldiniz, ${firstName}` : "Anasayfa"}

- Açık işleri, bildirimleri ve istatistikleri buradan takip edin. + Açık işleri, finansal akışı ve bildirimleri buradan takip edin.

-
- +
+ 0 + ? `${data.pendingActionCount} kalem sizden eylem bekliyor` + : "Hepsi yolunda" + } + href={isLab ? "/jobs/inbound" : "/jobs/outbound"} + /> + + 0 ? "Yeni etkinlikler var" : "Hepsi okundu"} + href="/notifications" + /> + 0 + ? `Onaylı ${isLab ? "klinik" : "laboratuvar"}` + : "Henüz bağlantınız yok" + } + href="/connections" + /> +
+ + {isClinic && data.approvedConnectionsCount === 0 && ( + - Açık işler - Gelen ve giden iş özetleri burada listelenecek. + Bir laboratuvarla bağlantı kurun + + İş gönderebilmeniz için en az bir onaylı laboratuvar bağlantınız olmalı. + - - Modül yapım aşamasında. + + + )} + +
- - İşlem bekleyen - Onay/işlem bekleyen kalemler. + +
+ {isLab ? "Son Gelen İşler" : "Son Giden İşler"} + En son 8 kayıt. +
+
+ {isClinic && ( + + )} + +
- - Modül yapım aşamasında. + + {data.recentJobs.length === 0 ? ( +

+ Henüz iş kaydı yok. +

+ ) : ( + + + + {isLab ? "Klinik" : "Laboratuvar"} + Hasta + Tür + Durum + Tarih + + + + {data.recentJobs.map((j) => ( + + + + {j.counterpartName ?? "—"} + + + {j.patientCode} + + {PROSTHETIC_TYPE_LABELS[j.prostheticType]} + + + + {JOB_STATUS_LABELS[j.status]} + + + + {dateFormatter.format(new Date(j.$createdAt))} + + + ))} + +
+ )}
+ - - Bildirimler - Bağlantılarınızdan gelen son bildirimler. + +
+ Son Bildirimler + Bağlantı ve iş etkinlikleri. +
+
- - Modül yapım aşamasında. + + {data.recentNotifications.length === 0 ? ( +

+ Bildirim yok. +

+ ) : ( +
    + {data.recentNotifications.map((n) => ( +
  • + +
    +

    {n.message}

    +

    + {datetimeFormatter.format(new Date(n.$createdAt))} +

    +
    +
  • + ))} +
+ )}
); } + +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 ( + +

+ {label} +

+

{value}

+ {hint &&

{hint}

} + + ); +} diff --git a/src/lib/appwrite/active-context.ts b/src/lib/appwrite/active-context.ts index 58a6e20..3c59faa 100644 --- a/src/lib/appwrite/active-context.ts +++ b/src/lib/appwrite/active-context.ts @@ -3,12 +3,13 @@ import "server-only"; import { Query } from "node-appwrite"; 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"; export type ActiveContext = { user: { id: string; name: string; email: string }; tenantId: string; + kind: TenantKind | null; settings: TenantSettings | null; }; @@ -39,6 +40,7 @@ export async function getActiveContext(): Promise { return { user: { id: user.$id, name: user.name, email: user.email }, tenantId, + kind: settings?.kind ?? null, settings, }; } diff --git a/src/lib/appwrite/dashboard-queries.ts b/src/lib/appwrite/dashboard-queries.ts new file mode 100644 index 0000000..2f61a30 --- /dev/null +++ b/src/lib/appwrite/dashboard-queries.ts @@ -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 { + 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(); + 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[], + }; +}