Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-28 00:27:04 +00:00
co-authored by renee-png
parent 6da1011481
commit b821dd6256
+158 -1
View File
@@ -18,6 +18,7 @@ import {
ExternalLink,
Pencil,
Share2,
FolderInput,
} from "lucide-react";
import { formatDate } from "@/lib/format";
import { toast } from "sonner";
@@ -29,6 +30,14 @@ import {
} from "@/components/ui/dialog";
import { ShareDocumentDialog } from "./share-document-dialog";
import JSZip from "jszip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
const MAX_BYTES = 150 * 1024 * 1024;
@@ -46,6 +55,10 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
const [previewLoading, setPreviewLoading] = useState(false);
const [shareDoc, setShareDoc] = useState<any | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const [selectedFolders, setSelectedFolders] = useState<Set<string>>(new Set());
const [selectedDocs, setSelectedDocs] = useState<Set<string>>(new Set());
const [moveOpen, setMoveOpen] = useState(false);
const [moveDest, setMoveDest] = useState<string>("");
const load = useCallback(async () => {
const [{ data: ds, error: de }, { data: fs }] = await Promise.all([
@@ -90,6 +103,12 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
useEffect(() => { load(); }, [load]);
// Clear selection when folder changes
useEffect(() => {
setSelectedFolders(new Set());
setSelectedDocs(new Set());
}, [path]);
// Visible items: subfolders directly under `path` and files whose folder === path
const subfolders = folders
.filter((f) => {
@@ -340,6 +359,84 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
load();
};
const toggleFolderSel = (full: string) => {
setSelectedFolders((prev) => {
const next = new Set(prev);
if (next.has(full)) next.delete(full); else next.add(full);
return next;
});
};
const toggleDocSel = (id: string) => {
setSelectedDocs((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const selectionCount = selectedFolders.size + selectedDocs.size;
// Destinations: Root + all folders that are NOT a selected folder or descendant of one
const moveDestinations = (() => {
const blocked = (p: string) => {
for (const sel of selectedFolders) {
if (p === sel || p.startsWith(`${sel}/`)) return true;
}
return false;
};
return ["", ...folders.filter((f) => !blocked(f))].filter(
(dest) => dest !== path, // moving to current folder is a no-op
);
})();
const performMove = async () => {
const dest = moveDest; // "" = root
// Move folders
for (const full of selectedFolders) {
const name = full.split("/").pop() || full;
const newFull = dest ? `${dest}/${name}` : name;
if (newFull === full) continue;
if (folders.includes(newFull)) {
toast.error(`A folder named "${name}" already exists at the destination`);
return;
}
// Update documents in this folder + descendants
const affectedDocs = docs.filter(
(d) => (d.folder || "") === full || (d.folder || "").startsWith(`${full}/`),
);
for (const d of affectedDocs) {
const next = d.folder === full ? newFull : `${newFull}${d.folder.slice(full.length)}`;
const { error } = await supabase.from("documents").update({ folder: next }).eq("id", d.id);
if (error) { toast.error("Move failed", { description: error.message }); return; }
}
// Update folder rows
const affectedFolders = folders.filter((f) => f === full || f.startsWith(`${full}/`));
for (const f of affectedFolders) {
const next = f === full ? newFull : `${newFull}${f.slice(full.length)}`;
const { error } = await supabase
.from("document_folders")
.update({ path: next })
.eq("case_id", caseId)
.eq("path", f);
if (error) {
await supabase.from("document_folders").delete().eq("case_id", caseId).eq("path", f);
await ensureFolder(next);
}
}
}
// Move loose files
for (const id of selectedDocs) {
const { error } = await supabase.from("documents").update({ folder: dest }).eq("id", id);
if (error) { toast.error("Move failed", { description: error.message }); return; }
}
toast.success(`Moved ${selectionCount} item${selectionCount === 1 ? "" : "s"}`);
setMoveOpen(false);
setMoveDest("");
setSelectedFolders(new Set());
setSelectedDocs(new Set());
load();
};
const crumbs = path ? path.split("/") : [];
return (
@@ -372,6 +469,15 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
<Button variant="outline" size="sm" onClick={() => setCreatingFolder((v) => !v)}>
<FolderPlus className="h-4 w-4 mr-1.5" /> New folder
</Button>
{selectionCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => { setMoveDest(""); setMoveOpen(true); }}
>
<FolderInput className="h-4 w-4 mr-1.5" /> Move ({selectionCount})
</Button>
)}
<input ref={fileRef} type="file" multiple className="hidden" onChange={onSelectFiles} />
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Upload className="h-4 w-4 mr-1.5" />}
@@ -409,6 +515,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="w-8 px-2 py-3"></th>
<th className="text-left px-4 py-3 font-medium">Name</th>
<th className="text-left px-4 py-3 font-medium">Uploaded by</th>
<th className="text-left px-4 py-3 font-medium">Date</th>
@@ -419,7 +526,7 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
<tbody>
{subfolders.length === 0 && visibleDocs.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-16 text-muted-foreground">
<td colSpan={6} className="text-center py-16 text-muted-foreground">
<Upload className="h-6 w-6 mx-auto mb-2 opacity-60" />
Drag & drop files or folders here, or click <span className="font-medium">Upload</span>.
</td>
@@ -427,6 +534,13 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
)}
{subfolders.map((f) => (
<tr key={`d-${f.full}`} className="border-t hover:bg-muted/30">
<td className="px-2 py-3 text-center">
<Checkbox
checked={selectedFolders.has(f.full)}
onCheckedChange={() => toggleFolderSel(f.full)}
aria-label={`Select folder ${f.name}`}
/>
</td>
<td className="px-4 py-3">
<button onClick={() => setPath(f.full)} className="flex items-center gap-2 font-medium hover:text-primary">
<Folder className="h-4 w-4 text-muted-foreground" />
@@ -446,6 +560,13 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
))}
{visibleDocs.map((d) => (
<tr key={d.id} className="border-t hover:bg-muted/30">
<td className="px-2 py-3 text-center">
<Checkbox
checked={selectedDocs.has(d.id)}
onCheckedChange={() => toggleDocSel(d.id)}
aria-label={`Select ${d.name}`}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
@@ -504,6 +625,42 @@ export function CaseDocumentsTab({ caseId }: { caseId: string }) {
caseId={caseId}
doc={shareDoc}
/>
<Dialog open={moveOpen} onOpenChange={setMoveOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Move {selectionCount} item{selectionCount === 1 ? "" : "s"}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground">Choose a destination folder:</div>
<Select value={moveDest} onValueChange={setMoveDest}>
<SelectTrigger>
<SelectValue placeholder="Select destination…" />
</SelectTrigger>
<SelectContent>
{moveDestinations.map((dest) => (
<SelectItem key={dest || "__root__"} value={dest || "__root__"}>
{dest ? dest : "Root"}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" onClick={() => setMoveOpen(false)}>Cancel</Button>
<Button
onClick={() => {
// SelectItem cannot have empty value; map sentinel back to ""
if (moveDest === "__root__") setMoveDest("");
performMove();
}}
disabled={!moveDest}
>
Move
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}