Files
sciagent/frontend_admin/src/pages/TemplatesPage.tsx
T
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

405 lines
15 KiB
TypeScript

import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileDown, Pencil, Plus, Power, Trash2 } from 'lucide-react';
import {
useAuth,
Card,
CardContent,
CardHeader,
CardTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Button,
buttonVariants,
Input,
Label,
Textarea,
Badge,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
cn,
formatIsoToDdMmYyyyHhMm,
detailFromApiError,
listTemplates,
createTemplate,
updateTemplate,
deleteTemplate,
downloadTemplateFile,
saveArrayBufferAs,
TEMPLATE_DOCX_MIME,
type DocumentTemplate,
} from '@ump/shared';
import { toast } from 'sonner';
export function TemplatesPage() {
const { hasRole } = useAuth();
const qc = useQueryClient();
const isAdmin = hasRole('admin');
const templatesQuery = useQuery({
queryKey: ['templates'],
queryFn: listTemplates,
enabled: isAdmin,
});
const templates = templatesQuery.data ?? [];
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<DocumentTemplate | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DocumentTemplate | null>(null);
const invalidate = () => qc.invalidateQueries({ queryKey: ['templates'] });
const createMut = useMutation({
mutationFn: createTemplate,
onSuccess: (t) => {
toast.success(`Đã tạo mẫu "${t.name}" — ${t.fields.length} trường.`);
setCreateOpen(false);
void invalidate();
},
onError: (e) => toast.error(detailFromApiError(e, 'Không tạo được mẫu.')),
});
const updateMut = useMutation({
mutationFn: (v: { id: string; patch: { name?: string; description?: string; isActive?: boolean } }) =>
updateTemplate(v.id, v.patch),
onSuccess: () => {
setEditing(null);
void invalidate();
},
onError: (e) => toast.error(detailFromApiError(e, 'Không cập nhật được mẫu.')),
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteTemplate(id, { hard: true }),
onSuccess: () => {
toast.success('Đã xóa mẫu.');
setDeleteTarget(null);
void invalidate();
},
onError: (e) => toast.error(detailFromApiError(e, 'Không xóa được mẫu.')),
});
const handleDownload = async (t: DocumentTemplate) => {
try {
const buf = await downloadTemplateFile(t.id);
saveArrayBufferAs(buf, t.originalFilename || `${t.name}.docx`, TEMPLATE_DOCX_MIME);
} catch (e) {
toast.error(detailFromApiError(e, 'Không tải được tệp mẫu.'));
}
};
if (!isAdmin) {
return (
<p className="text-sm text-muted-foreground">
Chỉ tài khoản quản trị mới quản được mẫu tài liệu.
</p>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Mẫu tài liệu</h1>
<p className="text-sm text-muted-foreground">
Tải lên mẫu .docx (Word) với các trường <code>{'{{ ten_truong }}'}</code>. Người nộp đơn
sẽ điền theo từng mẫu.
</p>
</div>
<Button onClick={() => setCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
Tạo mẫu mới
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Danh sách mẫu</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">STT</TableHead>
<TableHead className="min-w-[200px]">Tên mẫu</TableHead>
<TableHead className="min-w-[200px]"> tả</TableHead>
<TableHead className="w-[90px] text-center">Số trường</TableHead>
<TableHead className="w-[120px]">Trạng thái</TableHead>
<TableHead className="w-[150px]">Cập nhật</TableHead>
<TableHead className="w-[200px] text-center">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templatesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
Đang tải
</TableCell>
</TableRow>
) : templatesQuery.isError ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-destructive">
Không tải được danh sách mẫu.
</TableCell>
</TableRow>
) : templates.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
Chưa mẫu nào. Bấm « Tạo mẫu mới » để tải lên.
</TableCell>
</TableRow>
) : (
templates.map((t, i) => (
<TableRow key={t.id} data-state={t.isActive ? undefined : 'selected'}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium" title={t.originalFilename ?? ''}>
{t.name}
</TableCell>
<TableCell className="max-w-[280px] truncate text-muted-foreground" title={t.description ?? ''}>
{t.description || '—'}
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{t.fields.length}</Badge>
</TableCell>
<TableCell>
{t.isActive ? (
<Badge className="bg-green-600 hover:bg-green-600/90">Đang dùng</Badge>
) : (
<Badge variant="outline">n</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{t.updatedAt ? formatIsoToDdMmYyyyHhMm(t.updatedAt) : '—'}
</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1.5">
<Button size="icon" variant="outline" title="Tải tệp .docx" onClick={() => void handleDownload(t)}>
<FileDown className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline" title="Sửa thông tin" onClick={() => setEditing(t)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
title={t.isActive ? 'Ẩn mẫu (vô hiệu hóa)' : 'Hiện mẫu (kích hoạt)'}
disabled={updateMut.isPending}
onClick={() => updateMut.mutate({ id: t.id, patch: { isActive: !t.isActive } })}
>
<Power className={cn('h-4 w-4', t.isActive ? 'text-green-600' : 'text-muted-foreground')} />
</Button>
<Button size="icon" variant="destructive" title="Xóa vĩnh viễn" onClick={() => setDeleteTarget(t)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<CreateTemplateDialog
open={createOpen}
submitting={createMut.isPending}
onOpenChange={setCreateOpen}
onSubmit={(v) => createMut.mutate(v)}
/>
<EditTemplateDialog
template={editing}
submitting={updateMut.isPending}
onOpenChange={(o) => !o && setEditing(null)}
onSubmit={(patch) => editing && updateMut.mutate({ id: editing.id, patch })}
/>
<AlertDialog open={deleteTarget != null} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Xóa mẫu vĩnh viễn?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget ? (
<>
Xóa hẳn mẫu <span className="font-medium text-foreground">"{deleteTarget.name}"</span> tệp .docx
khỏi kho. Thao tác này không thể hoàn tác. Để tạm n thay xóa, hãy dùng nút hiệu hóa.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Hủy</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={() => deleteTarget && deleteMut.mutate(deleteTarget.id)}
>
Xóa vĩnh viễn
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function CreateTemplateDialog(props: {
open: boolean;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (v: { name: string; description?: string; file: File }) => void;
}) {
const { open, submitting, onOpenChange, onSubmit } = props;
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [file, setFile] = useState<File | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const reset = () => {
setName('');
setDescription('');
setFile(null);
if (fileRef.current) fileRef.current.value = '';
};
const submit = () => {
if (!name.trim()) return toast.error('Nhập tên mẫu.');
if (!file) return toast.error('Chọn tệp .docx.');
if (!file.name.toLowerCase().endsWith('.docx')) return toast.error('Chỉ chấp nhận tệp .docx (Word).');
onSubmit({ name: name.trim(), description: description.trim() || undefined, file });
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) reset();
onOpenChange(o);
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Tạo mẫu tài liệu mới</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="tpl-name">Tên mẫu</Label>
<Input id="tpl-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Đơn xin nghỉ phép" disabled={submitting} />
</div>
<div className="space-y-2">
<Label htmlFor="tpl-desc"> tả (tùy chọn)</Label>
<Textarea id="tpl-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Mục đích của mẫu này…" disabled={submitting} />
</div>
<div className="space-y-2">
<Label htmlFor="tpl-file">Tệp .docx (Word, chứa các trường {'{{ ten }}'})</Label>
<Input
id="tpl-file"
ref={fileRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={submitting}
/>
{file ? <p className="text-xs text-muted-foreground">Đã chọn: {file.name}</p> : null}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Hủy
</Button>
<Button type="button" onClick={submit} disabled={submitting}>
{submitting ? 'Đang tải lên…' : 'Tạo mẫu'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function EditTemplateDialog(props: {
template: DocumentTemplate | null;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (patch: { name?: string; description?: string }) => void;
}) {
const { template, submitting, onOpenChange, onSubmit } = props;
const [name, setName] = useState('');
const [description, setDescription] = useState('');
// Sync local state when a new template is opened.
const [syncedId, setSyncedId] = useState<string | null>(null);
if (template && template.id !== syncedId) {
setSyncedId(template.id);
setName(template.name);
setDescription(template.description ?? '');
}
return (
<Dialog open={template != null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sửa thông tin mẫu</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="tpl-edit-name">Tên mẫu</Label>
<Input id="tpl-edit-name" value={name} onChange={(e) => setName(e.target.value)} disabled={submitting} />
</div>
<div className="space-y-2">
<Label htmlFor="tpl-edit-desc"> tả</Label>
<Textarea id="tpl-edit-desc" value={description} onChange={(e) => setDescription(e.target.value)} disabled={submitting} />
</div>
{template ? (
<div className="space-y-1">
<Label>Các trường ({template.fields.length})</Label>
<div className="flex flex-wrap gap-1.5">
{template.fields.length === 0 ? (
<span className="text-xs text-muted-foreground">Không phát hiện trường {'{{ }}'} nào.</span>
) : (
template.fields.map((f) => (
<Badge key={f.key} variant="outline" className="font-mono text-xs">
{f.key}
</Badge>
))
)}
</div>
</div>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Hủy
</Button>
<Button
type="button"
onClick={() => {
if (!name.trim()) return toast.error('Tên mẫu không được để trống.');
onSubmit({ name: name.trim(), description: description.trim() });
}}
disabled={submitting}
>
{submitting ? 'Đang lưu…' : 'Lưu'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}