[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
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:
@ -1,8 +1,24 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
// "Thao tác HĐ" — 2-panel UX cho Ct_*_Create menu. Panel 1 list HĐ theo
|
||||
// type + nút "Thêm mới" cuối list | Panel 2 form Header + Chi tiết section
|
||||
// (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 { 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 { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
@ -10,19 +26,216 @@ import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged, Project, Supplier } from '@/types/master'
|
||||
import type { ContractTemplate } 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() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = 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 urlType = searchParams.get('type')
|
||||
const initialType = urlType && !isNaN(Number(urlType)) ? Number(urlType) : 2
|
||||
const list = useQuery({
|
||||
queryKey: ['my-contracts', typeFilter],
|
||||
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 HĐ…</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 [projectId, setProjectId] = useState('')
|
||||
const [templateId, setTemplateId] = useState('')
|
||||
@ -31,21 +244,23 @@ export function ContractCreatePage() {
|
||||
const [noiDung, setNoiDung] = useState('')
|
||||
const [bypass, setBypass] = useState(false)
|
||||
|
||||
// Reset type về default khi typeFilter (parent prop) thay đổi
|
||||
useEffect(() => { setType(defaultType) }, [defaultType])
|
||||
|
||||
const suppliers = useQuery({
|
||||
queryKey: ['suppliers-all'],
|
||||
queryFn: async () => (await api.get<Paged<Supplier>>('/suppliers', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const projects = useQuery({
|
||||
queryKey: ['projects-all'],
|
||||
queryFn: async () => (await api.get<Paged<Project>>('/projects', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const templates = useQuery({
|
||||
queryKey: ['templates-by-type', type],
|
||||
queryFn: async () => (await api.get<ContractTemplate[]>('/forms/templates', { params: { type } })).data,
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post<{ id: string }>('/contracts', {
|
||||
@ -64,7 +279,8 @@ export function ContractCreatePage() {
|
||||
},
|
||||
onSuccess: id => {
|
||||
toast.success('Đã tạo HĐ draft')
|
||||
navigate(`/contracts/${id}`)
|
||||
qc.invalidateQueries({ queryKey: ['my-contracts'] })
|
||||
onCreated(id)
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
@ -78,28 +294,118 @@ export function ContractCreatePage() {
|
||||
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 HĐ 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 HĐ (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="p-6">
|
||||
<PageHeader
|
||||
title={urlType ? `Tạo ${typeLabel}` : 'Tạo hợp đồng mới'}
|
||||
description="Điền thông tin cơ bản. Sau đó bổ sung nội dung + submit lên phase góp ý."
|
||||
/>
|
||||
<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 HĐ — 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">
|
||||
⚠ HĐ đã chuyển khỏi phase Đang soạn thảo → Header read-only. Bạn vẫn có 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="space-y-1.5">
|
||||
<Label>Loại HĐ *</Label>
|
||||
<Select value={type} onChange={e => setType(Number(e.target.value))}>
|
||||
{Object.entries(ContractTypeLabel).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Label>Loại HĐ</Label>
|
||||
<Input value={ContractTypeLabel[contract.type] ?? '—'} disabled className="bg-slate-50" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Template (optional)</Label>
|
||||
<Select value={templateId} onChange={e => setTemplateId(e.target.value)}>
|
||||
<Label>Template</Label>
|
||||
<Select
|
||||
value={templateId}
|
||||
onChange={e => setTemplateId(e.target.value)}
|
||||
disabled={!isDraft}
|
||||
>
|
||||
<option value="">— Chưa chọn —</option>
|
||||
{templates.data?.filter(t => t.isActive).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.formCode} — {t.name}</option>
|
||||
@ -107,54 +413,141 @@ export function ContractCreatePage() {
|
||||
</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.data?.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Label>NCC</Label>
|
||||
<Input value={contract.supplierName} disabled className="bg-slate-50" />
|
||||
</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.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Label>Dự án</Label>
|
||||
<Input value={contract.projectName} disabled className="bg-slate-50" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Tên HĐ</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 className="space-y-1.5">
|
||||
<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 className="flex items-end gap-2 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="bypass"
|
||||
checked={bypass}
|
||||
onChange={e => setBypass(e.target.checked)}
|
||||
checked={contract.bypassProcurementAndCCM}
|
||||
disabled
|
||||
className="h-4 w-4 accent-brand-600"
|
||||
/>
|
||||
<Label htmlFor="bypass" className="cursor-pointer">HĐ với Chủ đầu tư (bypass CCM)</Label>
|
||||
<Label className="text-slate-500">HĐ với Chủ đầu tư (bypass CCM) — không đổi sau khi tạo</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)} />
|
||||
<Textarea rows={4} value={noiDung} onChange={e => setNoiDung(e.target.value)} disabled={!isDraft} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Hủy</Button>
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'Đang tạo…' : 'Tạo HĐ draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Chi tiết section — luôn hiển thị cho user thao tác */}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<FileText className="h-4 w-4" />
|
||||
Chi tiết ({ContractTypeLabel[contract.type] ?? '—'})
|
||||
</h2>
|
||||
<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 HĐ *</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 HĐ</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">HĐ với Chủ đầu tư (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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user