src/features/reviews/workspace-documents-tab.tsx
Linked repository file from this build.
"use client";
import { useRef, useState } from "react";
import { Download, ExternalLink, FileText, Pencil, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { fieldMultilineClass, Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { DocumentStorage } from "@/lib/documents/storage";
import { canEdit } from "./commands";
import type { Actor, ReviewDocument } from "./types";
import type { SnapReview } from "@/programs/snap-negative/types";
import { documentCategories } from "./types";
const textareaClass = `${fieldMultilineClass} min-h-24`;
type NamedDocument = ReviewDocument & { name?: string };
type UpdateDocument = (id: string, name: string, description: string) => Promise<void>;
function DocumentRow({
doc,
url,
editable,
onUpdate,
}: {
doc: NamedDocument;
url: string;
editable: boolean;
onUpdate: UpdateDocument;
}) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(doc.name || doc.filename);
const [description, setDescription] = useState(doc.description);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
async function save() {
if (!editable || saving || !name.trim() || !description.trim()) return;
setSaving(true);
setError("");
try {
await onUpdate(doc.id, name.trim(), description.trim());
setEditing(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not save document details.");
} finally {
setSaving(false);
}
}
return (
<article className="space-y-3 rounded-lg border bg-card p-4 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-start gap-3">
<FileText className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 space-y-1">
<h4 className="break-words font-semibold">{doc.name || doc.filename}</h4>
<p className="text-xs text-muted-foreground">
{doc.category} · {(doc.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{url && (
<>
<Button asChild size="sm" variant="outline">
<a href={url} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" aria-hidden /> Preview
</a>
</Button>
<Button asChild size="sm" variant="outline">
<a href={url} download={doc.filename}>
<Download className="size-4" aria-hidden /> Download
</a>
</Button>
</>
)}
{editable && !editing && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setName(doc.name || doc.filename);
setDescription(doc.description);
setError("");
setEditing(true);
}}
>
<Pencil className="size-4" aria-hidden /> Edit details
</Button>
)}
</div>
</div>
{editing && editable ? (
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="space-y-1.5">
<Label htmlFor={`doc-name-${doc.id}`}>Document name</Label>
<Input
id={`doc-name-${doc.id}`}
required
value={name}
disabled={saving}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`doc-purpose-${doc.id}`}>Purpose</Label>
<textarea
id={`doc-purpose-${doc.id}`}
required
value={description}
disabled={saving}
onChange={(event) => setDescription(event.target.value)}
className={textareaClass}
/>
</div>
{error && (
<p role="alert" className="text-destructive">
{error}
</p>
)}
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={saving || !name.trim() || !description.trim()}
>
{saving ? "Saving…" : "Save details"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={saving}
onClick={() => setEditing(false)}
>
Cancel
</Button>
</div>
</form>
) : (
<p className="whitespace-pre-wrap break-words leading-relaxed">
{doc.description || "Purpose has not been added yet."}
</p>
)}
<p className="break-words text-xs leading-relaxed text-muted-foreground">
{doc.filename} · Added by {doc.actor.name} · {new Date(doc.createdAt).toLocaleString()}
</p>
</article>
);
}
export function WorkspaceDocumentsTab({
review,
actor,
storage,
onUpload,
onUpdateDocument,
}: {
review: SnapReview;
actor: Actor;
storage: DocumentStorage;
onUpload(
file: File,
category: (typeof documentCategories)[number],
description: string,
name: string,
): Promise<void>;
onUpdateDocument: UpdateDocument;
}) {
const editable = canEdit(review, actor);
const [category, setCategory] = useState<(typeof documentCategories)[number]>("Evidence");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const ready = editable && !uploading && Boolean(name.trim()) && Boolean(description.trim());
async function handleFile(file: File | undefined) {
if (!file || !ready) return;
setUploadError("");
setUploading(true);
try {
await onUpload(file, category, description.trim(), name.trim());
setName("");
setDescription("");
} catch (cause) {
setUploadError(cause instanceof Error ? cause.message : "Upload failed.");
} finally {
if (fileInputRef.current) fileInputRef.current.value = "";
setUploading(false);
}
}
return (
<section aria-labelledby="documents-heading" className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
<div>
<h2 id="documents-heading" className="text-lg font-semibold">
Documents
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Keep the evidence and supporting documents for this review together. Give each document a
clear name and a short explanation of its purpose.
</p>
</div>
{editable ? (
<section
aria-labelledby="add-document-heading"
className="space-y-4 rounded-lg border bg-card p-4 sm:p-5"
>
<h3 id="add-document-heading" className="font-medium">
Add a document
</h3>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_12rem]">
<div className="space-y-1.5">
<Label htmlFor="doc-name">
Document name <span className="text-muted-foreground">(required)</span>
</Label>
<Input
id="doc-name"
required
value={name}
placeholder="For example, Notice of denial"
disabled={uploading}
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-category">Category</Label>
<Select
value={category}
disabled={uploading}
onValueChange={(value) => setCategory(value as (typeof documentCategories)[number])}
>
<SelectTrigger id="doc-category" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{documentCategories.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="doc-description">
Purpose <span className="text-muted-foreground">(required)</span>
</Label>
<textarea
id="doc-description"
required
value={description}
placeholder="Explain what this document shows and how it supports the review."
disabled={uploading}
onChange={(event) => setDescription(event.target.value)}
className={textareaClass}
/>
</div>
<input
ref={fileInputRef}
type="file"
aria-label="Document PDF"
accept="application/pdf,.pdf"
className="hidden"
disabled={!ready}
onChange={(event) => void handleFile(event.target.files?.[0])}
/>
{uploadError && (
<p role="alert" className="text-sm text-destructive">
{uploadError}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
disabled={!ready}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />{" "}
{uploading ? "Uploading…" : "Choose PDF to attach"}
</Button>
<p className="text-xs text-muted-foreground" role="status">
{uploading
? "Attaching your document…"
: "Enter a name and purpose, then choose a PDF."}
</p>
</div>
</section>
) : (
<p className="rounded-md bg-muted px-4 py-3 text-sm text-muted-foreground">
Documents are read-only in this review state or role.
</p>
)}
<section aria-labelledby="attached-documents-heading" className="space-y-3">
<h3 id="attached-documents-heading" className="font-medium">
Attached documents{" "}
<span className="text-muted-foreground">({review.documents.length})</span>
</h3>
{review.documents.length === 0 ? (
<p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
No documents attached.
</p>
) : (
review.documents.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
url={storage.url(doc.storageKey)}
editable={editable}
onUpdate={onUpdateDocument}
/>
))
)}
</section>
</section>
);
}