feat: job status/step flow, file upload, finance sync, notifications
Job lifecycle
- acceptJobAction (lab): pending → in_progress + currentStep=olcu
- advanceStepAction (lab): step ilerletir, son adım sonrası status=sent
- markDeliveredAction (clinic): sent → delivered
- cancelJobAction: pending iş iptali (her iki taraf)
- job_status_history her step transition'da idempotent kayıt
- Detay sayfası interactive panel + Aşama Geçmişi kartı
Job files (Appwrite Storage job-files bucket, 30MB/file)
- uploadJobFilesAction: çoklu dosya, mimeType'tan kind sınıflandırma
(scan/image/document), her iki team'e read permission, partial-fail
rollback (storage + row temizliği)
- deleteJobFileAction: yetkilendirilmiş silme, file + row birlikte
- JobFilesPanel: client-side select + upload + liste + indir + sil
- next.config bodySizeLimit 3mb → 100mb (toplu yükleme için)
Finance sync (idempotent)
- syncFinanceForJob helper: sent/delivered transition'larında klinik
payable + lab receivable rows (jobId+tenantId+type unique kontrolü,
her tarafta tek satır garanti)
- markFinancePaidAction / reopenFinanceAction: manuel ödendi/geri al
- /finance sayfası: stat kartlar (bekleyen alacak/borç, aylık gelir/gider)
+ hareketler tablosu, role-aware kopyalar
- Memory rule [[feedback_cross_entity_sync_helpers]]: best-effort, never
re-throws
Notifications
- createNotification helper, connection (request/approve) ve job
(create/accept/sent/delivered) eventlerinde tetikleniyor
- /notifications sayfası + tek tek / hepsi okundu işaretle
- Header'a Bell ikonu + okunmamış count badge (layout SSR'de besler)
- Middleware PROTECTED_PREFIXES'e /notifications ekli
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||||
import { Query } from "node-appwrite";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { listJobFiles } from "@/lib/appwrite/job-file-queries";
|
||||
import { listJobHistory } from "@/lib/appwrite/job-history-queries";
|
||||
import {
|
||||
JOB_STATUS_LABELS,
|
||||
JOB_STEP_LABELS,
|
||||
JOB_STEP_ORDER,
|
||||
PROSTHETIC_TYPE_LABELS,
|
||||
} from "@/lib/appwrite/job-types";
|
||||
import { createAdminClient } from "@/lib/appwrite/server";
|
||||
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||||
import { JobActionsPanel } from "./components/job-actions-panel";
|
||||
import { JobFilesPanel } from "./components/job-files-panel";
|
||||
|
||||
export const metadata = {
|
||||
title: "DLS — İş Detay",
|
||||
@@ -73,7 +77,12 @@ export default async function JobDetailPage({
|
||||
});
|
||||
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||||
|
||||
const [history, files] = await Promise.all([
|
||||
listJobHistory(jobId),
|
||||
listJobFiles(jobId),
|
||||
]);
|
||||
const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
|
||||
const side = job.clinicTenantId === ctx.tenantId ? "clinic" : "lab";
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-6">
|
||||
@@ -89,9 +98,12 @@ export default async function JobDetailPage({
|
||||
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{JOB_STATUS_LABELS[job.status]}
|
||||
</Badge>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{JOB_STATUS_LABELS[job.status]}
|
||||
</Badge>
|
||||
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
@@ -167,6 +179,46 @@ export default async function JobDetailPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Taranan Dosyalar ve Görseller</CardTitle>
|
||||
<CardDescription>
|
||||
Hem klinik hem laboratuvar dosya yükleyip indirebilir.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobFilesPanel jobId={job.$id} files={files} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aşama Geçmişi</CardTitle>
|
||||
<CardDescription>Tamamlanan aşamaların kaydı.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="space-y-3">
|
||||
{history.map((h) => (
|
||||
<li key={h.$id} className="border-l-2 border-primary/30 pl-4">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="font-medium">{JOB_STEP_LABELS[h.step]}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{dateFormatter.format(new Date(h.completedAt))}
|
||||
</span>
|
||||
</div>
|
||||
{h.note && (
|
||||
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
|
||||
{h.note}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
|
||||
|
||||
Reference in New Issue
Block a user