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:
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
deleteJobFileAction,
|
||||
uploadJobFilesAction,
|
||||
} from "@/lib/appwrite/job-file-actions";
|
||||
import {
|
||||
initialJobFileActionState,
|
||||
initialJobFileUploadState,
|
||||
JOB_FILE_KIND_LABELS,
|
||||
} from "@/lib/appwrite/job-file-types";
|
||||
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function kindIcon(kind: string) {
|
||||
if (kind === "image") return <ImageIcon className="size-4" />;
|
||||
if (kind === "scan") return <Layers className="size-4" />;
|
||||
return <FileText className="size-4" />;
|
||||
}
|
||||
|
||||
export function JobFilesPanel({
|
||||
jobId,
|
||||
files,
|
||||
}: {
|
||||
jobId: string;
|
||||
files: JobFileWithUrl[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UploadForm jobId={jobId} />
|
||||
{files.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
Henüz dosya yok.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{files.map((f) => (
|
||||
<FileRow key={f.$id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadForm({ jobId }: { jobId: string }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
uploadJobFilesAction,
|
||||
initialJobFileUploadState,
|
||||
);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [selected, setSelected] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok && state.uploaded) {
|
||||
toast.success(`${state.uploaded} dosya yüklendi.`);
|
||||
formRef.current?.reset();
|
||||
setSelected([]);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={action}
|
||||
className="bg-muted/30 flex flex-wrap items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<input type="hidden" name="jobId" value={jobId} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
name="files"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.tiff,.tif,.bmp,.heic,.heif,.stl,.obj,.ply,.3mf,.zip,.rar,.7z,.dcm,.stm"
|
||||
onChange={(e) => {
|
||||
const list = e.target.files ? Array.from(e.target.files) : [];
|
||||
setSelected(list);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Dosya seç
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs flex-1">
|
||||
{selected.length > 0
|
||||
? `${selected.length} dosya seçildi (${formatSize(selected.reduce((s, f) => s + f.size, 0))})`
|
||||
: "Tarama (STL/OBJ), görsel veya PDF — max 30MB / dosya"}
|
||||
</span>
|
||||
<Button type="submit" size="sm" disabled={pending || selected.length === 0}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Yükleniyor...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-4" />
|
||||
Yükle
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ file }: { file: JobFileWithUrl }) {
|
||||
const [state, action, pending] = useActionState(
|
||||
deleteJobFileAction,
|
||||
initialJobFileActionState,
|
||||
);
|
||||
const [, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.ok) {
|
||||
toast.success("Dosya silindi.");
|
||||
setOpen(false);
|
||||
} else if (state.error) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
<span className="text-muted-foreground">{kindIcon(file.kind)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind} · {formatSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="hidden sm:inline-flex">
|
||||
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
|
||||
</Badge>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={file.url} target="_blank" rel="noopener noreferrer" download={file.name}>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dosya silinsin mi?</DialogTitle>
|
||||
<DialogDescription>{file.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Vazgeç
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<form
|
||||
action={(fd) => {
|
||||
startTransition(() => action(fd));
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="rowId" value={file.$id} />
|
||||
<Button type="submit" variant="destructive" disabled={pending}>
|
||||
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
||||
Sil
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user