From ea7e0dbf3c61017ce472da661bc13049f23c3a71 Mon Sep 17 00:00:00 2001 From: fuongchuche Date: Mon, 4 May 2026 11:07:00 +0700 Subject: [PATCH 1/2] UC-5-Sprint_3: Add legal document --- .../components/AddDocumentForm.tsx | 405 ++++++++++++++++++ .../components/AddDocumentPage.tsx | 128 ++++++ .../components/DuplicateWarningBanner.tsx | 42 ++ .../legal-documents/components/FormField.tsx | 56 +++ .../components/MarkdownTextarea.tsx | 130 ++++++ .../legal-documents/components/Sidebar.tsx | 104 +++++ .../legal-documents/components/Toast.tsx | 108 +++++ .../components/ToggleSwitch.tsx | 30 ++ .../constants/mockDuplicates.ts | 7 + .../legal-documents/hooks/useDocumentForm.ts | 71 +++ .../legal-documents/hooks/useToast.ts | 38 ++ .../legal-documents/schema/document.schema.ts | 29 ++ .../legal-documents/types/document.types.ts | 17 + 13 files changed, 1165 insertions(+) create mode 100644 frontend/src/features/legal-documents/components/AddDocumentForm.tsx create mode 100644 frontend/src/features/legal-documents/components/AddDocumentPage.tsx create mode 100644 frontend/src/features/legal-documents/components/DuplicateWarningBanner.tsx create mode 100644 frontend/src/features/legal-documents/components/FormField.tsx create mode 100644 frontend/src/features/legal-documents/components/MarkdownTextarea.tsx create mode 100644 frontend/src/features/legal-documents/components/Sidebar.tsx create mode 100644 frontend/src/features/legal-documents/components/Toast.tsx create mode 100644 frontend/src/features/legal-documents/components/ToggleSwitch.tsx create mode 100644 frontend/src/features/legal-documents/constants/mockDuplicates.ts create mode 100644 frontend/src/features/legal-documents/hooks/useDocumentForm.ts create mode 100644 frontend/src/features/legal-documents/hooks/useToast.ts create mode 100644 frontend/src/features/legal-documents/schema/document.schema.ts create mode 100644 frontend/src/features/legal-documents/types/document.types.ts diff --git a/frontend/src/features/legal-documents/components/AddDocumentForm.tsx b/frontend/src/features/legal-documents/components/AddDocumentForm.tsx new file mode 100644 index 0000000..2943f71 --- /dev/null +++ b/frontend/src/features/legal-documents/components/AddDocumentForm.tsx @@ -0,0 +1,405 @@ +import { useRef } from "react"; +import { Controller } from "react-hook-form"; +import { Info, Calendar, Link, ArrowRight } from "lucide-react"; +import { useDocumentForm } from "../hooks/useDocumentForm"; +import { FormField } from "./FormField"; +import { ToggleSwitch } from "./ToggleSwitch"; +import { DuplicateWarningBanner } from "./DuplicateWarningBanner"; +import { MarkdownTextarea } from "./MarkdownTextarea"; + +// Common style for all text inputs +const inputStyle = (hasError?: boolean, isWarning?: boolean): React.CSSProperties => ({ + height: "38px", + border: `1px solid ${hasError ? "#dc2626" : isWarning ? "#d97706" : "#d1d5db"}`, + borderRadius: "8px", + padding: "0 12px", + fontSize: "13px", + color: "#111827", + background: hasError ? "#fff8f8" : isWarning ? "#fffbf0" : "#ffffff", + width: "100%", + boxSizing: "border-box" as const, + outline: "none", + fontFamily: "inherit", + transition: "border-color 0.15s, box-shadow 0.15s, background 0.15s", +}); + +const focusHandlers = { + onFocus: (e: React.FocusEvent) => { + e.currentTarget.style.boxShadow = "0 0 0 3px rgba(79,70,229,0.12)"; + e.currentTarget.style.borderColor = "#6366f1"; + }, + onBlur: (e: React.FocusEvent) => { + e.currentTarget.style.boxShadow = "none"; + e.currentTarget.style.borderColor = "#d1d5db"; + }, +}; + +// Tooltip for the document number info icon +function InfoTooltip() { + return ( + + + + Định dạng: số/năm/cơ quan. Ví dụ: 31/2024/QH15 + + + ); +} + +interface AddDocumentFormProps { + onSuccess: () => void; +} + +export function AddDocumentForm({ onSuccess }: AddDocumentFormProps) { + const { + form, + isDuplicate, + isSubmitting, + checkDuplicate, + clearDuplicate, + onSubmit, + handleCancel, + } = useDocumentForm(onSuccess); + + const { + register, + handleSubmit, + control, + formState: { errors }, + } = form; + + // Ref for scroll-to-first-error + const firstErrorRef = useRef(null); + + const handleFormSubmit = handleSubmit( + (data) => onSubmit(data), + () => { + // Scroll to first invalid field + const firstError = document.querySelector("[aria-invalid='true'], [data-error='true']"); + if (firstError) { + firstError.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + ); + + return ( +
+ {/* ── Field 1: Tên văn bản ── */} +
+ + + +
+ + {/* ── Row 2: Số hiệu + Trạng thái ── */} +
+ {/* Số hiệu văn bản */} + + SỐ HIỆU VĂN BẢN + * + + + } + error={errors.soHieuVanBan?.message} + > + checkDuplicate(e.target.value), + })} + onChange={(e) => { + register("soHieuVanBan").onChange(e); + clearDuplicate(); + }} + style={inputStyle(!!errors.soHieuVanBan, isDuplicate)} + onFocus={(e) => { + e.currentTarget.style.boxShadow = "0 0 0 3px rgba(79,70,229,0.12)"; + e.currentTarget.style.borderColor = "#6366f1"; + }} + /> + {isDuplicate && } + + + {/* Trạng thái hiệu lực */} + + ( + + )} + /> + +
+ + {/* ── Row 3: Ngày ban hành + Ngày hiệu lực ── */} +
+ {/* Ngày ban hành */} + +
+ + +
+
+ + {/* Ngày hiệu lực */} + +
+ + +
+
+
+ + {/* ── Field 4: Nguồn văn bản (URL) ── */} + +
+ + +
+
+ + {/* ── Field 5: Nội dung văn bản ── */} +
+ + + ( + + )} + /> +
+ + {/* ── Footer ── */} +
+ {/* Cancel */} + + + {/* Submit */} + +
+ + {/* Keyframe for spinner */} + +
+ ); +} diff --git a/frontend/src/features/legal-documents/components/AddDocumentPage.tsx b/frontend/src/features/legal-documents/components/AddDocumentPage.tsx new file mode 100644 index 0000000..e9e6ff2 --- /dev/null +++ b/frontend/src/features/legal-documents/components/AddDocumentPage.tsx @@ -0,0 +1,128 @@ +import { Bell, User, Search } from "lucide-react"; +import { Sidebar } from "./Sidebar"; +import { AddDocumentForm } from "./AddDocumentForm"; +import { Toast } from "./Toast"; +import { useToast } from "../hooks/useToast"; + +/** + * Full-page layout: sidebar (left) + content area (right). + * Occupies the full viewport height. + */ +export function AddDocumentPage() { + const toast = useToast(); + + return ( +
+ {/* Left sidebar */} + + + {/* Right content */} +
+ {/* Top bar */} +
+ {/* Search */} +
+ + +
+ + {/* Right icons */} +
+ + +
+
+ + {/* Form card */} +
+ {/* Card header */} +

+ Thêm văn bản pháp luật +

+

+ Nhập thông tin văn bản để phục vụ tra cứu và AI phân tích +

+ + +
+
+ + {/* Toast notification */} + +
+ ); +} diff --git a/frontend/src/features/legal-documents/components/DuplicateWarningBanner.tsx b/frontend/src/features/legal-documents/components/DuplicateWarningBanner.tsx new file mode 100644 index 0000000..dc76aa5 --- /dev/null +++ b/frontend/src/features/legal-documents/components/DuplicateWarningBanner.tsx @@ -0,0 +1,42 @@ +import { AlertTriangle } from "lucide-react"; + +/** + * ⭐ Primary deliverable for this sprint. + * Soft warning shown when the entered document number matches an existing one. + * Does NOT block form submission — purely informational. + */ +export function DuplicateWarningBanner() { + return ( +
+ {/* Warning icon */} + + {/* Warning text */} + + Số hiệu này có thể đã tồn tại trong bản nháp. + +
+ ); +} diff --git a/frontend/src/features/legal-documents/components/FormField.tsx b/frontend/src/features/legal-documents/components/FormField.tsx new file mode 100644 index 0000000..ad5e606 --- /dev/null +++ b/frontend/src/features/legal-documents/components/FormField.tsx @@ -0,0 +1,56 @@ +import React from "react"; + +interface FormFieldProps { + id: string; + label: React.ReactNode; + error?: string; + children: React.ReactNode; + className?: string; +} + +/** + * Reusable label + input slot + error message wrapper. + * Renders any input/select/custom control passed as children. + */ +export function FormField({ + id, + label, + error, + children, + className, +}: FormFieldProps) { + return ( +
+ + {children} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/frontend/src/features/legal-documents/components/MarkdownTextarea.tsx b/frontend/src/features/legal-documents/components/MarkdownTextarea.tsx new file mode 100644 index 0000000..5018afd --- /dev/null +++ b/frontend/src/features/legal-documents/components/MarkdownTextarea.tsx @@ -0,0 +1,130 @@ +import { useState, useRef } from "react"; +import { Eye, Maximize2 } from "lucide-react"; + +interface MarkdownTextareaProps { + id: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +/** + * Textarea with a preview toggle that renders Markdown as HTML. + * Uses the marked library loaded as an ES module from CDN via dynamic import. + */ +export function MarkdownTextarea({ + id, + value, + onChange, + placeholder, +}: MarkdownTextareaProps) { + const [isPreview, setIsPreview] = useState(false); + const [previewHtml, setPreviewHtml] = useState(""); + const textareaRef = useRef(null); + + const inputStyle: React.CSSProperties = { + minHeight: "160px", + resize: "vertical", + border: "1px solid #d1d5db", + borderRadius: "8px", + padding: "10px 12px", + fontSize: "13px", + color: "#111827", + fontFamily: "inherit", + width: "100%", + boxSizing: "border-box", + outline: "none", + transition: "border-color 0.15s, box-shadow 0.15s", + }; + + const handleTogglePreview = async () => { + if (!isPreview) { + // Dynamically import marked to parse markdown + try { + const { marked } = await import("marked"); + setPreviewHtml(marked(value || "") as string); + } catch { + setPreviewHtml(`
${value}
`); + } + } + setIsPreview((prev) => !prev); + }; + + return ( +
+ {isPreview ? ( +
+ ) : ( +