[CLAUDE] FE-Admin+FE-User: PurchaseEvaluation pages (3-panel list + tabs detail)
Types + pages + components cho module Duyệt NCC ở cả 2 FE (copy-share). Pages: - PurchaseEvaluationsListPage: 3-panel lg:grid-cols-[340px_1fr_360px] * Panel 1: list filter theo type/phase/search + pendingMe inbox mode * Panel 2: PeDetailTabs (Thông tin/NCC/Hạng mục/Duyệt/Lịch sử) * Panel 3: PeWorkflowPanel với timeline + nextPhase buttons * Mobile fallback fullpage /purchase-evaluations/:id - PurchaseEvaluationCreatePage: form create/edit header (Type / Tên gói thầu / Dự án / Địa điểm / Mô tả / PaymentTerms JSON). Suppliers+Details+Quotes thêm sau khi save ở Detail tabs. Components: - PeDetailTabs: 5 tab + dialogs (AddSupplier/EditSupplier/DetailDialog/ QuoteDialog) + matrix N NCC × M hạng mục clickable cells + select winner - PeWorkflowPanel: policy timeline từ BE workflow.activePhases + transition confirmation dialog với comment Routes (cả 2 app): - /purchase-evaluations (+ ?type=1|2&pendingMe=1&id=...) - /purchase-evaluations/new (+ ?type / ?id để edit) - /purchase-evaluations/:id (mobile fullpage) Menu resolver: - Pe_<Code>_List → /purchase-evaluations?type=N - Pe_<Code>_Create → /purchase-evaluations/new?type=N - Pe_<Code>_Pending → /purchase-evaluations?type=N&pendingMe=1 - PeWf_<Code> (fe-admin only) → /system/pe-workflows/<code> Skip MVP: PE Workflow admin designer UI, PE Attachments. TS build pass cả 2 app.
This commit is contained in:
176
fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal file
176
fe-user/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
// Create / edit draft phiếu Duyệt NCC (Header only — Suppliers + Details + Quotes
|
||||
// chỉnh sửa ở Detail tabs sau khi save).
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { ClipboardCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import {
|
||||
PurchaseEvaluationType,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import type { Project } from '@/types/master'
|
||||
|
||||
export function PurchaseEvaluationCreatePage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [sp] = useSearchParams()
|
||||
const editId = sp.get('id')
|
||||
const urlType = sp.get('type') ? Number(sp.get('type')) : PurchaseEvaluationType.DuyetNcc
|
||||
|
||||
const projects = useQuery({
|
||||
queryKey: ['all-projects'],
|
||||
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||||
})
|
||||
const existing = useQuery({
|
||||
queryKey: ['pe-detail', editId],
|
||||
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${editId}`)).data,
|
||||
enabled: !!editId,
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
type: urlType as number,
|
||||
tenGoiThau: '',
|
||||
projectId: '',
|
||||
diaDiem: '',
|
||||
moTa: '',
|
||||
paymentTerms: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (existing.data) {
|
||||
setForm({
|
||||
type: existing.data.type,
|
||||
tenGoiThau: existing.data.tenGoiThau,
|
||||
projectId: existing.data.projectId,
|
||||
diaDiem: existing.data.diaDiem ?? '',
|
||||
moTa: existing.data.moTa ?? '',
|
||||
paymentTerms: existing.data.paymentTerms ?? '',
|
||||
})
|
||||
}
|
||||
}, [existing.data])
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (editId) {
|
||||
return api.put(`/purchase-evaluations/${editId}`, {
|
||||
id: editId,
|
||||
tenGoiThau: form.tenGoiThau,
|
||||
diaDiem: form.diaDiem || null,
|
||||
moTa: form.moTa || null,
|
||||
paymentTerms: form.paymentTerms || null,
|
||||
})
|
||||
}
|
||||
return api.post<{ id: string }>('/purchase-evaluations', {
|
||||
type: form.type,
|
||||
tenGoiThau: form.tenGoiThau,
|
||||
projectId: form.projectId,
|
||||
diaDiem: form.diaDiem || null,
|
||||
moTa: form.moTa || null,
|
||||
paymentTerms: form.paymentTerms || null,
|
||||
})
|
||||
},
|
||||
onSuccess: res => {
|
||||
toast.success(editId ? 'Đã lưu.' : 'Đã tạo phiếu.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
const id = editId ?? (res as { data: { id: string } }).data.id
|
||||
navigate(`/purchase-evaluations?id=${id}&type=${form.type}`)
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<header className="flex items-center gap-2">
|
||||
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="text-base font-semibold tracking-tight text-slate-900">
|
||||
{editId ? 'Sửa phiếu Duyệt NCC' : 'Tạo phiếu Duyệt NCC mới'}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="max-w-2xl space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<Label>Loại quy trình</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
disabled={!!editId}
|
||||
onChange={e => setForm({ ...form, type: Number(e.target.value) })}
|
||||
>
|
||||
{Object.values(PurchaseEvaluationType).map(t => (
|
||||
<option key={t} value={t}>{PurchaseEvaluationTypeLabel[t]}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tên gói thầu *</Label>
|
||||
<Input
|
||||
value={form.tenGoiThau}
|
||||
onChange={e => setForm({ ...form, tenGoiThau: e.target.value })}
|
||||
placeholder="vd Cung cấp bê tông"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Dự án *</Label>
|
||||
<Select
|
||||
value={form.projectId}
|
||||
disabled={!!editId}
|
||||
onChange={e => setForm({ ...form, projectId: e.target.value })}
|
||||
>
|
||||
<option value="">-- Chọn --</option>
|
||||
{projects.data?.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.code} — {p.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Địa điểm</Label>
|
||||
<Input
|
||||
value={form.diaDiem}
|
||||
onChange={e => setForm({ ...form, diaDiem: e.target.value })}
|
||||
placeholder="Lô K, KCN Lộc An - Bình Sơn..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Mô tả</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.moTa}
|
||||
onChange={e => setForm({ ...form, moTa: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Điều khoản thanh toán (JSON hoặc text)</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.paymentTerms}
|
||||
onChange={e => setForm({ ...form, paymentTerms: e.target.value })}
|
||||
placeholder='{"tamUng":"10%","thanhToanTam":"100% W.done","quyetToan":"Final Account","baoHanh":"5%"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>Hủy</Button>
|
||||
<Button
|
||||
onClick={() => mut.mutate()}
|
||||
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
||||
>
|
||||
{editId ? 'Lưu' : 'Tạo phiếu'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user