[CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s
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:
@ -28,4 +28,50 @@ public class FormsController(IMediator mediator) : ControllerBase
|
||||
var result = await mediator.Send(new RenderTemplateCommand(id, data), ct);
|
||||
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);
|
||||
|
||||
@ -94,3 +94,156 @@ public interface IWebHostEnvironmentLocator
|
||||
string WebRootPath { 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user