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/DocumentListPage.tsx b/frontend/src/features/legal-documents/components/DocumentListPage.tsx new file mode 100644 index 0000000..12627b4 --- /dev/null +++ b/frontend/src/features/legal-documents/components/DocumentListPage.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { Bell, Search } from "lucide-react"; +import { Sidebar } from "./Sidebar"; +import { FilterBar } from "./FilterBar"; +import { DocumentTable } from "./DocumentTable"; +import { MOCK_DOCUMENTS, LegalDocument } from "../constants/mockDocuments"; + +/** + * UC-6: Legal Document Management (Admin) page. + * Displays a searchable, filterable list of legal documents + * with an expandable version history per row. + */ +export function DocumentListPage() { + const [documents, setDocuments] = useState(MOCK_DOCUMENTS); + const [search, setSearch] = useState(""); + + const filtered = documents.filter( + (d) => + d.tenVanBan.toLowerCase().includes(search.toLowerCase()) || + d.soHieu.toLowerCase().includes(search.toLowerCase()) + ); + + const handleToggle = (id: number) => { + setDocuments((prev) => + prev.map((d) => (d.id === id ? { ...d, active: !d.active } : d)) + ); + }; + + return ( +
+ {/* Left sidebar */} + + + {/* Right content */} +
+ {/* Top bar */} +
+
+ + +
+
+ +
+
+ + {/* Page heading */} +

+ Quản lý văn bản pháp luật +

+

+ Cập nhật và điều chỉnh các văn bản pháp quy trong hệ thống. +

+ + {/* White card */} +
+
+ +
+ + {/* Document table */} + +
+
+
+ ); +} diff --git a/frontend/src/features/legal-documents/components/DocumentTable.tsx b/frontend/src/features/legal-documents/components/DocumentTable.tsx new file mode 100644 index 0000000..f2d8fd0 --- /dev/null +++ b/frontend/src/features/legal-documents/components/DocumentTable.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { Eye, Pencil, ChevronDown, ChevronUp } from "lucide-react"; +import { LegalDocument } from "../constants/mockDocuments"; +import { VersionHistoryPanel } from "./VersionHistoryPanel"; + +// Inline toggle — smaller knob, muted blue ON color +function InlineToggle({ checked, onChange }: { checked: boolean; onChange: () => void }) { + return ( + + ); +} + +const thStyle: React.CSSProperties = { + fontSize: "11px", + fontWeight: 600, + letterSpacing: "0.5px", + textTransform: "uppercase", + color: "#6b7280", + padding: "10px 16px", + textAlign: "left", + background: "#f9fafb", + borderBottom: "1px solid #e5e7eb", + whiteSpace: "nowrap", +}; + +const tdStyle: React.CSSProperties = { + padding: "11px 16px", + borderBottom: "1px solid #f3f4f6", + verticalAlign: "middle", +}; + +interface DocumentTableProps { + documents: LegalDocument[]; + onToggle: (id: number) => void; +} + +export function DocumentTable({ documents, onToggle }: DocumentTableProps) { + const [expandedId, setExpandedId] = useState(null); + + const toggleExpand = (id: number) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + return ( +
+ + + + + + + + + + + + + {documents.map((doc) => ( + <> + + {/* Tên văn bản */} + + + {/* Số hiệu */} + + + {/* Ngày hiệu lực */} + + + {/* Version badge — clickable to expand */} + + + {/* Trạng thái */} + + + {/* Hành động */} + + + + {expandedId === doc.id && ( + + )} + + ))} + +
TÊN VĂN BẢNSỐ HIỆUNGÀY HIỆU LỰCVERSIONTRẠNG THÁIHÀNH ĐỘNG
+

+ {doc.tenVanBan} +

+

+ {doc.subDescription} +

+
+ {doc.soHieu} + + {doc.ngayHieuLuc} + + + + + {doc.trangThai} + + +
+ + + onToggle(doc.id)} /> +
+
+
+ ); +} 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/FilterBar.tsx b/frontend/src/features/legal-documents/components/FilterBar.tsx new file mode 100644 index 0000000..3183eb7 --- /dev/null +++ b/frontend/src/features/legal-documents/components/FilterBar.tsx @@ -0,0 +1,82 @@ +import { Search, ChevronDown, Calendar, Plus } from "lucide-react"; + +interface FilterBarProps { + search: string; + onSearchChange: (v: string) => void; +} + +export function FilterBar({ search, onSearchChange }: FilterBarProps) { + const controlStyle: React.CSSProperties = { + height: "34px", + border: "1px solid #e5e7eb", + borderRadius: "6px", + fontSize: "13px", + color: "#374151", + background: "#fff", + outline: "none", + boxSizing: "border-box", + }; + + return ( +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + style={{ ...controlStyle, width: "100%", paddingLeft: "30px", paddingRight: "10px" }} + /> +
+ + {/* Trạng thái */} +
+ + +
+ + {/* Ngày ban hành */} +
+ + +
+ + {/* + Thêm mới */} + +
+ ); +} 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 ? ( +
+ ) : ( +