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:
kovakmedya
2026-05-21 20:17:33 +03:00
parent 76e02754b8
commit 2c6c074a06
24 changed files with 2066 additions and 21 deletions
+59 -7
View File
@@ -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"}>