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
@@ -0,0 +1,239 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import {
ArrowRight,
Check,
CircleAlert,
Loader2,
Play,
PackageCheck,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
acceptJobAction,
advanceStepAction,
cancelJobAction,
markDeliveredAction,
} from "@/lib/appwrite/job-actions";
import {
JOB_STEP_LABELS,
JOB_STEP_ORDER,
initialJobActionState,
} from "@/lib/appwrite/job-types";
import type { Job, TenantKind } from "@/lib/appwrite/schema";
type Side = "clinic" | "lab";
export function JobActionsPanel({
job,
side,
kind,
}: {
job: Job;
side: Side;
kind: TenantKind | null;
}) {
if (!kind) return null;
const isLab = side === "lab";
const isClinic = side === "clinic";
return (
<div className="flex flex-wrap items-center gap-2">
{isLab && job.status === "pending" && <AcceptButton jobId={job.$id} />}
{isLab && job.status === "in_progress" && <AdvanceButton job={job} />}
{isClinic && job.status === "sent" && <DeliverButton jobId={job.$id} />}
{(isClinic || isLab) && job.status === "pending" && (
<CancelButton jobId={job.$id} />
)}
</div>
);
}
function AcceptButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.ok) toast.success("İş işleme alındı.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
İşleme Al
</Button>
</form>
);
}
function AdvanceButton({ job }: { job: Job }) {
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("Aşama ilerletildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
const currentLabel = job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—";
const nextLabel = isFinal
? "Gönderildi olarak işaretle"
: JOB_STEP_LABELS[JOB_STEP_ORDER[currentIdx + 1]];
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button onClick={() => setOpen(true)}>
{isFinal ? <PackageCheck className="size-4" /> : <ArrowRight className="size-4" />}
{isFinal ? "Gönderildi" : "Sonraki Aşama"}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isFinal ? "İşi gönderilmiş olarak işaretle" : `${currentLabel} tamamlandı`}
</DialogTitle>
<DialogDescription>
{isFinal
? "İş artık 'Gönderildi' durumuna geçecek; klinik 'Teslim Aldım' onayını verecek."
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
</DialogDescription>
</DialogHeader>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
className="grid gap-3"
>
<input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2">
<Label htmlFor="note">Not (opsiyonel)</Label>
<Textarea
id="note"
name="note"
rows={3}
maxLength={1000}
placeholder="Örn. Renk kontrolü yapıldı, hasta provası onaylandı."
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function DeliverButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => {
if (state.ok) toast.success("İş teslim alındı.");
else if (state.error) toast.error(state.error);
}, [state]);
return (
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
Teslim Aldım
</Button>
</form>
);
}
function CancelButton({ jobId }: { jobId: string }) {
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false);
useEffect(() => {
if (state.ok) {
toast.success("İş iptal edildi.");
setOpen(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button variant="outline" onClick={() => setOpen(true)}>
<X className="size-4" />
İptal Et
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>İş iptal edilsin mi?</DialogTitle>
<DialogDescription className="flex items-start gap-2">
<CircleAlert className="text-destructive size-4 shrink-0" />
<span>
İş geri alınamaz şekilde iptal duruma geçer. Karşı taraf da bu işi iptal edilmiş olarak görür.
</span>
</DialogDescription>
</DialogHeader>
<form
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} />
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" variant="destructive" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
İptal Et
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}