[CLAUDE] FE-User+FE-Admin: 2-panel layout cho Thao tác (Ct_*_Create)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m36s

Trang /contracts/new?type=X (menu "Thao tác") redesign từ single form
→ 2-panel: Panel 1 list HĐ theo type | Panel 2 Header form + Chi tiết.

## Layout

Panel 1 (320px) — flex column 3 vùng:
- Top: Search box (filter mã/tên/NCC client-side)
- Middle: List HĐ theo type (scroll, click row chọn)
- Bottom: + Thêm mới button (sticky, ring-brand khi active mode=new)

Panel 2 (flex) — 3 trạng thái theo URL:
- Empty state — chưa chọn HĐ và chưa bấm + Thêm mới
- ContractHeaderForm (mode=new) — form trống, sau Tạo HĐ draft
  → URL update ?id=newId chuyển edit mode
- ContractEditForm (id=abc) — form populated từ /contracts/{id}, +
  section Chi tiết bên dưới (ContractDetailsTab reuse)

## URL state

- ?type=X            → empty
- ?type=X&mode=new   → form trống
- ?type=X&id=abc     → edit form + Chi tiết
- ?type=X&q=keyword  → search filter Panel 1

## Edit constraints

ContractEditForm respect UpdateContractDraftCommand limits:
- Editable khi Phase=DangSoanThao: Tên HĐ, Giá trị, Template, Nội dung
- Read-only luôn: Loại HĐ, NCC, Dự án, Bypass CCM (không đổi sau create
  qua BE command hiện tại)
- Khi Phase != DangSoanThao: warning amber + tất cả input disabled,
  nhưng Chi tiết section vẫn render để user xem (ContractDetailsTab tự
  disable add/delete khi không phải draft)

## Components

ContractCreatePage.tsx (rewrite) — page entry
ContractHeaderForm — create mode (full fields editable)
ContractEditForm — edit mode (limited fields + Chi tiết section)
FormFields helper — shared form layout cho create

## Build verify

- fe-user: tsc + vite pass (374ms)
- fe-admin: tsc + vite pass (987ms)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-23 10:48:29 +07:00
parent ad0652d590
commit 8c4b4da951
2 changed files with 901 additions and 104 deletions

View File

@ -1,8 +1,24 @@
import { useState, type FormEvent } from 'react' // "Thao tác HĐ" — 2-panel UX cho Ct_*_Create menu. Panel 1 list HĐ theo
import { useMutation, useQuery } from '@tanstack/react-query' // type + nút "Thêm mới" cuối list | Panel 2 form Header + Chi tiết section
import { useNavigate, useSearchParams } from 'react-router-dom' // (khi edit mode). Giữ tên file ContractCreatePage để route /contracts/new
// không cần đổi.
//
// URL state:
// ?type=X → Panel 2 empty state
// ?type=X&mode=new → Form trống (create mode)
// ?type=X&id=abc → Form populated (edit mode) + Chi tiết
//
// Sau khi tạo xong → redirect URL ?id=newId tự động chuyển edit mode +
// hiển thị Chi tiết section.
import { useState, useMemo, type FormEvent, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -10,19 +26,216 @@ 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 { cn } from '@/lib/cn'
import type { Paged, Project, Supplier } from '@/types/master' import type { Paged, Project, Supplier } from '@/types/master'
import type { ContractTemplate } from '@/types/forms' import type { ContractTemplate } from '@/types/forms'
import { ContractTypeLabel } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms'
import {
ContractPhase,
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function ContractCreatePage() { export function ContractCreatePage() {
const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams()
const [searchParams] = useSearchParams() const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2
const selectedId = searchParams.get('id')
const isNewMode = searchParams.get('mode') === 'new'
const search = searchParams.get('q') ?? ''
// Pre-select type from sidebar menu link (?type=X). Fallback: Giao khoán. const list = useQuery({
const urlType = searchParams.get('type') queryKey: ['my-contracts', typeFilter],
const initialType = urlType && !isNaN(Number(urlType)) ? Number(urlType) : 2 queryFn: async () =>
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
})
const [type, setType] = useState(initialType) const detail = useQuery({
queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
enabled: !!selectedId,
})
const rows = useMemo(() => {
let items = list.data?.items ?? []
items = items.filter(c => c.type === typeFilter)
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(c =>
(c.maHopDong ?? '').toLowerCase().includes(q) ||
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
(c.supplierName ?? '').toLowerCase().includes(q),
)
}
return items
}, [list.data, typeFilter, search])
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(searchParams)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSearchParams(next, { replace: key === 'q' })
}
function selectContract(id: string) {
const next = new URLSearchParams(searchParams)
next.set('id', id)
next.delete('mode')
setSearchParams(next, { replace: false })
}
function startNew() {
const next = new URLSearchParams(searchParams)
next.set('mode', 'new')
next.delete('id')
setSearchParams(next, { replace: false })
}
const typeLabel = ContractTypeLabel[typeFilter] ?? 'HĐ'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
Thao tác · {typeLabel}
</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{rows.length}
</span>
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
{/* Panel 1 — List + Thêm mới button cuối */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm theo mã / tên / NCC…"
className="pl-8"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chưa có HĐ"
description="Bấm + Thêm mới phía dưới để tạo HĐ đầu tiên."
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(c => (
<li key={c.id}>
<button
onClick={() => selectContract(c.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{fmtMoney(c.giaTri)}</span>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))}
</ul>
</div>
{/* + Thêm mới button — sticky cuối Panel 1 */}
<div className="shrink-0 border-t border-slate-200 bg-white p-3">
<Button
onClick={startNew}
className={cn('w-full justify-center', isNewMode && 'ring-2 ring-brand-300')}
>
<Plus className="h-4 w-4" />
Thêm mới
</Button>
</div>
</aside>
{/* Panel 2 — Form (new/edit) hoặc empty state */}
<main className="hidden overflow-y-auto bg-slate-50 lg:block">
{!isNewMode && !selectedId && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chọn 1 HĐ ở danh sách bên trái"
description="Hoặc bấm + Thêm mới phía dưới list để tạo HĐ mới."
/>
</div>
)}
{isNewMode && (
<ContractHeaderForm
key="new"
defaultType={typeFilter}
onCreated={(newId) => {
// Sau khi tạo: refresh list + chuyển sang edit mode để user nhập Chi tiết
const next = new URLSearchParams(searchParams)
next.delete('mode')
next.set('id', newId)
setSearchParams(next, { replace: true })
}}
/>
)}
{!isNewMode && selectedId && detail.data && (
<ContractEditForm
key={selectedId}
contract={detail.data}
onSaved={() => {
/* mutation tự invalidate */
}}
/>
)}
{!isNewMode && selectedId && detail.isLoading && (
<div className="p-6 text-sm text-slate-500">Đang tải </div>
)}
</main>
</div>
</div>
)
}
// ===== Form components =====
function ContractHeaderForm({
defaultType,
onCreated,
}: {
defaultType: number
onCreated: (newId: string) => void
}) {
const [type, setType] = useState(defaultType)
const [supplierId, setSupplierId] = useState('') const [supplierId, setSupplierId] = useState('')
const [projectId, setProjectId] = useState('') const [projectId, setProjectId] = useState('')
const [templateId, setTemplateId] = useState('') const [templateId, setTemplateId] = useState('')
@ -31,21 +244,23 @@ export function ContractCreatePage() {
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['suppliers-all'], queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items, queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
}) })
const projects = useQuery({ const projects = useQuery({
queryKey: ['projects-all'], queryKey: ['projects-all'],
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items, queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
}) })
const templates = useQuery({ const templates = useQuery({
queryKey: ['templates-by-type', type], queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data, queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
}) })
const qc = useQueryClient()
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', { const res = await api.post<{ id: string }>('/contracts', {
@ -64,7 +279,8 @@ export function ContractCreatePage() {
}, },
onSuccess: id => { onSuccess: id => {
toast.success('Đã tạo HĐ draft') toast.success('Đã tạo HĐ draft')
navigate(`/contracts/${id}`) qc.invalidateQueries({ queryKey: ['my-contracts'] })
onCreated(id)
}, },
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
@ -78,28 +294,118 @@ export function ContractCreatePage() {
create.mutate() create.mutate()
} }
const typeLabel = ContractTypeLabel[type] ?? 'HĐ' return (
<form onSubmit={submit} className="space-y-4 p-6">
<div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700">Tạo mới Header</h2>
<FormFields
type={type} setType={setType}
templateId={templateId} setTemplateId={setTemplateId}
supplierId={supplierId} setSupplierId={setSupplierId}
projectId={projectId} setProjectId={setProjectId}
tenHopDong={tenHopDong} setTenHopDong={setTenHopDong}
giaTri={giaTri} setGiaTri={setGiaTri}
bypass={bypass} setBypass={setBypass}
noiDung={noiDung} setNoiDung={setNoiDung}
suppliers={suppliers.data ?? []}
projects={projects.data ?? []}
templates={templates.data ?? []}
typeReadonly={false}
/>
<div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}>
<Save className="h-4 w-4" />
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
</Button>
</div>
</div>
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-400">
Chi tiết (line items) sẽ hiện đây sau khi tạo Header xong.
</div>
</form>
)
}
function ContractEditForm({
contract,
onSaved,
}: {
contract: ContractDetail
onSaved: () => void
}) {
const isDraft = contract.phase === ContractPhase.DangSoanThao
const [templateId, setTemplateId] = useState(contract.templateId ?? '')
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const templates = useQuery({
queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
})
const qc = useQueryClient()
const update = useMutation({
mutationFn: async () => {
await api.put(`/contracts/${contract.id}`, {
id: contract.id,
giaTri: giaTri ? Number(giaTri) : 0,
tenHopDong: tenHopDong || null,
noiDung: noiDung || null,
templateId: templateId || null,
draftData: null,
})
},
onSuccess: () => {
toast.success('Đã lưu thay đổi')
qc.invalidateQueries({ queryKey: ['contract', contract.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
onSaved()
},
onError: err => toast.error(getErrorMessage(err)),
})
return ( return (
<div className="p-6"> <div className="space-y-4 p-6">
<PageHeader <div className="rounded-lg border border-slate-200 bg-white p-5">
title={urlType ? `Tạo ${typeLabel}` : 'Tạo hợp đồng mới'} <div className="mb-4 flex items-start justify-between gap-3">
description="Điền thông tin cơ bản. Sau đó bổ sung nội dung + submit lên phase góp ý." <div>
/> <h2 className="text-sm font-semibold text-slate-700">Chỉnh sửa Header</h2>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{contract.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={contract.phase} className="text-[10px]" />
</div>
</div>
{isDraft && (
<Button
onClick={() => update.mutate()}
disabled={update.isPending}
>
<Save className="h-4 w-4" />
{update.isPending ? 'Đang lưu…' : 'Lưu thay đổi'}
</Button>
)}
</div>
{!isDraft && (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
đã chuyển khỏi phase Đang soạn thảo Header read-only. Bạn vẫn thể xem Chi tiết phía dưới.
</div>
)}
<form onSubmit={submit} className="max-w-3xl space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Loại *</Label> <Label>Loại </Label>
<Select value={type} onChange={e => setType(Number(e.target.value))}> <Input value={ContractTypeLabel[contract.type] ?? '—'} disabled className="bg-slate-50" />
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Template (optional)</Label> <Label>Template</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}> <Select
value={templateId}
onChange={e => setTemplateId(e.target.value)}
disabled={!isDraft}
>
<option value=""> Chưa chọn </option> <option value=""> Chưa chọn </option>
{templates.data?.filter(t => t.isActive).map(t => ( {templates.data?.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option> <option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
@ -107,54 +413,141 @@ export function ContractCreatePage() {
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>NCC *</Label> <Label>NCC</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required> <Input value={contract.supplierName} disabled className="bg-slate-50" />
<option value=""> Chọn </option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Dự án *</Label> <Label>Dự án</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required> <Input value={contract.projectName} disabled className="bg-slate-50" />
<option value=""> Chọn </option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Tên </Label> <Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" /> <Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Giá trị (VND)</Label> <Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} /> <Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="flex items-end gap-2 pb-2"> <div className="flex items-end gap-2 pb-2">
<input <input
type="checkbox" type="checkbox"
id="bypass" checked={contract.bypassProcurementAndCCM}
checked={bypass} disabled
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600" className="h-4 w-4 accent-brand-600"
/> />
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label> <Label className="text-slate-500"> với Chủ đu (bypass CCM) không đi sau khi tạo</Label>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label> <Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
</div> </div>
</div>
<div className="flex gap-2 pt-2"> {/* Chi tiết section — luôn hiển thị cho user thao tác */}
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button> <div className="rounded-lg border border-slate-200 bg-white p-5">
<Button type="submit" disabled={create.isPending}> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'} <FileText className="h-4 w-4" />
</Button> Chi tiết ({ContractTypeLabel[contract.type] ?? '—'})
</div> </h2>
</form> <ContractDetailsTab contract={contract} />
</div>
</div>
)
}
function FormFields({
type, setType,
templateId, setTemplateId,
supplierId, setSupplierId,
projectId, setProjectId,
tenHopDong, setTenHopDong,
giaTri, setGiaTri,
bypass, setBypass,
noiDung, setNoiDung,
suppliers, projects, templates,
typeReadonly,
}: {
type: number
setType: (n: number) => void
templateId: string
setTemplateId: (s: string) => void
supplierId: string
setSupplierId: (s: string) => void
projectId: string
setProjectId: (s: string) => void
tenHopDong: string
setTenHopDong: (s: string) => void
giaTri: string
setGiaTri: (s: string) => void
bypass: boolean
setBypass: (b: boolean) => void
noiDung: string
setNoiDung: (s: string) => void
suppliers: Supplier[]
projects: Project[]
templates: ContractTemplate[]
typeReadonly: boolean
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại *</Label>
<Select value={type} onChange={e => setType(Number(e.target.value))} disabled={typeReadonly}>
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Template (optional)</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
<option value=""> Chưa chọn </option>
{templates.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>NCC *</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
<option value=""> Chọn </option>
{suppliers.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Dự án *</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
<option value=""> Chọn </option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
</div>
<div className="space-y-1.5">
<Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
</div>
<div className="flex items-end gap-2 pb-2">
<input
type="checkbox"
id="bypass"
checked={bypass}
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
</div>
</div> </div>
) )
} }

View File

@ -1,8 +1,24 @@
import { useState, type FormEvent } from 'react' // "Thao tác HĐ" — 2-panel UX cho Ct_*_Create menu. Panel 1 list HĐ theo
import { useMutation, useQuery } from '@tanstack/react-query' // type + nút "Thêm mới" cuối list | Panel 2 form Header + Chi tiết section
import { useNavigate } from 'react-router-dom' // (khi edit mode). Giữ tên file ContractCreatePage để route /contracts/new
// không cần đổi.
//
// URL state:
// ?type=X → Panel 2 empty state
// ?type=X&mode=new → Form trống (create mode)
// ?type=X&id=abc → Form populated (edit mode) + Chi tiết
//
// Sau khi tạo xong → redirect URL ?id=newId tự động chuyển edit mode +
// hiển thị Chi tiết section.
import { useState, useMemo, type FormEvent, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { FileText, Plus, Search, Save } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader' import { ContractDetailsTab } from '@/components/contracts/ContractDetailsTab'
import { PhaseBadge } from '@/components/PhaseBadge'
import { SlaTimer } from '@/components/SlaTimer'
import { EmptyState } from '@/components/EmptyState'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input' import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label' import { Label } from '@/components/ui/Label'
@ -10,13 +26,216 @@ 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 { cn } from '@/lib/cn'
import type { Paged, Project, Supplier } from '@/types/master' import type { Paged, Project, Supplier } from '@/types/master'
import type { ContractTemplate } from '@/types/forms' import type { ContractTemplate } from '@/types/forms'
import { ContractTypeLabel } from '@/types/forms' import { ContractTypeLabel } from '@/types/forms'
import {
ContractPhase,
type ContractDetail,
type ContractListItem,
} from '@/types/contracts'
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
export function ContractCreatePage() { export function ContractCreatePage() {
const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams()
const [type, setType] = useState(2) const typeFilter = searchParams.get('type') ? Number(searchParams.get('type')) : 2
const selectedId = searchParams.get('id')
const isNewMode = searchParams.get('mode') === 'new'
const search = searchParams.get('q') ?? ''
const list = useQuery({
queryKey: ['my-contracts', typeFilter],
queryFn: async () =>
(await api.get<Paged<ContractListItem>>('/contracts', { params: { page: 1, pageSize: 100 } })).data,
})
const detail = useQuery({
queryKey: ['contract', selectedId],
queryFn: async () => (await api.get<ContractDetail>(`/contracts/${selectedId}`)).data,
enabled: !!selectedId,
})
const rows = useMemo(() => {
let items = list.data?.items ?? []
items = items.filter(c => c.type === typeFilter)
if (search.trim()) {
const q = search.toLowerCase()
items = items.filter(c =>
(c.maHopDong ?? '').toLowerCase().includes(q) ||
(c.tenHopDong ?? '').toLowerCase().includes(q) ||
(c.supplierName ?? '').toLowerCase().includes(q),
)
}
return items
}, [list.data, typeFilter, search])
function setParam(key: string, value: string | null) {
const next = new URLSearchParams(searchParams)
if (value == null || value === '') next.delete(key)
else next.set(key, value)
setSearchParams(next, { replace: key === 'q' })
}
function selectContract(id: string) {
const next = new URLSearchParams(searchParams)
next.set('id', id)
next.delete('mode')
setSearchParams(next, { replace: false })
}
function startNew() {
const next = new URLSearchParams(searchParams)
next.set('mode', 'new')
next.delete('id')
setSearchParams(next, { replace: false })
}
const typeLabel = ContractTypeLabel[typeFilter] ?? 'HĐ'
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-slate-500" />
<h1 className="text-base font-semibold tracking-tight text-slate-900">
Thao tác · {typeLabel}
</h1>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
{rows.length}
</span>
</div>
</header>
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[320px_1fr]">
{/* Panel 1 — List + Thêm mới button cuối */}
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
<div className="border-b border-slate-200 p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={search}
onChange={e => setParam('q', e.target.value)}
placeholder="Tìm theo mã / tên / NCC…"
className="pl-8"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{list.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-md bg-slate-100" />
))}
</div>
)}
{!list.isLoading && rows.length === 0 && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chưa có HĐ"
description="Bấm + Thêm mới phía dưới để tạo HĐ đầu tiên."
/>
</div>
)}
<ul className="divide-y divide-slate-100">
{rows.map(c => (
<li key={c.id}>
<button
onClick={() => selectContract(c.id)}
className={cn(
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
selectedId === c.id && 'bg-brand-50 ring-1 ring-inset ring-brand-200',
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-slate-900">
{c.tenHopDong ?? '(chưa đặt tên)'}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono">{c.maHopDong ?? '—'}</span>
<span>·</span>
<span className="truncate">{c.supplierName}</span>
</div>
</div>
<PhaseBadge phase={c.phase} className="shrink-0 text-[10px]" />
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{fmtMoney(c.giaTri)}</span>
<SlaTimer deadline={c.slaDeadline} createdAt={c.createdAt} />
</div>
</button>
</li>
))}
</ul>
</div>
{/* + Thêm mới button — sticky cuối Panel 1 */}
<div className="shrink-0 border-t border-slate-200 bg-white p-3">
<Button
onClick={startNew}
className={cn('w-full justify-center', isNewMode && 'ring-2 ring-brand-300')}
>
<Plus className="h-4 w-4" />
Thêm mới
</Button>
</div>
</aside>
{/* Panel 2 — Form (new/edit) hoặc empty state */}
<main className="hidden overflow-y-auto bg-slate-50 lg:block">
{!isNewMode && !selectedId && (
<div className="p-6">
<EmptyState
icon={FileText}
title="Chọn 1 HĐ ở danh sách bên trái"
description="Hoặc bấm + Thêm mới phía dưới list để tạo HĐ mới."
/>
</div>
)}
{isNewMode && (
<ContractHeaderForm
key="new"
defaultType={typeFilter}
onCreated={(newId) => {
// Sau khi tạo: refresh list + chuyển sang edit mode để user nhập Chi tiết
const next = new URLSearchParams(searchParams)
next.delete('mode')
next.set('id', newId)
setSearchParams(next, { replace: true })
}}
/>
)}
{!isNewMode && selectedId && detail.data && (
<ContractEditForm
key={selectedId}
contract={detail.data}
onSaved={() => {
/* mutation tự invalidate */
}}
/>
)}
{!isNewMode && selectedId && detail.isLoading && (
<div className="p-6 text-sm text-slate-500">Đang tải </div>
)}
</main>
</div>
</div>
)
}
// ===== Form components =====
function ContractHeaderForm({
defaultType,
onCreated,
}: {
defaultType: number
onCreated: (newId: string) => void
}) {
const [type, setType] = useState(defaultType)
const [supplierId, setSupplierId] = useState('') const [supplierId, setSupplierId] = useState('')
const [projectId, setProjectId] = useState('') const [projectId, setProjectId] = useState('')
const [templateId, setTemplateId] = useState('') const [templateId, setTemplateId] = useState('')
@ -25,21 +244,23 @@ export function ContractCreatePage() {
const [noiDung, setNoiDung] = useState('') const [noiDung, setNoiDung] = useState('')
const [bypass, setBypass] = useState(false) const [bypass, setBypass] = useState(false)
// Reset type về default khi typeFilter (parent prop) thay đổi
useEffect(() => { setType(defaultType) }, [defaultType])
const suppliers = useQuery({ const suppliers = useQuery({
queryKey: ['suppliers-all'], queryKey: ['suppliers-all'],
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items, queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
}) })
const projects = useQuery({ const projects = useQuery({
queryKey: ['projects-all'], queryKey: ['projects-all'],
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items, queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
}) })
const templates = useQuery({ const templates = useQuery({
queryKey: ['templates-by-type', type], queryKey: ['templates-by-type', type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data, queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
}) })
const qc = useQueryClient()
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
const res = await api.post<{ id: string }>('/contracts', { const res = await api.post<{ id: string }>('/contracts', {
@ -58,7 +279,8 @@ export function ContractCreatePage() {
}, },
onSuccess: id => { onSuccess: id => {
toast.success('Đã tạo HĐ draft') toast.success('Đã tạo HĐ draft')
navigate(`/contracts/${id}`) qc.invalidateQueries({ queryKey: ['my-contracts'] })
onCreated(id)
}, },
onError: err => toast.error(getErrorMessage(err)), onError: err => toast.error(getErrorMessage(err)),
}) })
@ -73,22 +295,117 @@ export function ContractCreatePage() {
} }
return ( return (
<div className="p-6"> <form onSubmit={submit} className="space-y-4 p-6">
<PageHeader title="Tạo hợp đồng mới" description="Điền thông tin cơ bản. Sau đó có thể bổ sung + submit lên phase góp ý." /> <div className="rounded-lg border border-slate-200 bg-white p-5">
<h2 className="mb-4 text-sm font-semibold text-slate-700">Tạo mới Header</h2>
<FormFields
type={type} setType={setType}
templateId={templateId} setTemplateId={setTemplateId}
supplierId={supplierId} setSupplierId={setSupplierId}
projectId={projectId} setProjectId={setProjectId}
tenHopDong={tenHopDong} setTenHopDong={setTenHopDong}
giaTri={giaTri} setGiaTri={setGiaTri}
bypass={bypass} setBypass={setBypass}
noiDung={noiDung} setNoiDung={setNoiDung}
suppliers={suppliers.data ?? []}
projects={projects.data ?? []}
templates={templates.data ?? []}
typeReadonly={false}
/>
<div className="mt-4 flex justify-end gap-2">
<Button type="submit" disabled={create.isPending}>
<Save className="h-4 w-4" />
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
</Button>
</div>
</div>
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-6 text-center text-sm text-slate-400">
Chi tiết (line items) sẽ hiện đây sau khi tạo Header xong.
</div>
</form>
)
}
function ContractEditForm({
contract,
onSaved,
}: {
contract: ContractDetail
onSaved: () => void
}) {
const isDraft = contract.phase === ContractPhase.DangSoanThao
const [templateId, setTemplateId] = useState(contract.templateId ?? '')
const [giaTri, setGiaTri] = useState(String(contract.giaTri ?? 0))
const [tenHopDong, setTenHopDong] = useState(contract.tenHopDong ?? '')
const [noiDung, setNoiDung] = useState(contract.noiDung ?? '')
const templates = useQuery({
queryKey: ['templates-by-type', contract.type],
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type: contract.type } })).data,
})
const qc = useQueryClient()
const update = useMutation({
mutationFn: async () => {
await api.put(`/contracts/${contract.id}`, {
id: contract.id,
giaTri: giaTri ? Number(giaTri) : 0,
tenHopDong: tenHopDong || null,
noiDung: noiDung || null,
templateId: templateId || null,
draftData: null,
})
},
onSuccess: () => {
toast.success('Đã lưu thay đổi')
qc.invalidateQueries({ queryKey: ['contract', contract.id] })
qc.invalidateQueries({ queryKey: ['my-contracts'] })
qc.invalidateQueries({ queryKey: ['contract-changelogs', contract.id] })
onSaved()
},
onError: err => toast.error(getErrorMessage(err)),
})
return (
<div className="space-y-4 p-6">
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-slate-700">Chỉnh sửa Header</h2>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{contract.maHopDong ?? '(chưa có mã)'}</span>
<PhaseBadge phase={contract.phase} className="text-[10px]" />
</div>
</div>
{isDraft && (
<Button
onClick={() => update.mutate()}
disabled={update.isPending}
>
<Save className="h-4 w-4" />
{update.isPending ? 'Đang lưu…' : 'Lưu thay đổi'}
</Button>
)}
</div>
{!isDraft && (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
đã chuyển khỏi phase Đang soạn thảo Header read-only. Bạn vẫn thể xem Chi tiết phía dưới.
</div>
)}
<form onSubmit={submit} className="max-w-3xl space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Loại *</Label> <Label>Loại </Label>
<Select value={type} onChange={e => setType(Number(e.target.value))}> <Input value={ContractTypeLabel[contract.type] ?? '—'} disabled className="bg-slate-50" />
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Template (optional)</Label> <Label>Template</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}> <Select
value={templateId}
onChange={e => setTemplateId(e.target.value)}
disabled={!isDraft}
>
<option value=""> Chưa chọn </option> <option value=""> Chưa chọn </option>
{templates.data?.filter(t => t.isActive).map(t => ( {templates.data?.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option> <option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
@ -96,54 +413,141 @@ export function ContractCreatePage() {
</Select> </Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>NCC *</Label> <Label>NCC</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required> <Input value={contract.supplierName} disabled className="bg-slate-50" />
<option value=""> Chọn </option>
{suppliers.data?.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Dự án *</Label> <Label>Dự án</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required> <Input value={contract.projectName} disabled className="bg-slate-50" />
<option value=""> Chọn </option>
{projects.data?.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Tên </Label> <Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" /> <Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Giá trị (VND)</Label> <Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} /> <Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} disabled={!isDraft} />
</div> </div>
<div className="flex items-end gap-2 pb-2"> <div className="flex items-end gap-2 pb-2">
<input <input
type="checkbox" type="checkbox"
id="bypass" checked={contract.bypassProcurementAndCCM}
checked={bypass} disabled
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600" className="h-4 w-4 accent-brand-600"
/> />
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label> <Label className="text-slate-500"> với Chủ đu (bypass CCM) không đi sau khi tạo</Label>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label> <Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} /> <Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
</div> </div>
</div> </div>
</div>
<div className="flex gap-2 pt-2"> {/* Chi tiết section — luôn hiển thị cho user thao tác */}
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button> <div className="rounded-lg border border-slate-200 bg-white p-5">
<Button type="submit" disabled={create.isPending}> <h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'} <FileText className="h-4 w-4" />
</Button> Chi tiết ({ContractTypeLabel[contract.type] ?? '—'})
</div> </h2>
</form> <ContractDetailsTab contract={contract} />
</div>
</div>
)
}
function FormFields({
type, setType,
templateId, setTemplateId,
supplierId, setSupplierId,
projectId, setProjectId,
tenHopDong, setTenHopDong,
giaTri, setGiaTri,
bypass, setBypass,
noiDung, setNoiDung,
suppliers, projects, templates,
typeReadonly,
}: {
type: number
setType: (n: number) => void
templateId: string
setTemplateId: (s: string) => void
supplierId: string
setSupplierId: (s: string) => void
projectId: string
setProjectId: (s: string) => void
tenHopDong: string
setTenHopDong: (s: string) => void
giaTri: string
setGiaTri: (s: string) => void
bypass: boolean
setBypass: (b: boolean) => void
noiDung: string
setNoiDung: (s: string) => void
suppliers: Supplier[]
projects: Project[]
templates: ContractTemplate[]
typeReadonly: boolean
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Loại *</Label>
<Select value={type} onChange={e => setType(Number(e.target.value))} disabled={typeReadonly}>
{Object.entries(ContractTypeLabel).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Template (optional)</Label>
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
<option value=""> Chưa chọn </option>
{templates.filter(t => t.isActive).map(t => (
<option key={t.id} value={t.id}>{t.formCode} {t.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>NCC *</Label>
<Select value={supplierId} onChange={e => setSupplierId(e.target.value)} required>
<option value=""> Chọn </option>
{suppliers.map(s => (
<option key={s.id} value={s.id}>{s.code} {s.name}</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>Dự án *</Label>
<Select value={projectId} onChange={e => setProjectId(e.target.value)} required>
<option value=""> Chọn </option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.code} {p.name}</option>
))}
</Select>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Tên </Label>
<Input value={tenHopDong} onChange={e => setTenHopDong(e.target.value)} placeholder="vd: HĐ giao khoán nhân công dự án FLOCK 01" />
</div>
<div className="space-y-1.5">
<Label>Giá trị (VND)</Label>
<Input type="number" value={giaTri} onChange={e => setGiaTri(e.target.value)} />
</div>
<div className="flex items-end gap-2 pb-2">
<input
type="checkbox"
id="bypass"
checked={bypass}
onChange={e => setBypass(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
<Label htmlFor="bypass" className="cursor-pointer"> với Chủ đu (bypass CCM)</Label>
</div>
<div className="col-span-2 space-y-1.5">
<Label>Nội dung / ghi chú</Label>
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} />
</div>
</div> </div>
) )
} }