From d7d2ac557ba4ff37360003d19d449b8ee1e27b48 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 16:02:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(jobs):=20due-date=20awareness=20=E2=80=94?= =?UTF-8?q?=20DueBadge=20+=20dashboard=20'Geciken=20=C4=B0=C5=9Fler'=20wid?= =?UTF-8?q?get?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(dashboard)/dashboard/page.tsx | 32 ++++++++++- src/app/(dashboard)/jobs/[jobId]/page.tsx | 10 +++- .../jobs/_components/jobs-table.tsx | 12 ++++ src/components/due-badge.tsx | 27 +++++++++ src/lib/appwrite/dashboard-queries.ts | 36 +++++++++++- src/lib/appwrite/due-date.ts | 56 +++++++++++++++++++ 6 files changed, 166 insertions(+), 7 deletions(-) create mode 100644 src/components/due-badge.tsx create mode 100644 src/lib/appwrite/due-date.ts diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 5e376a0..f424d6c 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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() { /> + {data.overdueJobs.length > 0 && ( + + + + + Geciken İşler ({data.overdueJobs.length}) + + + Termin tarihi geçmiş ve henüz teslim edilmemiş işler. + + + +
    + {data.overdueJobs.map((j) => ( +
  • + + {j.counterpartName ?? "—"} + · + {j.patientCode} + + +
  • + ))} +
+
+
+ )} + {isClinic && data.approvedConnectionsCount === 0 && ( diff --git a/src/app/(dashboard)/jobs/[jobId]/page.tsx b/src/app/(dashboard)/jobs/[jobId]/page.tsx index adbedd9..252301e 100644 --- a/src/app/(dashboard)/jobs/[jobId]/page.tsx +++ b/src/app/(dashboard)/jobs/[jobId]/page.tsx @@ -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({

- - {JOB_STATUS_LABELS[job.status]} - +
+ + + {JOB_STATUS_LABELS[job.status]} + +
diff --git a/src/app/(dashboard)/jobs/_components/jobs-table.tsx b/src/app/(dashboard)/jobs/_components/jobs-table.tsx index 7e87568..40749a2 100644 --- a/src/app/(dashboard)/jobs/_components/jobs-table.tsx +++ b/src/app/(dashboard)/jobs/_components/jobs-table.tsx @@ -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({ Renk Tür Durum + Termin Tarih İşlem @@ -80,6 +82,16 @@ export function JobsTable({ {JOB_STATUS_LABELS[j.status]} + + {j.dueDate ? ( +
+ {dateFormatter.format(new Date(j.dueDate))} + +
+ ) : ( + "—" + )} +
{dateFormatter.format(new Date(j.$createdAt))} diff --git a/src/components/due-badge.tsx b/src/components/due-badge.tsx new file mode 100644 index 0000000..d553d90 --- /dev/null +++ b/src/components/due-badge.tsx @@ -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; + 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 ( + + {state.kind === "overdue" ? ( + + ) : ( + + )} + {dueLabel(state)} + + ); +} diff --git a/src/lib/appwrite/dashboard-queries.ts b/src/lib/appwrite/dashboard-queries.ts index a203e3c..7221e0b 100644 --- a/src/lib/appwrite/dashboard-queries.ts +++ b/src/lib/appwrite/dashboard-queries.ts @@ -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, + })), }); } diff --git a/src/lib/appwrite/due-date.ts b/src/lib/appwrite/due-date.ts new file mode 100644 index 0000000..5454ea5 --- /dev/null +++ b/src/lib/appwrite/due-date.ts @@ -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, + 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 ""; + } +}