[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:
@ -39,8 +39,9 @@ function getCtGroupCode(key: string): string | null {
|
||||
// /my-contracts (user's own drafts), Duyệt to /inbox (pending THEIR approval).
|
||||
function resolvePath(key: string): string | null {
|
||||
const staticMap: Record<string, string> = {
|
||||
Dashboard: '/dashboard', // Tổng quan riêng — KHÔNG trùng /inbox (Hộp thư)
|
||||
Dashboard: '/dashboard',
|
||||
Contracts: '/my-contracts',
|
||||
PurchaseEvaluations: '/purchase-evaluations',
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
|
||||
@ -53,6 +54,18 @@ function resolvePath(key: string): string | null {
|
||||
if (action === 'Create') return `/contracts/new?type=${typeInt}`
|
||||
if (action === 'Pending') return `/inbox?type=${typeInt}`
|
||||
}
|
||||
|
||||
// Pe_<Code>_<Action> cho module Duyệt NCC (user side)
|
||||
const peMatch = key.match(/^Pe_([^_]+)_(List|Create|Pending)$/)
|
||||
if (peMatch) {
|
||||
const [, code, action] = peMatch
|
||||
const PE_CODE_TO_INT: Record<string, number> = { DuyetNcc: 1, DuyetNccPhuongAn: 2 }
|
||||
const typeInt = PE_CODE_TO_INT[code]
|
||||
if (!typeInt) return null
|
||||
if (action === 'List') return `/purchase-evaluations?type=${typeInt}`
|
||||
if (action === 'Create') return `/purchase-evaluations/new?type=${typeInt}`
|
||||
if (action === 'Pending') return `/purchase-evaluations?type=${typeInt}&pendingMe=1`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
624
fe-user/src/components/pe/PeDetailTabs.tsx
Normal file
624
fe-user/src/components/pe/PeDetailTabs.tsx
Normal file
@ -0,0 +1,624 @@
|
||||
// Detail tabs cho 1 phiếu Duyệt NCC: Thông tin / NCC / Hạng mục + Báo giá /
|
||||
// Duyệt / Lịch sử. Inline action dialog để add NCC, add Detail, upsert Quote,
|
||||
// select winner.
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeChangelog,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
type PeQuote,
|
||||
type PeSupplier,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import type { Supplier } from '@/types/master'
|
||||
|
||||
type TabKey = 'info' | 'suppliers' | 'items' | 'approvals' | 'history'
|
||||
|
||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||
|
||||
export function PeDetailTabs({
|
||||
evaluation,
|
||||
onBack,
|
||||
onDelete,
|
||||
}: {
|
||||
evaluation: PeDetailBundle
|
||||
onBack: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>('info')
|
||||
const navigate = useNavigate()
|
||||
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-5 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">{evaluation.tenGoiThau}</h2>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||||
PurchaseEvaluationPhaseColor[evaluation.phase],
|
||||
)}
|
||||
>
|
||||
{PurchaseEvaluationPhaseLabel[evaluation.phase]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[12px] text-slate-500">
|
||||
<span className="font-mono">{evaluation.maPhieu ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
|
||||
<span>·</span>
|
||||
<span>{evaluation.projectName}</span>
|
||||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isDraft && (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => navigate(`/purchase-evaluations/new?id=${evaluation.id}`)} className="gap-1.5 text-xs">
|
||||
<Pencil className="h-3.5 w-3.5" /> Sửa header
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onDelete} className="gap-1.5 text-xs">
|
||||
<Trash2 className="h-3.5 w-3.5" /> Xóa
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onBack} className="text-xs">← Đóng</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex gap-1 border-b border-slate-200 px-3 pt-2">
|
||||
{(
|
||||
[
|
||||
['info', 'Thông tin'],
|
||||
['suppliers', `NCC (${evaluation.suppliers.length})`],
|
||||
['items', `Hạng mục (${evaluation.details.length})`],
|
||||
['approvals', `Duyệt (${evaluation.approvals.length})`],
|
||||
['history', 'Lịch sử'],
|
||||
] as const
|
||||
).map(([k, lbl]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
className={cn(
|
||||
'rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition',
|
||||
tab === k
|
||||
? 'border-brand-500 text-brand-700'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-5">
|
||||
{tab === 'info' && <InfoTab ev={evaluation} />}
|
||||
{tab === 'suppliers' && <SuppliersTab ev={evaluation} />}
|
||||
{tab === 'items' && <ItemsTab ev={evaluation} />}
|
||||
{tab === 'approvals' && <ApprovalsTab ev={evaluation} />}
|
||||
{tab === 'history' && <HistoryTab ev={evaluation} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Tab: Thông tin =====
|
||||
function InfoTab({ ev }: { ev: PeDetailBundle }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<Field label="Tên gói thầu" value={ev.tenGoiThau} />
|
||||
<Field label="Dự án" value={ev.projectName} />
|
||||
<Field label="Địa điểm" value={ev.diaDiem ?? '—'} />
|
||||
<Field label="Mô tả" value={ev.moTa ?? '—'} />
|
||||
<Field label="NCC được chọn" value={ev.selectedSupplierName ?? '—'} />
|
||||
<Field label="Điều khoản thanh toán" value={ev.paymentTerms ?? '—'} />
|
||||
{ev.contractId && (
|
||||
<Field label="HĐ kế thừa" value={<a href={`/contracts/${ev.contractId}`} className="text-brand-600 hover:underline">✓ Xem HĐ</a>} />
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[11px] uppercase tracking-wide text-slate-400">{label}</dt>
|
||||
<dd className="mt-0.5 text-slate-800">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Tab: NCC =====
|
||||
function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editRow, setEditRow] = useState<PeSupplier | null>(null)
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (rowId: string) => api.delete(`/purchase-evaluations/${ev.id}/suppliers/${rowId}`),
|
||||
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
const setWinner = useMutation({
|
||||
mutationFn: async (supplierId: string) =>
|
||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||
onSuccess: () => { toast.success('Đã chọn NCC thắng.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<Button onClick={() => setOpen(true)} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Thêm NCC
|
||||
</Button>
|
||||
</div>
|
||||
{ev.suppliers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Chưa có NCC. Thêm NCC để bắt đầu so sánh giá.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">NCC</th>
|
||||
<th className="px-3 py-2 text-left">Hiển thị</th>
|
||||
<th className="px-3 py-2 text-left">Liên hệ</th>
|
||||
<th className="px-3 py-2 text-left">Điều khoản TT</th>
|
||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.suppliers.map(s => (
|
||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{s.supplierName}</td>
|
||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||
{s.contactName && <div>{s.contactName}</div>}
|
||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-[12px] text-slate-600">{s.note ?? '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => setWinner.mutate(s.supplierId)}
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[11px]',
|
||||
ev.selectedSupplierId === s.supplierId
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'text-slate-500 hover:bg-emerald-50 hover:text-emerald-700',
|
||||
)}
|
||||
title="Chọn NCC thắng"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditRow(s)}
|
||||
className="rounded px-1.5 py-0.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Sửa"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Xóa NCC này khỏi phiếu?')) remove.mutate(s.id) }}
|
||||
className="rounded px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && <AddSupplierDialog evaluationId={ev.id} onClose={() => setOpen(false)} />}
|
||||
{editRow && <EditSupplierDialog evaluationId={ev.id} row={editRow} onClose={() => setEditRow(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddSupplierDialog({ evaluationId, onClose }: { evaluationId: string; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const suppliers = useQuery({
|
||||
queryKey: ['all-suppliers'],
|
||||
queryFn: async () => (await api.get<{ items: Supplier[] }>('/suppliers', { params: { pageSize: 1000 } })).data.items,
|
||||
})
|
||||
const [form, setForm] = useState({
|
||||
supplierId: '',
|
||||
displayName: '',
|
||||
contactName: '',
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
paymentTermText: '',
|
||||
note: '',
|
||||
})
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => api.post(`/purchase-evaluations/${evaluationId}/suppliers`, form),
|
||||
onSuccess: () => { toast.success('Đã thêm NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title="Thêm NCC vào phiếu"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={!form.supplierId || mut.isPending}>Thêm</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>NCC (master)</Label>
|
||||
<Select value={form.supplierId} onChange={e => setForm({ ...form, supplierId: e.target.value })}>
|
||||
<option value="">-- Chọn --</option>
|
||||
{suppliers.data?.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
|
||||
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} placeholder="vd 30 ngày, 300tr" /></div>
|
||||
<div><Label>Người liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
|
||||
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} placeholder="ĐÃ CHỐT SO SÁNH LẦN 1 / ĐÀM PHÁN THÊM..." /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeSupplier; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
supplierId: row.supplierId,
|
||||
displayName: row.displayName ?? '',
|
||||
contactName: row.contactName ?? '',
|
||||
contactEmail: row.contactEmail ?? '',
|
||||
contactPhone: row.contactPhone ?? '',
|
||||
paymentTermText: row.paymentTermText ?? '',
|
||||
note: row.note ?? '',
|
||||
})
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => api.put(`/purchase-evaluations/${evaluationId}/suppliers/${row.id}`, form),
|
||||
onSuccess: () => { toast.success('Đã cập nhật.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Sửa NCC — ${row.supplierName}`}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} /></div>
|
||||
<div><Label>Điều khoản TT</Label><Input value={form.paymentTermText} onChange={e => setForm({ ...form, paymentTermText: e.target.value })} /></div>
|
||||
<div><Label>Liên hệ</Label><Input value={form.contactName} onChange={e => setForm({ ...form, contactName: e.target.value })} /></div>
|
||||
<div><Label>Điện thoại</Label><Input value={form.contactPhone} onChange={e => setForm({ ...form, contactPhone: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Email</Label><Input value={form.contactEmail} onChange={e => setForm({ ...form, contactEmail: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Tab: Hạng mục + Báo giá (matrix) =====
|
||||
function ItemsTab({ ev }: { ev: PeDetailBundle }) {
|
||||
const qc = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
|
||||
const [quoteEdit, setQuoteEdit] = useState<{ detail: PeDetailRow; supplier: PeSupplier; existing: PeQuote | null } | null>(null)
|
||||
|
||||
const removeDetail = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${ev.id}/details/${id}`),
|
||||
onSuccess: () => { toast.success('Đã xóa hạng mục.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const quoteKey = (detailId: string, supplierRowId: string) =>
|
||||
ev.details.find(d => d.id === detailId)?.quotes.find(q => q.purchaseEvaluationSupplierId === supplierRowId) ?? null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
{ev.suppliers.length === 0
|
||||
? 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.'
|
||||
: `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC — click ô để nhập báo giá.`}
|
||||
</p>
|
||||
<Button onClick={() => setAddOpen(true)} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Thêm hạng mục
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ev.details.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Chưa có hạng mục.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full border border-slate-200 text-xs">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 border-r border-slate-200 bg-slate-50 px-2 py-2 text-left">Hạng mục</th>
|
||||
<th className="border-r border-slate-200 px-2 py-2 text-right">KL</th>
|
||||
<th className="border-r border-slate-200 px-2 py-2 text-right">ĐG ngân sách</th>
|
||||
<th className="border-r border-slate-200 px-2 py-2 text-right">TT ngân sách</th>
|
||||
{ev.suppliers.map(s => (
|
||||
<th key={s.id} className="border-r border-slate-200 px-2 py-2 text-right">
|
||||
{s.displayName ?? s.supplierName}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{ev.details.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td className="sticky left-0 z-10 border-r border-slate-200 bg-white px-2 py-2">
|
||||
<div className="font-medium text-slate-900">{d.groupCode} {d.noiDung}</div>
|
||||
<div className="text-[10px] text-slate-500">{d.groupName} · {d.donViTinh ?? ''}</div>
|
||||
</td>
|
||||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{d.khoiLuongNganSach}</td>
|
||||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.donGiaNganSach)}</td>
|
||||
<td className="border-r border-slate-200 px-2 py-2 text-right font-mono">{fmtMoney(d.thanhTienNganSach)}</td>
|
||||
{ev.suppliers.map(s => {
|
||||
const q = quoteKey(d.id, s.id)
|
||||
return (
|
||||
<td
|
||||
key={s.id}
|
||||
onClick={() => setQuoteEdit({ detail: d, supplier: s, existing: q })}
|
||||
className={cn(
|
||||
'cursor-pointer border-r border-slate-200 px-2 py-2 text-right font-mono transition hover:bg-brand-50',
|
||||
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
|
||||
)}
|
||||
>
|
||||
{q ? fmtMoney(q.thanhTien) : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setEditDetail(d)} className="rounded px-1 py-0.5 text-slate-500 hover:bg-slate-100">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm('Xóa hạng mục?')) removeDetail.mutate(d.id) }} className="rounded px-1 py-0.5 text-red-500 hover:bg-red-50">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addOpen && <DetailDialog evaluationId={ev.id} row={null} onClose={() => setAddOpen(false)} />}
|
||||
{editDetail && <DetailDialog evaluationId={ev.id} row={editDetail} onClose={() => setEditDetail(null)} />}
|
||||
{quoteEdit && (
|
||||
<QuoteDialog
|
||||
evaluationId={ev.id}
|
||||
detailId={quoteEdit.detail.id}
|
||||
supplierRowId={quoteEdit.supplier.id}
|
||||
supplierName={quoteEdit.supplier.supplierName}
|
||||
itemName={quoteEdit.detail.noiDung}
|
||||
khoiLuong={quoteEdit.detail.khoiLuongThiCong || quoteEdit.detail.khoiLuongNganSach}
|
||||
existing={quoteEdit.existing}
|
||||
onClose={() => setQuoteEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailDialog({ evaluationId, row, onClose }: { evaluationId: string; row: PeDetailRow | null; onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
groupCode: row?.groupCode ?? 'A.I',
|
||||
groupName: row?.groupName ?? '',
|
||||
itemCode: row?.itemCode ?? '',
|
||||
noiDung: row?.noiDung ?? '',
|
||||
donViTinh: row?.donViTinh ?? '',
|
||||
khoiLuongNganSach: row?.khoiLuongNganSach ?? 0,
|
||||
khoiLuongThiCong: row?.khoiLuongThiCong ?? 0,
|
||||
donGiaNganSach: row?.donGiaNganSach ?? 0,
|
||||
thanhTienNganSach: row?.thanhTienNganSach ?? 0,
|
||||
ghiChu: row?.ghiChu ?? '',
|
||||
})
|
||||
const mut = useMutation({
|
||||
mutationFn: async () =>
|
||||
row
|
||||
? api.put(`/purchase-evaluations/${evaluationId}/details/${row.id}`, form)
|
||||
: api.post(`/purchase-evaluations/${evaluationId}/details`, form),
|
||||
onSuccess: () => { toast.success(row ? 'Đã sửa.' : 'Đã thêm.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const updateAndRecalc = (patch: Partial<typeof form>) => {
|
||||
const next = { ...form, ...patch }
|
||||
// Auto-compute ThanhTien = KL ngân sách × ĐG ngân sách
|
||||
next.thanhTienNganSach = Number(next.khoiLuongNganSach) * Number(next.donGiaNganSach)
|
||||
setForm(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={(row ? 'Sửa' : 'Thêm') + ' hạng mục'}
|
||||
size="lg"
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>{row ? 'Lưu' : 'Thêm'}</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label>Nhóm (A.I/A.II...)</Label><Input value={form.groupCode} onChange={e => setForm({ ...form, groupCode: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Tên nhóm</Label><Input value={form.groupName} onChange={e => setForm({ ...form, groupName: e.target.value })} placeholder="Bê tông / Phụ gia..." /></div>
|
||||
<div><Label>Mã (tùy chọn)</Label><Input value={form.itemCode} onChange={e => setForm({ ...form, itemCode: e.target.value })} /></div>
|
||||
<div className="col-span-2"><Label>Nội dung</Label><Input value={form.noiDung} onChange={e => setForm({ ...form, noiDung: e.target.value })} /></div>
|
||||
<div><Label>ĐVT</Label><Input value={form.donViTinh} onChange={e => setForm({ ...form, donViTinh: e.target.value })} /></div>
|
||||
<div><Label>KL ngân sách</Label><Input type="number" value={form.khoiLuongNganSach} onChange={e => updateAndRecalc({ khoiLuongNganSach: Number(e.target.value) })} /></div>
|
||||
<div><Label>KL thi công</Label><Input type="number" value={form.khoiLuongThiCong} onChange={e => setForm({ ...form, khoiLuongThiCong: Number(e.target.value) })} /></div>
|
||||
<div><Label>Đơn giá ngân sách</Label><Input type="number" value={form.donGiaNganSach} onChange={e => updateAndRecalc({ donGiaNganSach: Number(e.target.value) })} /></div>
|
||||
<div className="col-span-2"><Label>Thành tiền ngân sách (auto)</Label><Input type="number" value={form.thanhTienNganSach} onChange={e => setForm({ ...form, thanhTienNganSach: Number(e.target.value) })} /></div>
|
||||
<div className="col-span-3"><Label>Ghi chú</Label><Input value={form.ghiChu} onChange={e => setForm({ ...form, ghiChu: e.target.value })} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteDialog({
|
||||
evaluationId, detailId, supplierRowId, supplierName, itemName, khoiLuong, existing, onClose,
|
||||
}: {
|
||||
evaluationId: string
|
||||
detailId: string
|
||||
supplierRowId: string
|
||||
supplierName: string
|
||||
itemName: string
|
||||
khoiLuong: number
|
||||
existing: PeQuote | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState({
|
||||
bgVat: existing?.bgVat ?? 0,
|
||||
chuaVat: existing?.chuaVat ?? 0,
|
||||
thanhTien: existing?.thanhTien ?? 0,
|
||||
isSelected: existing?.isSelected ?? false,
|
||||
note: existing?.note ?? '',
|
||||
})
|
||||
|
||||
const updateAndRecalc = (patch: Partial<typeof form>) => {
|
||||
const next = { ...form, ...patch }
|
||||
next.thanhTien = Number(next.chuaVat) * khoiLuong
|
||||
setForm(next)
|
||||
}
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
|
||||
purchaseEvaluationDetailId: detailId,
|
||||
purchaseEvaluationSupplierId: supplierRowId,
|
||||
...form,
|
||||
}),
|
||||
onSuccess: () => { toast.success('Đã lưu báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async () =>
|
||||
existing ? api.delete(`/purchase-evaluations/${evaluationId}/quotes/${existing.id}`) : Promise.resolve(),
|
||||
onSuccess: () => { toast.success('Đã xóa báo giá.'); qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] }); onClose() },
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Báo giá — ${supplierName}`}
|
||||
footer={<>
|
||||
{existing && <Button variant="danger" onClick={() => del.mutate()} disabled={del.isPending}>Xóa</Button>}
|
||||
<Button variant="ghost" onClick={onClose}>Hủy</Button>
|
||||
<Button onClick={() => mut.mutate()} disabled={mut.isPending}>Lưu</Button>
|
||||
</>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong> · KL {khoiLuong}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><Label>Đơn giá chưa VAT</Label><Input type="number" value={form.chuaVat} onChange={e => updateAndRecalc({ chuaVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Đơn giá có VAT</Label><Input type="number" value={form.bgVat} onChange={e => setForm({ ...form, bgVat: Number(e.target.value) })} /></div>
|
||||
<div><Label>Thành tiền (auto)</Label><Input type="number" value={form.thanhTien} onChange={e => setForm({ ...form, thanhTien: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isSelected} onChange={e => setForm({ ...form, isSelected: e.target.checked })} />
|
||||
Chọn NCC này cho hạng mục
|
||||
</label>
|
||||
<div><Label>Ghi chú</Label><Input value={form.note} onChange={e => setForm({ ...form, note: e.target.value })} /></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Tab: Duyệt =====
|
||||
function ApprovalsTab({ ev }: { ev: PeDetailBundle }) {
|
||||
if (ev.approvals.length === 0) return <p className="text-sm text-slate-500">Chưa có bước duyệt nào.</p>
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{ev.approvals.map(a => (
|
||||
<li key={a.id} className="rounded border border-slate-200 bg-white p-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.fromPhase])}>
|
||||
{PurchaseEvaluationPhaseLabel[a.fromPhase]}
|
||||
</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[11px]', PurchaseEvaluationPhaseColor[a.toPhase])}>
|
||||
{PurchaseEvaluationPhaseLabel[a.toPhase]}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{new Date(a.approvedAt).toLocaleString('vi-VN')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{a.approverName ?? 'Hệ thống'}{a.comment && ` · ${a.comment}`}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Tab: Lịch sử =====
|
||||
function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
||||
const logs = useQuery({
|
||||
queryKey: ['pe-changelog', ev.id],
|
||||
queryFn: async () => (await api.get<PeChangelog[]>(`/purchase-evaluations/${ev.id}/changelogs`)).data,
|
||||
})
|
||||
if (logs.isLoading) return <p className="text-sm text-slate-500">Đang tải…</p>
|
||||
if (!logs.data || logs.data.length === 0) return <p className="text-sm text-slate-500">Chưa có lịch sử.</p>
|
||||
return (
|
||||
<ol className="space-y-1.5 text-sm">
|
||||
{logs.data.map(l => (
|
||||
<li key={l.id} className="border-l-2 border-slate-200 pl-3 py-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{l.userName ?? 'Hệ thống'}</span>
|
||||
<span>{new Date(l.createdAt).toLocaleString('vi-VN')}</span>
|
||||
</div>
|
||||
<div className="text-slate-800">{l.summary}</div>
|
||||
{l.contextNote && <div className="text-xs text-slate-500">{l.contextNote}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
124
fe-user/src/components/pe/PeWorkflowPanel.tsx
Normal file
124
fe-user/src/components/pe/PeWorkflowPanel.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
// Panel 3: workflow + transition buttons. Pulls nextPhases từ BE bundle
|
||||
// (single source of truth) → render per-phase action button.
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
type PeDetailBundle,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
|
||||
export function PeWorkflowPanel({ evaluation }: { evaluation: PeDetailBundle }) {
|
||||
const [target, setTarget] = useState<number | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
const qc = useQueryClient()
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async () =>
|
||||
api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
|
||||
targetPhase: target,
|
||||
decision: target === PurchaseEvaluationPhase.TuChoi ? 2 : 1,
|
||||
comment: comment || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã chuyển phase.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluation.id] })
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
setTarget(null)
|
||||
setComment('')
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const next = evaluation.workflow.nextPhases
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Quy trình</h3>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">{evaluation.workflow.policyDescription}</p>
|
||||
</div>
|
||||
|
||||
<ol className="space-y-1.5">
|
||||
{evaluation.workflow.activePhases
|
||||
.filter(p => p !== PurchaseEvaluationPhase.TuChoi)
|
||||
.map(p => {
|
||||
const isCurrent = evaluation.phase === p
|
||||
const isPast = isPastPhase(evaluation.phase, p, evaluation.workflow.activePhases)
|
||||
return (
|
||||
<li key={p}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded border px-2 py-1.5 text-xs',
|
||||
isCurrent && 'border-brand-300 bg-brand-50 font-medium',
|
||||
isPast && 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
!isCurrent && !isPast && 'border-slate-200 text-slate-500',
|
||||
)}
|
||||
>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', PurchaseEvaluationPhaseColor[p])}>
|
||||
{p}
|
||||
</span>
|
||||
<span className="truncate">{PurchaseEvaluationPhaseLabel[p]}</span>
|
||||
{isCurrent && <span className="ml-auto text-[10px] text-brand-700">● hiện tại</span>}
|
||||
{isPast && <span className="ml-auto text-[10px] text-emerald-600">✓</span>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{next.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-xs">Chuyển tiếp:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{next.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setTarget(p)}
|
||||
className={cn(
|
||||
'rounded border px-2 py-1 text-[11px] transition',
|
||||
p === PurchaseEvaluationPhase.TuChoi
|
||||
? 'border-red-200 text-red-700 hover:bg-red-50'
|
||||
: 'border-brand-300 text-brand-700 hover:bg-brand-50',
|
||||
)}
|
||||
>
|
||||
→ {PurchaseEvaluationPhaseLabel[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== null && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setTarget(null)}
|
||||
title={`Chuyển → ${PurchaseEvaluationPhaseLabel[target]}`}
|
||||
footer={<>
|
||||
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
|
||||
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
|
||||
</>}
|
||||
>
|
||||
<Label>Ghi chú (tùy chọn)</Label>
|
||||
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isPastPhase(current: number, p: number, active: number[]): boolean {
|
||||
const orderedIdx = active.indexOf(p)
|
||||
const currentIdx = active.indexOf(current)
|
||||
if (orderedIdx < 0 || currentIdx < 0) return false
|
||||
return orderedIdx < currentIdx && p !== PurchaseEvaluationPhase.TuChoi
|
||||
}
|
||||
Reference in New Issue
Block a user