[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:
@ -18,6 +18,8 @@ import { ContractDetailPage } from '@/pages/contracts/ContractDetailPage'
|
||||
import { ContractCreatePage } from '@/pages/contracts/ContractCreatePage'
|
||||
import { ReportsPage } from '@/pages/ReportsPage'
|
||||
import { UsersPage } from '@/pages/system/UsersPage'
|
||||
import { PurchaseEvaluationsListPage, PurchaseEvaluationDetailPage } from '@/pages/pe/PurchaseEvaluationsListPage'
|
||||
import { PurchaseEvaluationCreatePage } from '@/pages/pe/PurchaseEvaluationCreatePage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -47,6 +49,9 @@ function App() {
|
||||
<Route path="/contracts" element={<ContractsListPage />} />
|
||||
<Route path="/contracts/new" element={<ContractCreatePage />} />
|
||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||
<Route path="/purchase-evaluations" element={<PurchaseEvaluationsListPage />} />
|
||||
<Route path="/purchase-evaluations/new" element={<PurchaseEvaluationCreatePage />} />
|
||||
<Route path="/purchase-evaluations/:id" element={<PurchaseEvaluationDetailPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
|
||||
@ -44,6 +44,8 @@ function resolvePath(key: string): string | null {
|
||||
CatalogMaterials: '/master/catalogs/materials',
|
||||
CatalogServices: '/master/catalogs/services',
|
||||
CatalogWorkItems: '/master/catalogs/work-items',
|
||||
PurchaseEvaluations: '/purchase-evaluations',
|
||||
PeWorkflows: '/system/pe-workflows',
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
|
||||
@ -64,6 +66,24 @@ function resolvePath(key: string): string | null {
|
||||
if (TYPE_CODE_TO_INT[code]) return `/system/workflows/${code}`
|
||||
}
|
||||
|
||||
// Pe_<Code>_<Action> cho module Duyệt NCC
|
||||
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`
|
||||
}
|
||||
// PE workflow admin leaf: PeWf_<Code> → /system/pe-workflows/<code>
|
||||
const peWfMatch = key.match(/^PeWf_(.+)$/)
|
||||
if (peWfMatch) {
|
||||
const code = peWfMatch[1]
|
||||
if (code === 'DuyetNcc' || code === 'DuyetNccPhuongAn') return `/system/pe-workflows/${code}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
624
fe-admin/src/components/pe/PeDetailTabs.tsx
Normal file
624
fe-admin/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-admin/src/components/pe/PeWorkflowPanel.tsx
Normal file
124
fe-admin/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
|
||||
}
|
||||
@ -12,6 +12,8 @@ export const MenuKeys = {
|
||||
Users: 'Users',
|
||||
Roles: 'Roles',
|
||||
Permissions: 'Permissions',
|
||||
PurchaseEvaluations: 'PurchaseEvaluations',
|
||||
PeWorkflows: 'PeWorkflows',
|
||||
} as const
|
||||
|
||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||
|
||||
176
fe-admin/src/pages/pe/PurchaseEvaluationCreatePage.tsx
Normal file
176
fe-admin/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>
|
||||
)
|
||||
}
|
||||
250
fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
250
fe-admin/src/pages/pe/PurchaseEvaluationsListPage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
// List + Detail phiếu Duyệt NCC — 3-panel: List | Detail tabs | Workflow + history.
|
||||
// URL params: type (filter A/B), pendingMe (1=inbox), id (selected), q (search).
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { ClipboardCheck, Plus, Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { SlaTimer } from '@/components/SlaTimer'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { Paged } from '@/types/master'
|
||||
import {
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeDetailBundle,
|
||||
type PeListItem,
|
||||
} from '@/types/purchaseEvaluation'
|
||||
import { PeDetailTabs } from '@/components/pe/PeDetailTabs'
|
||||
import { PeWorkflowPanel } from '@/components/pe/PeWorkflowPanel'
|
||||
|
||||
export function PurchaseEvaluationsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const typeFilter = sp.get('type') ? Number(sp.get('type')) : null
|
||||
const pendingMe = sp.get('pendingMe') === '1'
|
||||
const search = sp.get('q') ?? ''
|
||||
const phase = sp.get('phase') ?? ''
|
||||
const selectedId = sp.get('id')
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['pe-list', { typeFilter, pendingMe, search, phase }],
|
||||
queryFn: async () => {
|
||||
if (pendingMe) {
|
||||
const res = await api.get<PeListItem[]>('/purchase-evaluations/inbox', {
|
||||
params: { type: typeFilter ?? undefined },
|
||||
})
|
||||
return { items: res.data, total: res.data.length, page: 1, pageSize: res.data.length }
|
||||
}
|
||||
const res = await api.get<Paged<PeListItem>>('/purchase-evaluations', {
|
||||
params: {
|
||||
pageSize: 50,
|
||||
search: search || undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
phase: phase || undefined,
|
||||
},
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['pe-detail', selectedId],
|
||||
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${selectedId}`)).data,
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/purchase-evaluations/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa phiếu.')
|
||||
setParam('id', null)
|
||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
function setParam(key: string, value: string | null) {
|
||||
const next = new URLSearchParams(sp)
|
||||
if (value == null || value === '') next.delete(key)
|
||||
else next.set(key, value)
|
||||
if (key !== 'id') next.delete('page')
|
||||
setSp(next, { replace: key === 'q' })
|
||||
}
|
||||
|
||||
function selectRow(id: string) {
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(min-width: 1024px)').matches) {
|
||||
setParam('id', id)
|
||||
} else {
|
||||
navigate(`/purchase-evaluations/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = list.data?.items ?? []
|
||||
const createHref =
|
||||
typeFilter != null ? `/purchase-evaluations/new?type=${typeFilter}` : '/purchase-evaluations/new'
|
||||
|
||||
const headerTitle = typeFilter
|
||||
? (pendingMe ? `${PurchaseEvaluationTypeLabel[typeFilter]} — Chờ duyệt` : PurchaseEvaluationTypeLabel[typeFilter])
|
||||
: pendingMe ? 'Duyệt NCC — Chờ tôi' : 'Quy trình Duyệt NCC'
|
||||
|
||||
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">
|
||||
<ClipboardCheck className="h-5 w-5 text-slate-500" />
|
||||
<h1 className="text-base font-semibold tracking-tight text-slate-900">{headerTitle}</h1>
|
||||
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{list.data?.total ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => navigate(createHref)} className="gap-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" /> Tạo phiếu mới
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[340px_1fr_360px]">
|
||||
{/* Panel 1: List */}
|
||||
<aside className="flex flex-col overflow-hidden border-r border-slate-200 bg-white">
|
||||
<div className="space-y-2 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 mã / tên gói thầu / dự án…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={phase} onChange={e => setParam('phase', e.target.value)}>
|
||||
<option value="">Tất cả phase</option>
|
||||
{Object.values(PurchaseEvaluationPhase).map(p => (
|
||||
<option key={p} value={p}>{PurchaseEvaluationPhaseLabel[p]}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{list.isLoading && (
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).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={ClipboardCheck} title="Chưa có phiếu" description="Tạo phiếu mới để bắt đầu quy trình." />
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{rows.map(p => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
onClick={() => selectRow(p.id)}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2.5 text-left transition hover:bg-slate-50',
|
||||
selectedId === p.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">{p.tenGoiThau}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<span className="font-mono">{p.maPhieu ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">{p.projectName}</span>
|
||||
</div>
|
||||
{p.selectedSupplierName && (
|
||||
<div className="mt-0.5 truncate text-[11px] text-emerald-600">
|
||||
✓ {p.selectedSupplierName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
PurchaseEvaluationPhaseColor[p.phase],
|
||||
)}
|
||||
>
|
||||
{PurchaseEvaluationPhaseLabel[p.phase]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-slate-600">
|
||||
{PurchaseEvaluationTypeLabel[p.type]}
|
||||
</span>
|
||||
<SlaTimer deadline={p.slaDeadline} createdAt={p.createdAt} />
|
||||
</div>
|
||||
{p.contractId && (
|
||||
<div className="mt-1 text-[10px] text-brand-600">✓ Đã tạo HĐ</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Panel 2: Detail tabs */}
|
||||
<main className="hidden overflow-y-auto bg-slate-50 p-6 lg:block">
|
||||
{!selectedId && (
|
||||
<EmptyState icon={ClipboardCheck} title="Chọn phiếu ở danh sách" description="Chi tiết NCC + báo giá + duyệt sẽ hiển thị ở đây." />
|
||||
)}
|
||||
{selectedId && detail.isLoading && <div className="text-sm text-slate-500">Đang tải…</div>}
|
||||
{selectedId && detail.data && (
|
||||
<PeDetailTabs
|
||||
evaluation={detail.data}
|
||||
onBack={() => setParam('id', null)}
|
||||
onDelete={() => del.mutate(detail.data!.id)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Panel 3: Workflow + history */}
|
||||
<aside className="hidden overflow-y-auto border-l border-slate-200 bg-white p-4 lg:block">
|
||||
{!selectedId && (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-sm text-slate-400">
|
||||
<X className="mx-auto mb-2 h-5 w-5" />
|
||||
Quy trình duyệt sẽ hiện khi chọn phiếu.
|
||||
</div>
|
||||
)}
|
||||
{selectedId && detail.data && <PeWorkflowPanel evaluation={detail.data} />}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fullpage detail route cho mobile (/purchase-evaluations/:id)
|
||||
export function PurchaseEvaluationDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const id = location.pathname.split('/').pop()!
|
||||
const detail = useQuery({
|
||||
queryKey: ['pe-detail', id],
|
||||
queryFn: async () => (await api.get<PeDetailBundle>(`/purchase-evaluations/${id}`)).data,
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async () => api.delete(`/purchase-evaluations/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa.')
|
||||
navigate('/purchase-evaluations')
|
||||
},
|
||||
})
|
||||
if (detail.isLoading) return <div className="p-6 text-sm text-slate-500">Đang tải…</div>
|
||||
if (!detail.data) return <div className="p-6 text-sm text-red-600">Không tìm thấy phiếu.</div>
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<PeDetailTabs evaluation={detail.data} onBack={() => navigate('/purchase-evaluations')} onDelete={() => del.mutate()} />
|
||||
<PeWorkflowPanel evaluation={detail.data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
165
fe-admin/src/types/purchaseEvaluation.ts
Normal file
165
fe-admin/src/types/purchaseEvaluation.ts
Normal file
@ -0,0 +1,165 @@
|
||||
// Types cho module Duyệt NCC (PurchaseEvaluation) — mirror BE Domain.
|
||||
|
||||
export const PurchaseEvaluationType = {
|
||||
DuyetNcc: 1,
|
||||
DuyetNccPhuongAn: 2,
|
||||
} as const
|
||||
export type PurchaseEvaluationType = typeof PurchaseEvaluationType[keyof typeof PurchaseEvaluationType]
|
||||
|
||||
export const PurchaseEvaluationTypeLabel: Record<number, string> = {
|
||||
1: 'Duyệt NCC',
|
||||
2: 'Duyệt NCC - Phương án',
|
||||
}
|
||||
|
||||
export const PurchaseEvaluationTypeCode: Record<number, string> = {
|
||||
1: 'DuyetNcc',
|
||||
2: 'DuyetNccPhuongAn',
|
||||
}
|
||||
|
||||
export const PurchaseEvaluationPhase = {
|
||||
DangSoanThao: 1,
|
||||
ChoPurchasing: 2,
|
||||
ChoDuAn: 3,
|
||||
ChoCCM: 4,
|
||||
ChoCEODuyetPA: 5,
|
||||
ChoCEODuyetNCC: 6,
|
||||
DaDuyet: 7,
|
||||
TuChoi: 99,
|
||||
} as const
|
||||
export type PurchaseEvaluationPhase = typeof PurchaseEvaluationPhase[keyof typeof PurchaseEvaluationPhase]
|
||||
|
||||
export const PurchaseEvaluationPhaseLabel: Record<number, string> = {
|
||||
1: 'Đang soạn thảo',
|
||||
2: 'Chờ Purchasing',
|
||||
3: 'Chờ Dự án',
|
||||
4: 'Chờ CCM',
|
||||
5: 'Chờ CEO duyệt PA',
|
||||
6: 'Chờ CEO duyệt NCC',
|
||||
7: 'Đã duyệt',
|
||||
99: 'Từ chối',
|
||||
}
|
||||
|
||||
export const PurchaseEvaluationPhaseColor: Record<number, string> = {
|
||||
1: 'bg-slate-100 text-slate-700',
|
||||
2: 'bg-blue-100 text-blue-700',
|
||||
3: 'bg-orange-100 text-orange-700',
|
||||
4: 'bg-indigo-100 text-indigo-700',
|
||||
5: 'bg-fuchsia-100 text-fuchsia-700',
|
||||
6: 'bg-pink-100 text-pink-700',
|
||||
7: 'bg-emerald-100 text-emerald-700',
|
||||
99: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export type PeListItem = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
tenGoiThau: string
|
||||
type: number
|
||||
phase: number
|
||||
projectId: string
|
||||
projectName: string
|
||||
selectedSupplierId: string | null
|
||||
selectedSupplierName: string | null
|
||||
contractId: string | null
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PeSupplier = {
|
||||
id: string
|
||||
supplierId: string
|
||||
supplierName: string
|
||||
displayName: string | null
|
||||
contactName: string | null
|
||||
contactEmail: string | null
|
||||
contactPhone: string | null
|
||||
paymentTermText: string | null
|
||||
note: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
export type PeQuote = {
|
||||
id: string
|
||||
purchaseEvaluationDetailId: string
|
||||
purchaseEvaluationSupplierId: string
|
||||
bgVat: number
|
||||
chuaVat: number
|
||||
thanhTien: number
|
||||
isSelected: boolean
|
||||
note: string | null
|
||||
}
|
||||
|
||||
export type PeDetailRow = {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
itemCode: string | null
|
||||
noiDung: string
|
||||
donViTinh: string | null
|
||||
khoiLuongNganSach: number
|
||||
khoiLuongThiCong: number
|
||||
donGiaNganSach: number
|
||||
thanhTienNganSach: number
|
||||
order: number
|
||||
ghiChu: string | null
|
||||
quotes: PeQuote[]
|
||||
}
|
||||
|
||||
export type PeApproval = {
|
||||
id: string
|
||||
fromPhase: number
|
||||
toPhase: number
|
||||
approverUserId: string | null
|
||||
approverName: string | null
|
||||
decision: number
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
}
|
||||
|
||||
export type PeWorkflowSummary = {
|
||||
policyName: string
|
||||
policyDescription: string
|
||||
activePhases: number[]
|
||||
nextPhases: number[]
|
||||
}
|
||||
|
||||
export type PeChangelog = {
|
||||
id: string
|
||||
entityType: number
|
||||
entityId: string | null
|
||||
action: number
|
||||
phaseAtChange: number | null
|
||||
userId: string | null
|
||||
userName: string | null
|
||||
summary: string | null
|
||||
fieldChangesJson: string | null
|
||||
contextNote: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PeDetailBundle = {
|
||||
id: string
|
||||
maPhieu: string | null
|
||||
type: number
|
||||
phase: number
|
||||
tenGoiThau: string
|
||||
diaDiem: string | null
|
||||
moTa: string | null
|
||||
projectId: string
|
||||
projectName: string
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
drafterUserId: string | null
|
||||
drafterName: string | null
|
||||
selectedSupplierId: string | null
|
||||
selectedSupplierName: string | null
|
||||
contractId: string | null
|
||||
paymentTerms: string | null
|
||||
slaDeadline: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
suppliers: PeSupplier[]
|
||||
details: PeDetailRow[]
|
||||
approvals: PeApproval[]
|
||||
workflow: PeWorkflowSummary
|
||||
}
|
||||
Reference in New Issue
Block a user