[CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s

Admin giờ có thể quản lý template HĐ hoàn toàn qua UI — không cần dev
đụng vào file system hay seed data.

BE (FormFeatures.cs + FormsController.cs):
- UploadContractTemplateCommand (multipart): validate FormCode
  (regex [A-Za-z0-9._-]+, unique), file <= 10MB, ext .docx/.xlsx,
  FieldSpec phải là JSON hợp lệ hoặc null. Ghi file vào
  wwwroot/templates/{formCode}_{guid:N}.{ext} để tránh collision
  + path traversal.
- UpdateContractTemplateCommand: sửa metadata + FieldSpec + IsActive
  (không đụng file — chỉ DB).
- DeleteContractTemplateCommand: soft delete qua IsActive=false
  (historical contracts ref template này vẫn resolve).
- Endpoints: POST /api/forms/templates (multipart),
  PUT /api/forms/templates/{id}, DELETE /api/forms/templates/{id}.
  RequestSizeLimit 12MB (validator caps 10MB).

FE (FormsPage.tsx admin):
- PageHeader action button "Upload template" mở dialog mới
- Row actions: Download (render existing), Pencil (edit), Trash (xóa
  confirm) thay vì chỉ có 1 nút Render — row hover reveals clearly
- Upload dialog: file picker với file: pseudo-element brand styled,
  FormCode (required, font-mono), Tên, Loại HĐ select, Mô tả,
  FieldSpec JSON textarea với placeholder example
- Edit dialog: same fields minus file (FormCode disabled, edit chỉ
  cập nhật metadata), có checkbox Kích hoạt
- Shared form submit handler — same dialog cho upload (__new) + edit

Foundation sẵn cho form builder thật (render UI từ FieldSpec JSON
đang là text field — iteration sau sẽ parse + render form dynamic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 21:15:35 +07:00
parent c52186bed0
commit 166d26c1d8
3 changed files with 515 additions and 25 deletions

View File

@ -1,6 +1,16 @@
import { useState } from 'react' import { useRef, useState, type FormEvent } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Download, FileSpreadsheet, FileText, CheckCircle2, XCircle } from 'lucide-react' import {
Download,
FileSpreadsheet,
FileText,
CheckCircle2,
XCircle,
Upload,
Pencil,
Trash2,
Plus,
} from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { DataTable, type Column } from '@/components/DataTable' import { DataTable, type Column } from '@/components/DataTable'
@ -8,13 +18,33 @@ import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog' import { Dialog } from '@/components/ui/Dialog'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
import { Select } from '@/components/ui/Select'
import { Textarea } from '@/components/ui/Textarea' import { Textarea } from '@/components/ui/Textarea'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError' import { getErrorMessage } from '@/lib/apiError'
import { type ContractTemplate, ContractTypeLabel } from '@/types/forms' import { type ContractTemplate, ContractTypeLabel } from '@/types/forms'
type EditState = ContractTemplate & { __new?: boolean }
const EMPTY_EDIT: EditState = {
id: '',
formCode: '',
name: '',
contractType: null,
fileName: '',
format: 'docx',
fieldSpec: null,
description: null,
isActive: true,
__new: true,
}
export function FormsPage() { export function FormsPage() {
const [dialog, setDialog] = useState<ContractTemplate | null>(null) const qc = useQueryClient()
const [renderDialog, setRenderDialog] = useState<ContractTemplate | null>(null)
const [edit, setEdit] = useState<EditState | null>(null)
const [uploadFile, setUploadFile] = useState<File | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [dataJson, setDataJson] = useState<string>(`{ const [dataJson, setDataJson] = useState<string>(`{
"benA_tenCongTy": "Công ty TNHH Xây dựng Solutions", "benA_tenCongTy": "Công ty TNHH Xây dựng Solutions",
"giaTri": "150,000,000 VND", "giaTri": "150,000,000 VND",
@ -29,7 +59,10 @@ export function FormsPage() {
const render = useMutation({ const render = useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => { mutationFn: async ({ id, data }: { id: string; data: Record<string, string | null> }) => {
const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' }) const res = await api.post(`/forms/templates/${id}/render`, data, { responseType: 'blob' })
return { blob: res.data as Blob, filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx' } return {
blob: res.data as Blob,
filename: res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? 'render.docx',
}
}, },
onSuccess: ({ blob, filename }) => { onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@ -39,13 +72,59 @@ export function FormsPage() {
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
toast.success('Đã tải file render') toast.success('Đã tải file render')
setDialog(null) setRenderDialog(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const upload = useMutation({
mutationFn: async (params: { file: File; meta: EditState }) => {
const form = new FormData()
form.append('file', params.file)
form.append('formCode', params.meta.formCode)
form.append('name', params.meta.name)
if (params.meta.contractType !== null) form.append('contractType', String(params.meta.contractType))
if (params.meta.description) form.append('description', params.meta.description)
if (params.meta.fieldSpec) form.append('fieldSpec', params.meta.fieldSpec)
await api.post('/forms/templates', form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-templates'] })
toast.success('Đã upload template mới')
setEdit(null)
setUploadFile(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const update = useMutation({
mutationFn: async (meta: EditState) =>
api.put(`/forms/templates/${meta.id}`, {
name: meta.name,
contractType: meta.contractType,
description: meta.description,
fieldSpec: meta.fieldSpec,
isActive: meta.isActive,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-templates'] })
toast.success('Đã cập nhật template')
setEdit(null)
},
onError: err => toast.error(getErrorMessage(err)),
})
const del = useMutation({
mutationFn: async (id: string) => api.delete(`/forms/templates/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contract-templates'] })
toast.success('Đã vô hiệu hóa template')
}, },
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
function handleRender() { function handleRender() {
if (!dialog) return if (!renderDialog) return
let data: Record<string, string | null> let data: Record<string, string | null>
try { try {
data = JSON.parse(dataJson) data = JSON.parse(dataJson)
@ -53,7 +132,21 @@ export function FormsPage() {
toast.error('JSON không hợp lệ') toast.error('JSON không hợp lệ')
return return
} }
render.mutate({ id: dialog.id, data }) render.mutate({ id: renderDialog.id, data })
}
function handleSaveEdit(e: FormEvent) {
e.preventDefault()
if (!edit) return
if (edit.__new) {
if (!uploadFile) {
toast.error('Chưa chọn file template .docx/.xlsx')
return
}
upload.mutate({ file: uploadFile, meta: edit })
} else {
update.mutate(edit)
}
} }
const columns: Column<ContractTemplate>[] = [ const columns: Column<ContractTemplate>[] = [
@ -63,11 +156,20 @@ export function FormsPage() {
width: 'w-12', width: 'w-12',
align: 'center', align: 'center',
render: t => render: t =>
t.format === 'xlsx' ? <FileSpreadsheet className="mx-auto h-4 w-4 text-emerald-600" /> : <FileText className="mx-auto h-4 w-4 text-blue-600" />, t.format === 'xlsx' ? (
<FileSpreadsheet className="mx-auto h-4 w-4 text-emerald-600" />
) : (
<FileText className="mx-auto h-4 w-4 text-brand-600" />
),
}, },
{ key: 'formCode', header: 'Form Code', render: t => <span className="font-mono text-xs">{t.formCode}</span>, width: 'w-40' }, { key: 'formCode', header: 'Form Code', render: t => <span className="font-mono text-xs">{t.formCode}</span>, width: 'w-40' },
{ key: 'name', header: 'Tên', render: t => t.name }, { key: 'name', header: 'Tên', render: t => t.name },
{ key: 'contractType', header: 'Loại HĐ', render: t => (t.contractType ? ContractTypeLabel[t.contractType] : '—'), width: 'w-40' }, {
key: 'contractType',
header: 'Loại HĐ',
render: t => (t.contractType ? ContractTypeLabel[t.contractType] : '—'),
width: 'w-40',
},
{ {
key: 'isActive', key: 'isActive',
header: 'Trạng thái', header: 'Trạng thái',
@ -79,7 +181,7 @@ export function FormsPage() {
) : ( ) : (
<span className="inline-flex items-center gap-1 text-xs text-amber-600"> <span className="inline-flex items-center gap-1 text-xs text-amber-600">
<XCircle className="h-3.5 w-3.5" /> <XCircle className="h-3.5 w-3.5" />
Chưa active Tắt
</span> </span>
), ),
}, },
@ -87,12 +189,43 @@ export function FormsPage() {
key: 'actions', key: 'actions',
header: '', header: '',
align: 'right', align: 'right',
width: 'w-32', width: 'w-56',
render: t => ( render: t => (
<Button size="sm" variant="outline" disabled={!t.isActive} onClick={() => setDialog(t)}> <div className="flex justify-end gap-1">
<Download className="h-3.5 w-3.5" /> <button
Render onClick={e => {
</Button> e.stopPropagation()
setRenderDialog(t)
}}
disabled={!t.isActive}
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-40"
title="Render"
>
<Download className="h-3.5 w-3.5" />
</button>
<button
onClick={e => {
e.stopPropagation()
setEdit({ ...t })
}}
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="Chỉnh sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
{t.isActive && (
<button
onClick={e => {
e.stopPropagation()
if (window.confirm(`Vô hiệu hóa template "${t.name}"?`)) del.mutate(t.id)
}}
className="flex h-7 w-7 items-center justify-center rounded text-slate-500 hover:bg-red-50 hover:text-red-600"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
), ),
}, },
] ]
@ -101,19 +234,26 @@ export function FormsPage() {
<div className="p-6"> <div className="p-6">
<PageHeader <PageHeader
title="Biểu mẫu hợp đồng" title="Biểu mẫu hợp đồng"
description="Danh sách template HĐ. Click 'Render' để test điền field {{key}} và tải file .docx/.xlsx." description="Template HĐ hệ thống. Upload mới .docx/.xlsx, edit FieldSpec JSON, render để tải file điền dữ liệu."
actions={
<Button onClick={() => setEdit(EMPTY_EDIT)}>
<Plus className="mr-1 h-4 w-4" />
Upload template
</Button>
}
/> />
<DataTable columns={columns} rows={list.data ?? []} getRowKey={t => t.id} isLoading={list.isLoading} /> <DataTable columns={columns} rows={list.data ?? []} getRowKey={t => t.id} isLoading={list.isLoading} />
{/* Render dialog */}
<Dialog <Dialog
open={!!dialog} open={!!renderDialog}
onClose={() => setDialog(null)} onClose={() => setRenderDialog(null)}
title={dialog ? `Render: ${dialog.name}` : ''} title={renderDialog ? `Render: ${renderDialog.name}` : ''}
size="lg" size="lg"
footer={ footer={
<> <>
<Button variant="outline" onClick={() => setDialog(null)}> <Button variant="outline" onClick={() => setRenderDialog(null)}>
Hủy Hủy
</Button> </Button>
<Button onClick={handleRender} disabled={render.isPending}> <Button onClick={handleRender} disabled={render.isPending}>
@ -124,19 +264,170 @@ export function FormsPage() {
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800"> <div className="rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800">
<strong>Hướng dẫn:</strong> Template chứa placeholder dạng <code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON <strong>Hướng dẫn:</strong> Template chứa placeholder dạng{' '}
dưới đây, backend sẽ replace placeholder trong file gốc. <code className="rounded bg-white px-1">{'{{fieldName}}'}</code>. Điền key-value JSON dưới đây, backend sẽ
replace placeholder trong file gốc.
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Form Code</Label> <Label>Form Code</Label>
<Input value={dialog?.formCode ?? ''} disabled /> <Input value={renderDialog?.formCode ?? ''} disabled />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Data JSON (placeholder value)</Label> <Label>Data JSON (placeholder value)</Label>
<Textarea rows={10} value={dataJson} onChange={e => setDataJson(e.target.value)} className="font-mono text-xs" /> <Textarea
rows={10}
value={dataJson}
onChange={e => setDataJson(e.target.value)}
className="font-mono text-xs"
/>
</div> </div>
</div> </div>
</Dialog> </Dialog>
{/* Upload / Edit dialog */}
<Dialog
open={!!edit}
onClose={() => {
setEdit(null)
setUploadFile(null)
}}
title={edit?.__new ? 'Upload template mới' : `Chỉnh sửa: ${edit?.name ?? ''}`}
size="lg"
footer={
<>
<Button
variant="outline"
onClick={() => {
setEdit(null)
setUploadFile(null)
}}
>
Hủy
</Button>
<Button
onClick={handleSaveEdit}
disabled={upload.isPending || update.isPending}
type="submit"
form="template-edit-form"
>
{upload.isPending || update.isPending
? 'Đang lưu…'
: edit?.__new
? 'Upload'
: 'Lưu thay đổi'}
</Button>
</>
}
>
{edit && (
<form id="template-edit-form" onSubmit={handleSaveEdit} className="space-y-4">
{edit.__new && (
<div className="space-y-1.5">
<Label htmlFor="file">File template (.docx / .xlsx)</Label>
<input
id="file"
ref={fileInputRef}
type="file"
accept=".docx,.xlsx"
onChange={e => setUploadFile(e.target.files?.[0] ?? null)}
className="block w-full rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm file:mr-3 file:rounded file:border-0 file:bg-brand-50 file:px-3 file:py-1 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
/>
{uploadFile && (
<div className="text-xs text-slate-500">
{uploadFile.name} · {(uploadFile.size / 1024).toFixed(1)} KB
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="formCode">Form Code *</Label>
<Input
id="formCode"
value={edit.formCode}
onChange={e => setEdit({ ...edit, formCode: e.target.value })}
disabled={!edit.__new}
placeholder="SOL-CCM-FO-XXX"
className="font-mono"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="contractType">Loại </Label>
<Select
id="contractType"
value={edit.contractType ?? ''}
onChange={e =>
setEdit({ ...edit, contractType: e.target.value === '' ? null : Number(e.target.value) })
}
>
<option value=""> (phụ lục / điều kiện chung)</option>
{Object.entries(ContractTypeLabel).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Tên template *</Label>
<Input
id="name"
value={edit.name}
onChange={e => setEdit({ ...edit, name: e.target.value })}
placeholder="VD: Hợp đồng Giao khoán"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="description"> tả</Label>
<Textarea
id="description"
rows={2}
value={edit.description ?? ''}
onChange={e => setEdit({ ...edit, description: e.target.value || null })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fieldSpec">FieldSpec JSON (optional)</Label>
<Textarea
id="fieldSpec"
rows={8}
value={edit.fieldSpec ?? ''}
onChange={e => setEdit({ ...edit, fieldSpec: e.target.value || null })}
placeholder={`{\n "benA_tenCongTy": { "label": "Tên Cty bên A", "type": "text", "required": true },\n "giaTri": { "label": "Giá trị", "type": "currency" }\n}`}
className="font-mono text-xs"
/>
<div className="text-xs text-slate-400">
tả các field trong template. Future iteration sẽ render form builder từ spec này.
</div>
</div>
{!edit.__new && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={edit.isActive}
onChange={e => setEdit({ ...edit, isActive: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
<span>Kích hoạt (hiển thị khi tạo )</span>
</label>
)}
</form>
)}
</Dialog>
<div className="mt-4 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
<Upload className="h-3.5 w-3.5" />
File template lưu tại <code className="rounded bg-white px-1">wwwroot/templates/</code>, placeholder pháp{' '}
<code className="rounded bg-white px-1">{'{{fieldKey}}'}</code>.
</div>
</div> </div>
) )
} }

View File

@ -28,4 +28,50 @@ public class FormsController(IMediator mediator) : ControllerBase
var result = await mediator.Send(new RenderTemplateCommand(id, data), ct); var result = await mediator.Send(new RenderTemplateCommand(id, data), ct);
return File(result.Content, result.ContentType, result.FileName); return File(result.Content, result.ContentType, result.FileName);
} }
// ==================== Admin CRUD ====================
// Upload new template — multipart form with file + metadata fields
[HttpPost("templates")]
[RequestSizeLimit(12_000_000)] // ~12 MB (validator caps at 10 MB)
public async Task<ActionResult<ContractTemplateDto>> Upload(
IFormFile file,
[FromForm] string formCode,
[FromForm] string name,
[FromForm] ContractType? contractType = null,
[FromForm] string? description = null,
[FromForm] string? fieldSpec = null,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
return BadRequest(new { detail = "Chưa chọn file template." });
await using var stream = file.OpenReadStream();
var dto = await mediator.Send(new UploadContractTemplateCommand(
formCode, name, contractType, description, fieldSpec,
file.FileName, file.ContentType, file.Length, stream), ct);
return Ok(dto);
}
[HttpPut("templates/{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractTemplateBody body, CancellationToken ct)
{
await mediator.Send(new UpdateContractTemplateCommand(
id, body.Name, body.ContractType, body.Description, body.FieldSpec, body.IsActive), ct);
return NoContent();
}
[HttpDelete("templates/{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractTemplateCommand(id), ct);
return NoContent();
}
} }
public record UpdateContractTemplateBody(
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
bool IsActive);

View File

@ -94,3 +94,156 @@ public interface IWebHostEnvironmentLocator
string WebRootPath { get; } string WebRootPath { get; }
string ContentRootPath { get; } string ContentRootPath { get; }
} }
// ========== UPLOAD new template ==========
public record UploadContractTemplateCommand(
string FormCode,
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
string FileName,
string ContentType,
long FileSize,
Stream Content) : IRequest<ContractTemplateDto>;
public class UploadContractTemplateCommandValidator : AbstractValidator<UploadContractTemplateCommand>
{
private const long MaxBytes = 10L * 1024 * 1024;
private static readonly HashSet<string> AllowedFormats = new(StringComparer.OrdinalIgnoreCase) { ".docx", ".xlsx" };
public UploadContractTemplateCommandValidator()
{
RuleFor(x => x.FormCode).NotEmpty().MaximumLength(100)
.Matches("^[A-Za-z0-9._-]+$")
.WithMessage("FormCode chỉ dùng chữ, số, và các ký tự . _ -");
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
.WithMessage("Template tối đa 10 MB.");
RuleFor(x => x.FileName).NotEmpty()
.Must(name => AllowedFormats.Contains(Path.GetExtension(name)))
.WithMessage("Chỉ chấp nhận file .docx hoặc .xlsx.");
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
}
private static bool BeValidJsonOrNull(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return true;
try { System.Text.Json.JsonDocument.Parse(json); return true; }
catch { return false; }
}
}
public class UploadContractTemplateCommandHandler(
IApplicationDbContext db,
IWebHostEnvironmentLocator env) : IRequestHandler<UploadContractTemplateCommand, ContractTemplateDto>
{
public async Task<ContractTemplateDto> Handle(UploadContractTemplateCommand request, CancellationToken ct)
{
// Enforce unique FormCode — admin-friendly error via ConflictException
if (await db.ContractTemplates.AnyAsync(t => t.FormCode == request.FormCode, ct))
throw new ConflictException($"FormCode '{request.FormCode}' đã tồn tại.");
var ext = Path.GetExtension(request.FileName).TrimStart('.').ToLowerInvariant();
var id = Guid.NewGuid();
// Store using a deterministic name — FormCode avoids path-traversal
// (already regex-validated above) and disambiguates files.
var safeFile = $"{request.FormCode}_{id:N}.{ext}";
var templatesDir = Path.Combine(env.WebRootPath, "templates");
Directory.CreateDirectory(templatesDir);
var absPath = Path.Combine(templatesDir, safeFile);
await using (var fs = File.Create(absPath))
await request.Content.CopyToAsync(fs, ct);
var entity = new Domain.Forms.ContractTemplate
{
Id = id,
FormCode = request.FormCode,
Name = request.Name,
ContractType = request.ContractType,
FileName = request.FileName,
StoragePath = $"templates/{safeFile}",
Format = ext,
FieldSpec = request.FieldSpec,
Description = request.Description,
IsActive = true,
};
db.ContractTemplates.Add(entity);
await db.SaveChangesAsync(ct);
return new ContractTemplateDto(
entity.Id, entity.FormCode, entity.Name, entity.ContractType,
entity.FileName, entity.Format, entity.FieldSpec, entity.Description, entity.IsActive);
}
}
// ========== UPDATE metadata + FieldSpec ==========
public record UpdateContractTemplateCommand(
Guid Id,
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
bool IsActive) : IRequest;
public class UpdateContractTemplateCommandValidator : AbstractValidator<UpdateContractTemplateCommand>
{
public UpdateContractTemplateCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
}
private static bool BeValidJsonOrNull(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return true;
try { System.Text.Json.JsonDocument.Parse(json); return true; }
catch { return false; }
}
}
public class UpdateContractTemplateCommandHandler(IApplicationDbContext db)
: IRequestHandler<UpdateContractTemplateCommand>
{
public async Task Handle(UpdateContractTemplateCommand request, CancellationToken ct)
{
var entity = await db.ContractTemplates.FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException("ContractTemplate", request.Id);
entity.Name = request.Name;
entity.ContractType = request.ContractType;
entity.Description = request.Description;
entity.FieldSpec = request.FieldSpec;
entity.IsActive = request.IsActive;
await db.SaveChangesAsync(ct);
}
}
// ========== DELETE (soft via IsActive=false) ==========
public record DeleteContractTemplateCommand(Guid Id) : IRequest;
public class DeleteContractTemplateCommandHandler(IApplicationDbContext db)
: IRequestHandler<DeleteContractTemplateCommand>
{
public async Task Handle(DeleteContractTemplateCommand request, CancellationToken ct)
{
var entity = await db.ContractTemplates.FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException("ContractTemplate", request.Id);
// Soft delete — AuditableEntity query filter already handles IsDeleted.
// But simpler MVP: just flip IsActive so historical contracts referencing
// this template can still resolve it.
entity.IsActive = false;
await db.SaveChangesAsync(ct);
}
}