sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,404 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user