Files
kandev/apps/web/components/task/simple/task-documents.tsx
T
2026-09-10 18:14:05 +01:00

333 lines
9.5 KiB
TypeScript

"use client";
import { useState, useCallback } from "react";
import {
IconChevronDown,
IconChevronRight,
IconPlus,
IconTrash,
IconDownload,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { toast } from "@/lib/toast/sonner";
import { formatRelativeTime } from "@/lib/utils";
import {
listDocuments,
createOrUpdateDocument,
deleteDocument,
type TaskDocument,
} from "@/lib/api/domains/office-extended-api";
import { useTranslation } from "react-i18next";
// --- Document type config ---
type DocTypeConfig = {
label: string;
className: string;
};
const DOC_TYPE_CONFIG: Record<string, DocTypeConfig> = {
PLAN: {
label: "PLAN",
className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
},
SPEC: {
label: "SPEC",
className: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
},
NOTES: {
label: "NOTES",
className: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
},
REVIEW: {
label: "REVIEW",
className: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
},
ATTACHMENT: {
label: "ATTACH",
className: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
},
};
const DOCUMENT_TYPES = ["PLAN", "SPEC", "NOTES", "REVIEW", "ATTACHMENT"] as const;
function getTypeConfig(type: string): DocTypeConfig {
return (
DOC_TYPE_CONFIG[type.toUpperCase()] ?? {
label: type,
className: "bg-muted text-muted-foreground",
}
);
}
// --- New document form ---
type NewDocFormState = {
key: string;
type: string;
title: string;
content: string;
};
const EMPTY_FORM: NewDocFormState = {
key: "",
type: "NOTES",
title: "",
content: "",
};
function NewDocumentForm({
taskId,
onCreated,
onCancel,
}: {
taskId: string;
onCreated: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<NewDocFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const update = useCallback((patch: Partial<NewDocFormState>) => {
setForm((prev) => ({ ...prev, ...patch }));
}, []);
const handleSubmit = async () => {
if (!form.key.trim() || !form.content.trim()) {
toast.error(t("task:keyAndContentAreRequired"));
return;
}
setSaving(true);
try {
await createOrUpdateDocument(taskId, form.key.trim(), {
type: form.type,
title: form.title.trim() || undefined,
content: form.content,
});
toast.success(t("task:documentSaved"));
onCreated();
} catch {
toast.error(t("task:failedToSaveDocument"));
} finally {
setSaving(false);
}
};
return (
<div className="border border-border rounded-lg p-3 space-y-2 bg-muted/30">
<div className="flex gap-2">
<input
className="flex-1 px-2 py-1 text-sm border border-border rounded bg-background"
placeholder={t("task:keyEGPlanNotes")}
value={form.key}
onChange={(e) => update({ key: e.target.value })}
/>
<select
className="px-2 py-1 text-sm border border-border rounded bg-background cursor-pointer"
value={form.type}
onChange={(e) => update({ type: e.target.value })}
>
{DOCUMENT_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<input
className="w-full px-2 py-1 text-sm border border-border rounded bg-background"
placeholder={t("task:titleOptional")}
value={form.title}
onChange={(e) => update({ title: e.target.value })}
/>
<Textarea
placeholder={t("task:contentMarkdownSupported")}
value={form.content}
onChange={(e) => update({ content: e.target.value })}
className="min-h-[100px] text-sm"
/>
<div className="flex gap-2 justify-end">
<Button variant="ghost" size="sm" className="cursor-pointer" onClick={onCancel}>
{t("common:cancel")}
</Button>
<Button size="sm" className="cursor-pointer" disabled={saving} onClick={handleSubmit}>
{saving ? t("task:saving") : t("common:save")}
</Button>
</div>
</div>
);
}
// --- Document card ---
function AttachmentBody({ doc }: { doc: TaskDocument }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2 px-3 pb-3">
<span className="text-sm text-muted-foreground flex-1">
{doc.filename ?? doc.key}
{doc.size !== undefined && (
<span className="ml-1">
{t("task:attachmentSizeKb", { size: Math.round(doc.size / 1024) })}
</span>
)}
</span>
<a
href={`/api/v1/office/tasks/${doc.taskId}/documents/${encodeURIComponent(doc.key)}/download`}
download={doc.filename ?? doc.key}
className="cursor-pointer"
>
<Button variant="ghost" size="icon" className="cursor-pointer">
<IconDownload className="h-3.5 w-3.5" />
</Button>
</a>
</div>
);
}
function DocumentCard({ doc, onDelete }: { doc: TaskDocument; onDelete: (key: string) => void }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const typeConfig = getTypeConfig(doc.type);
const isAttachment = doc.type.toUpperCase() === "ATTACHMENT";
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-accent/50 transition-colors cursor-pointer text-left"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? (
<IconChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<IconChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium shrink-0 ${typeConfig.className}`}
>
{typeConfig.label}
</span>
<span className="flex-1 text-sm font-medium truncate">{doc.title || doc.key}</span>
<span className="text-xs text-muted-foreground shrink-0">rev {doc.revision}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
{formatRelativeTime(doc.updatedAt)}
</span>
<button
type="button"
className="ml-1 cursor-pointer p-1 rounded hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(doc.key);
}}
>
<IconTrash className="h-3 w-3" />
</button>
</button>
{expanded &&
(isAttachment ? (
<AttachmentBody doc={doc} />
) : (
<div className="px-3 pb-3 pt-1 text-sm text-muted-foreground whitespace-pre-wrap border-t border-border/50 max-h-[400px] overflow-y-auto">
{doc.content || <span className="italic">{t("task:noContent")}</span>}
</div>
))}
</div>
);
}
// --- Main component ---
type Props = {
taskId: string;
};
export function TaskDocuments({ taskId }: Props) {
const { t } = useTranslation();
const [documents, setDocuments] = useState<TaskDocument[] | null>(null);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await listDocuments(taskId);
setDocuments(res.documents ?? []);
setLoaded(true);
} catch {
toast.error(t("task:failedToLoadDocuments"));
} finally {
setLoading(false);
}
}, [taskId, t]);
const handleDelete = useCallback(
async (key: string) => {
try {
await deleteDocument(taskId, key);
setDocuments((prev) => prev?.filter((d) => d.key !== key) ?? null);
toast.success(t("task:documentDeleted"));
} catch {
toast.error(t("task:failedToDeleteDocument"));
}
},
[taskId, t],
);
const handleCreated = useCallback(async () => {
setShowForm(false);
await load();
}, [load]);
// Lazy-load on first reveal
if (!loaded && !loading) {
load();
}
const docs = documents ?? [];
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold">{t("task:documents")}</h2>
<Button
variant="ghost"
size="sm"
className="cursor-pointer h-7 text-xs"
onClick={() => setShowForm((v) => !v)}
>
<IconPlus className="h-3.5 w-3.5 mr-1" />
{t("task:newDocument")}
</Button>
</div>
{showForm && (
<div className="mb-3">
<NewDocumentForm
taskId={taskId}
onCreated={handleCreated}
onCancel={() => setShowForm(false)}
/>
</div>
)}
{loading && docs.length === 0 && (
<p className="text-sm text-muted-foreground">{t("task:loading")}</p>
)}
{loaded && docs.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground">{t("task:noDocumentsYet")}</p>
)}
<div className="space-y-2">
{docs.map((doc) => (
<DocumentCard key={doc.key} doc={doc} onDelete={handleDelete} />
))}
</div>
</div>
);
}