405 lines
15 KiB
TypeScript
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 lý đượ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]">Mô 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 có 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> và tệp .docx
|
|
khỏi kho. Thao tác này không thể hoàn tác. Để tạm ẩn thay vì xóa, hãy dùng nút vô 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">Mô 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">Mô 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>
|
|
);
|
|
}
|