[CLAUDE] PE: readOnly mode cho menu 'Duyet' (pendingMe=1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m50s

User request: 'Menu duyet cua NCC -> chi de duyet thoi nhe khong co cac
action them sua j vao'.

Them readOnly prop vao PeDetailTabs — propagate xuong 3 sub-component
(InfoTab / SuppliersTab / ItemsTab) + SupplierAttachmentsCell. URL
pendingMe=1 (menu 'Duyet') → set readOnly=true.

Hide khi readOnly:
 - Header: [Sua header] [Xoa] button
 - SuppliersTab: [+ Them NCC] button + action column (Check winner/Pencil
   edit/Trash delete per row)
 - ItemsTab: [+ Them hang muc] button + action column (Pencil/Trash per
   row) + click cell bao gia popup
 - SupplierAttachmentsCell: [+ Them file] button + Trash delete icon
   (giu download tren file name)
 - InfoTab: [Tao HD tu phieu] button

Giu:
 - Moi thong tin doc-only
 - Download file dinh kem (click ten file)
 - Panel 3: Quy trinh + transition button (de action duyet phase)
 - [Dong] button
 - Chip 'che do duyet' gan phase badge de user biet mode

Mirror fe-admin + fe-user.
This commit is contained in:
pqhuy1987
2026-04-24 13:13:40 +07:00
parent 8cf1fe214a
commit eda9e84187
4 changed files with 238 additions and 174 deletions

View File

@ -39,10 +39,13 @@ export function PeDetailTabs({
evaluation,
onBack,
onDelete,
readOnly = false,
}: {
evaluation: PeDetailBundle
onBack: () => void
onDelete: () => void
/** Menu "Duyệt" (pendingMe=1) — ẩn mọi action thêm/sửa/xóa, chỉ xem + duyệt phase. */
readOnly?: boolean
}) {
const navigate = useNavigate()
const isDraft = evaluation.phase === PurchaseEvaluationPhase.DangSoanThao
@ -61,6 +64,11 @@ export function PeDetailTabs({
>
{PurchaseEvaluationPhaseLabel[evaluation.phase]}
</span>
{readOnly && (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600">
chế đ duyệt
</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>
@ -72,7 +80,7 @@ export function PeDetailTabs({
</div>
</div>
<div className="flex gap-2">
{isDraft && (
{isDraft && !readOnly && (
<>
<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
@ -88,13 +96,13 @@ export function PeDetailTabs({
<div className="divide-y divide-slate-200">
<Section title="Thông tin">
<InfoTab ev={evaluation} />
<InfoTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title={`NCC tham gia (${evaluation.suppliers.length})`}>
<SuppliersTab ev={evaluation} />
<SuppliersTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title={`Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} />
<ItemsTab ev={evaluation} readOnly={readOnly} />
</Section>
</div>
</div>
@ -131,8 +139,8 @@ export function PeHistorySection({ ev }: { ev: PeDetailBundle }) {
}
// ===== Tab: Thông tin =====
function InfoTab({ ev }: { ev: PeDetailBundle }) {
const canCreateContract = ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
function InfoTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
const [createOpen, setCreateOpen] = useState(false)
return (
<div className="space-y-4">
@ -236,7 +244,7 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) {
}
// ===== Tab: NCC =====
function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
function SuppliersTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [editRow, setEditRow] = useState<PeSupplier | null>(null)
@ -255,13 +263,17 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
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>
{!readOnly && (
<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 NCC. Thêm NCC đ bắt đu so sánh giá.</p>
<p className="text-sm text-slate-500">
{readOnly ? 'Chưa có NCC.' : '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">
@ -271,7 +283,7 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
<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">File đính kèm</th>
<th className="px-3 py-2"></th>
{!readOnly && <th className="px-3 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@ -281,6 +293,9 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
<div className="font-medium text-slate-900">{s.supplierName}</div>
{s.displayName && <div className="text-[11px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="mt-0.5 text-[11px] text-amber-600">{s.note}</div>}
{readOnly && ev.selectedSupplierId === s.supplierId && (
<div className="mt-0.5 text-[11px] font-medium text-emerald-700"> NCC đưc chọn</div>
)}
</td>
<td className="px-3 py-2 text-[12px] text-slate-600">
{s.contactName && <div>{s.contactName}</div>}
@ -293,38 +308,41 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
evaluationId={ev.id}
supplierRowId={s.id}
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
readOnly={readOnly}
/>
</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>
{!readOnly && (
<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>
@ -431,7 +449,7 @@ function EditSupplierDialog({ evaluationId, row, onClose }: { evaluationId: stri
}
// ===== Tab: Hạng mục + Báo giá (matrix) =====
function ItemsTab({ ev }: { ev: PeDetailBundle }) {
function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const qc = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [editDetail, setEditDetail] = useState<PeDetailRow | null>(null)
@ -451,12 +469,16 @@ function ItemsTab({ ev }: { ev: PeDetailBundle }) {
<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á.`}
? (readOnly ? 'Chưa có NCC tham gia.' : 'Thêm NCC ở tab "NCC" trước khi nhập báo giá.')
: readOnly
? `${ev.details.length} hạng mục × ${ev.suppliers.length} NCC`
: `${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>
{!readOnly && (
<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 ? (
@ -475,7 +497,7 @@ function ItemsTab({ ev }: { ev: PeDetailBundle }) {
{s.displayName ?? s.supplierName}
</th>
))}
<th className="px-2 py-2"></th>
{!readOnly && <th className="px-2 py-2"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@ -493,9 +515,10 @@ function ItemsTab({ ev }: { ev: PeDetailBundle }) {
return (
<td
key={s.id}
onClick={() => setQuoteEdit({ detail: d, supplier: s, existing: q })}
onClick={readOnly ? undefined : () => 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',
'border-r border-slate-200 px-2 py-2 text-right font-mono transition',
!readOnly && 'cursor-pointer hover:bg-brand-50',
q?.isSelected && 'bg-emerald-50 font-semibold text-emerald-700',
)}
>
@ -503,16 +526,18 @@ function ItemsTab({ ev }: { ev: PeDetailBundle }) {
</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>
{!readOnly && (
<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>
@ -729,10 +754,12 @@ function SupplierAttachmentsCell({
evaluationId,
supplierRowId,
attachments,
readOnly = false,
}: {
evaluationId: string
supplierRowId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
@ -809,32 +836,36 @@ function SupplierAttachmentsCell({
<span className="shrink-0 rounded bg-slate-200 px-1 text-[9px] text-slate-600">
{PeAttachmentPurposeLabel[a.purpose] ?? ''}
</span>
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3 w-3" />
</button>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
))}
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
<Upload className="h-3 w-3" />
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
</button>
</div>
{!readOnly && (
<div>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
onChange={onPick}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={upload.isPending}
className="inline-flex items-center gap-1 rounded border border-dashed border-slate-300 px-2 py-0.5 text-[11px] text-slate-500 hover:border-brand-300 hover:text-brand-700 disabled:opacity-50"
>
<Upload className="h-3 w-3" />
{upload.isPending ? 'Đang tải…' : '+ Thêm file'}
</button>
</div>
)}
</div>
)
}