d3977a5dcf
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.
385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
"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); // 0–100
|
||
|
||
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'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'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>
|
||
);
|
||
}
|