Files
lab/src/app/(dashboard)/jobs/[jobId]/components/job-files-panel.tsx
T
kovakmedya d3977a5dcf feat(jobs): purge file binaries when a job is delivered, keep metadata
Active scan + image traffic was going to bloat Storage fast — every
delivered case has tens of MB of STL hanging around forever. Now closing
a case via 'Teslim Aldım' fires a background archive sweep that deletes
the binary from the bucket but keeps the job_files row, so audit
('kim, ne, ne zaman yükledi') is preserved.

  - DB: job_files.archivedAt datetime (nullable).
  - archiveJobFiles(jobId) (lib/appwrite/job-file-archive.ts):
    lists rows, storage.deleteFile each, stamps archivedAt on the row.
    All in try/catch so partial Storage failures don't roll back the
    'delivered' transition.
  - markDeliveredAction fires it as 'void archiveJobFiles(jobId)' — same
    fire-and-forget pattern as audit/notifications/finance sync.

UI / API
  - Job detail file row dims to 60% opacity, shows 'Arşivlendi
    {tarih}' inline, and disables both the download dialog trigger and
    the STL viewer button.
  - /api/jobs/[jobId]/files/[fileId]/download returns 410 Gone with a
    Turkish message when archivedAt is set — direct-URL hot links can't
    fish the file back either.
2026-05-22 15:58:58 +03:00

385 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { Download, Eye, 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 { Progress } from "@/components/ui/progress";
import { deleteJobFileAction } from "@/lib/appwrite/job-file-actions";
import {
initialJobFileActionState,
JOB_FILE_KIND_LABELS,
} from "@/lib/appwrite/job-file-types";
import type { JobFileWithUrl } from "@/lib/appwrite/job-file-queries";
// three.js + react-three is ~500KB minified; only load it when the user
// actually opens the viewer dialog.
const STLViewer = dynamic(
() => import("@/components/stl-viewer").then((m) => m.STLViewer),
{ ssr: false, loading: () => null },
);
const VIEWABLE_RE = /\.(stl|ply|obj)$/i;
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>
);
}
const MAX_FILE_BYTES = 200 * 1024 * 1024;
const MAX_BATCH_BYTES = 200 * 1024 * 1024; // proxy cap; one large file fills the whole batch
type UploadPhase = "idle" | "uploading" | "processing";
function UploadForm({ jobId }: { jobId: string }) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
const [selected, setSelected] = useState<File[]>([]);
const [phase, setPhase] = useState<UploadPhase>("idle");
const [progress, setProgress] = useState(0); // 0100
const totalBytes = selected.reduce((s, f) => s + f.size, 0);
const overSize = selected.find((f) => f.size > MAX_FILE_BYTES);
const overBatch = totalBytes > MAX_BATCH_BYTES;
const blocked = Boolean(overSize) || overBatch;
const busy = phase !== "idle";
function reset() {
setSelected([]);
setProgress(0);
setPhase("idle");
if (inputRef.current) inputRef.current.value = "";
}
function startUpload() {
if (selected.length === 0 || blocked) return;
const formData = new FormData();
for (const f of selected) formData.append("files", f);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
setPhase("uploading");
setProgress(0);
xhr.upload.addEventListener("progress", (e) => {
if (!e.lengthComputable) return;
const pct = Math.round((e.loaded / e.total) * 100);
setProgress(pct);
});
// The browser has finished pushing bytes; the server is now writing to
// Appwrite. Flip to "processing" so the user sees something is still
// happening (large files take 30-60s to land in Storage).
xhr.upload.addEventListener("load", () => {
setPhase("processing");
setProgress(100);
});
xhr.addEventListener("load", () => {
let payload: { ok?: boolean; uploaded?: number; error?: string } = {};
try {
payload = JSON.parse(xhr.responseText);
} catch {
/* non-JSON response */
}
if (xhr.status >= 200 && xhr.status < 300 && payload.ok) {
toast.success(`${payload.uploaded ?? selected.length} dosya yüklendi.`);
reset();
router.refresh();
} else {
toast.error(payload.error || `Yükleme başarısız (HTTP ${xhr.status}).`);
setPhase("idle");
setProgress(0);
}
});
xhr.addEventListener("error", () => {
setPhase("idle");
setProgress(0);
toast.error("Ağ hatası. Tekrar deneyin.");
});
xhr.addEventListener("abort", () => {
setPhase("idle");
setProgress(0);
toast.message("Yükleme iptal edildi.");
});
xhr.open("POST", `/api/jobs/${jobId}/files`);
xhr.send(formData);
}
function cancelUpload() {
xhrRef.current?.abort();
}
return (
<div className="bg-muted/30 flex flex-col gap-3 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-3">
<input
ref={inputRef}
type="file"
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);
setProgress(0);
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => inputRef.current?.click()}
disabled={busy}
>
<Upload className="size-4" />
Dosya seç
</Button>
<span className="text-muted-foreground text-xs flex-1">
{selected.length > 0 ? (
overSize ? (
<span className="text-destructive">
{overSize.name} 200MB&apos;tan büyük (her dosya maksimum 200MB).
</span>
) : overBatch ? (
<span className="text-destructive">
Toplam {formatSize(totalBytes)} 200MB sınırını aşıyor. Daha az dosya seçin.
</span>
) : (
<>
{selected.length} dosya seçildi ({formatSize(totalBytes)})
</>
)
) : (
"Tarama (STL/OBJ), görsel veya PDF — max 200MB / dosya"
)}
</span>
{phase === "uploading" ? (
<Button type="button" size="sm" variant="outline" onClick={cancelUpload}>
İptal
</Button>
) : phase === "processing" ? (
<Button type="button" size="sm" variant="outline" disabled>
<Loader2 className="size-4 animate-spin" />
İşleniyor
</Button>
) : (
<Button
type="button"
size="sm"
onClick={startUpload}
disabled={selected.length === 0 || blocked}
>
<Upload className="size-4" />
Yükle
</Button>
)}
</div>
{phase === "uploading" && (
<div className="flex items-center gap-3">
<Progress value={progress} className="flex-1" />
<span className="text-muted-foreground min-w-[3rem] text-right text-xs tabular-nums">
{progress}%
</span>
</div>
)}
{phase === "processing" && (
<div className="flex items-center gap-3">
<Progress value={100} className="flex-1" />
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Loader2 className="size-3 animate-spin" />
Sunucu Appwrite&apos;a yazıyor büyük dosyalar 30-60 sn sürebilir
</span>
</div>
)}
</div>
);
}
function FileRow({ file }: { file: JobFileWithUrl }) {
const [state, action, pending] = useActionState(
deleteJobFileAction,
initialJobFileActionState,
);
const router = useRouter();
const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
const isArchived = Boolean(file.archivedAt);
const isViewable = !isArchived && VIEWABLE_RE.test(file.name);
useEffect(() => {
if (state.ok) {
toast.success("Dosya silindi.");
setOpen(false);
router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
function triggerDownload() {
// Use a programmatic anchor click — the server route streams the file
// with Content-Disposition: attachment, so the browser hands it straight
// to the download manager. Toast confirms it left our side.
const a = document.createElement("a");
a.href = file.url;
a.download = file.name;
document.body.appendChild(a);
a.click();
a.remove();
setDownloadOpen(false);
toast.success("İndirme başladı.", { description: file.name });
}
return (
<li className={`flex items-center gap-3 px-3 py-2 ${isArchived ? "opacity-60" : ""}`}>
<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)}
{isArchived && (
<>
{" · "}
<span className="text-amber-600 dark:text-amber-400">
Arşivlendi {new Date(file.archivedAt!).toLocaleDateString("tr-TR")}
</span>
</>
)}
</p>
</div>
<Badge variant="outline" className="hidden sm:inline-flex">
{JOB_FILE_KIND_LABELS[file.kind] ?? file.kind}
</Badge>
{isViewable && (
<Dialog open={viewerOpen} onOpenChange={setViewerOpen}>
<Button
size="sm"
variant="outline"
onClick={() => setViewerOpen(true)}
>
<Eye className="size-4" />
</Button>
<DialogContent className="h-[85vh] max-w-5xl gap-0 p-0">
<DialogHeader className="border-b px-4 py-3">
<DialogTitle className="truncate text-base">{file.name}</DialogTitle>
<DialogDescription className="text-xs">
{formatSize(file.size)} · Sürükleyerek döndürün, kaydırarak yaklaşın.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1">
{viewerOpen && <STLViewer url={file.url} filename={file.name} />}
</div>
</DialogContent>
</Dialog>
)}
<Dialog open={downloadOpen} onOpenChange={setDownloadOpen}>
<Button
size="sm"
variant="outline"
onClick={() => setDownloadOpen(true)}
disabled={isArchived}
title={isArchived ? "Bu dosya arşivlendi; indirilebilir kopyası yok." : undefined}
>
<Download className="size-4" />
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Dosya indirilsin mi?</DialogTitle>
<DialogDescription>
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground"> · {formatSize(file.size)}</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="button" onClick={triggerDownload}>
<Download className="size-4" />
İndir
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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={action}>
<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>
);
}