feat(jobs): due-date awareness — DueBadge + dashboard 'Geciken İşler' widget
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.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
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 { AlertCircle, ArrowRight, FlaskConical, Link2, Plus, Stethoscope } from "lucide-react";
|
||||||
|
|
||||||
|
import { DueBadge } from "@/components/due-badge";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -109,6 +111,34 @@ export default async function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data.overdueJobs.length > 0 && (
|
||||||
|
<Card className="border-destructive/40 bg-destructive/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertCircle className="text-destructive size-4" />
|
||||||
|
Geciken İşler ({data.overdueJobs.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Termin tarihi geçmiş ve henüz teslim edilmemiş işler.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ul className="divide-y rounded-md border bg-background">
|
||||||
|
{data.overdueJobs.map((j) => (
|
||||||
|
<li key={j.$id} className="flex items-center gap-3 px-3 py-2 text-sm">
|
||||||
|
<Link href={`/jobs/${j.$id}`} className="flex-1 min-w-0 hover:underline">
|
||||||
|
<span className="font-medium">{j.counterpartName ?? "—"}</span>
|
||||||
|
<span className="text-muted-foreground"> · </span>
|
||||||
|
<span className="font-mono text-xs">{j.patientCode}</span>
|
||||||
|
</Link>
|
||||||
|
<DueBadge job={j} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{isClinic && data.approvedConnectionsCount === 0 && (
|
{isClinic && data.approvedConnectionsCount === 0 && (
|
||||||
<Card className="border-primary/20 bg-primary/5">
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { notFound, redirect } from "next/navigation";
|
|||||||
import { Query } from "node-appwrite";
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DueBadge } from "@/components/due-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { listJobFiles } from "@/lib/appwrite/job-file-queries";
|
import { listJobFiles } from "@/lib/appwrite/job-file-queries";
|
||||||
@@ -116,9 +117,12 @@ export default async function JobDetailPage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-3">
|
<div className="flex flex-col items-end gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DueBadge job={job} />
|
||||||
<Badge variant="secondary" className="text-sm">
|
<Badge variant="secondary" className="text-sm">
|
||||||
{JOB_STATUS_LABELS[job.status]}
|
{JOB_STATUS_LABELS[job.status]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
|
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DueBadge } from "@/components/due-badge";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -63,6 +64,7 @@ export function JobsTable({
|
|||||||
<TableHead>Renk</TableHead>
|
<TableHead>Renk</TableHead>
|
||||||
<TableHead>Tür</TableHead>
|
<TableHead>Tür</TableHead>
|
||||||
<TableHead>Durum</TableHead>
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead>Termin</TableHead>
|
||||||
<TableHead>Tarih</TableHead>
|
<TableHead>Tarih</TableHead>
|
||||||
<TableHead className="text-right">İşlem</TableHead>
|
<TableHead className="text-right">İşlem</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -80,6 +82,16 @@ export function JobsTable({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
|
<Badge variant={statusVariant(j.status)}>{JOB_STATUS_LABELS[j.status]}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{j.dueDate ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{dateFormatter.format(new Date(j.dueDate))}</span>
|
||||||
|
<DueBadge job={j} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-xs">
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
{dateFormatter.format(new Date(j.$createdAt))}
|
{dateFormatter.format(new Date(j.$createdAt))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -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<Job, "dueDate" | "status">;
|
||||||
|
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 (
|
||||||
|
<Badge variant={variant} className={`gap-1 ${className ?? ""}`}>
|
||||||
|
{state.kind === "overdue" ? (
|
||||||
|
<AlertCircle className="size-3" />
|
||||||
|
) : (
|
||||||
|
<Clock className="size-3" />
|
||||||
|
)}
|
||||||
|
{dueLabel(state)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export type DashboardData = {
|
|||||||
approvedConnectionsCount: number;
|
approvedConnectionsCount: number;
|
||||||
recentJobs: DashboardJob[];
|
recentJobs: DashboardJob[];
|
||||||
recentNotifications: Notification[];
|
recentNotifications: Notification[];
|
||||||
|
/** Open jobs whose dueDate has already passed. */
|
||||||
|
overdueJobs: DashboardJob[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getDashboardData(
|
export async function getDashboardData(
|
||||||
@@ -41,8 +43,16 @@ export async function getDashboardData(
|
|||||||
// count separately for the stat card.
|
// count separately for the stat card.
|
||||||
const jobsField = isLab ? "labTenantId" : "clinicTenantId";
|
const jobsField = isLab ? "labTenantId" : "clinicTenantId";
|
||||||
|
|
||||||
const [recentJobsRes, openJobsRes, pendingActionRes, financeRes, notifRes, unreadRes, connRes] =
|
const [
|
||||||
await Promise.all([
|
recentJobsRes,
|
||||||
|
openJobsRes,
|
||||||
|
pendingActionRes,
|
||||||
|
financeRes,
|
||||||
|
notifRes,
|
||||||
|
unreadRes,
|
||||||
|
connRes,
|
||||||
|
overdueRes,
|
||||||
|
] = await Promise.all([
|
||||||
tablesDB.listRows({
|
tablesDB.listRows({
|
||||||
databaseId: DATABASE_ID,
|
databaseId: DATABASE_ID,
|
||||||
tableId: TABLES.jobs,
|
tableId: TABLES.jobs,
|
||||||
@@ -110,12 +120,27 @@ export async function getDashboardData(
|
|||||||
Query.limit(1),
|
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 recentJobs = recentJobsRes.rows as unknown as Job[];
|
||||||
|
const overdueJobs = overdueRes.rows as unknown as Job[];
|
||||||
const counterpartIds = Array.from(
|
const counterpartIds = Array.from(
|
||||||
new Set(
|
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,
|
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
|
||||||
})),
|
})),
|
||||||
recentNotifications: notifRes.rows as unknown as Notification[],
|
recentNotifications: notifRes.rows as unknown as Notification[],
|
||||||
|
overdueJobs: overdueJobs.map((j) => ({
|
||||||
|
...j,
|
||||||
|
counterpartName:
|
||||||
|
counterpartMap.get(isLab ? j.clinicTenantId : j.labTenantId) ?? null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Job, "dueDate" | "status">,
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user