[CLAUDE] PE: section Bang so sanh + rename demo email @solutions.com.vn
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m10s

PART A: Section 'Bang so sanh' (file tong ho so so sanh)

User request: 'theo them cho thong tin ve Bang so sanh, cho dinh kem
file so sanh tong len'.

BE:
 - PurchaseEvaluationAttachmentPurpose.ComparisonTable = 4 (new enum value)
   Backend validator IsInEnum pass, khong can migration (int column).

FE types (2 app):
 - PeAttachmentPurpose.ComparisonTable + Label '4: Bang so sanh'.

FE PeDetailTabs:
 - Them section thu 4 'Bang so sanh (file tong)' sau 'Hang muc + Bao gia'.
 - Component GeneralAttachmentsSection: upload KHONG truyen supplierRowId
   (BE luu NULL) → purpose=ComparisonTable default. Filter attachments
   co supplierRowId===null de render.
 - Card layout khac SupplierAttachmentsCell: full-width card + brand color
   + purpose chip + date. Upload button to hon ([+ Tai len bang so sanh]).
 - readOnly hide upload + delete, giu download.

PART B: Demo email rebrand @solutionerp.local → @solutions.com.vn

User request: 'tao nguoi dung demo theo email cua ben nay'.

BE DbInitializer:
 - Rename 18 email in source: AdminEmail const + 17 demo users
   (bod/pm/ccm/pro/fin/act/equ/hra/qs/nv) — keep password + role unchanged.
 - Them BackfillUserEmailDomainAsync (idempotent): scan user co email
   @solutionerp.local, rename sang @solutions.com.vn, update Email +
   NormalizedEmail + UserName + NormalizedUserName. Skip neu co conflict
   user da ton tai voi email moi. Chay truoc SeedAdmin de tranh tao
   duplicate admin.

Admin permission tao user da co san qua /system/users page.
Comment input khi duyet da co san o PeWorkflowPanel (Ghi chu tuy chon
Textarea) + ContractDetailContent (Yeu cau sua / Duyet tiep dialog).
This commit is contained in:
pqhuy1987
2026-04-24 15:08:00 +07:00
parent eda9e84187
commit a336997cfe
6 changed files with 353 additions and 27 deletions

View File

@ -104,6 +104,13 @@ export function PeDetailTabs({
<Section title={`Hạng mục + Báo giá (${evaluation.details.length})`}>
<ItemsTab ev={evaluation} readOnly={readOnly} />
</Section>
<Section title="Bảng so sánh (file tổng)">
<GeneralAttachmentsSection
evaluationId={evaluation.id}
attachments={evaluation.attachments.filter(a => a.purchaseEvaluationSupplierId === null)}
readOnly={readOnly}
/>
</Section>
</div>
</div>
)
@ -869,3 +876,140 @@ function SupplierAttachmentsCell({
</div>
)
}
// ===== Section Bảng so sánh — general attachments (không gắn NCC cụ thể) =====
// Purpose mặc định = ComparisonTable (4). Upload file Excel/PDF tổng hợp so
// sánh giá N NCC × M hạng mục. Storage path giống SupplierAttachmentsCell
// nhưng supplierRowId KHÔNG truyền → BE lưu NULL.
function GeneralAttachmentsSection({
evaluationId,
attachments,
readOnly = false,
}: {
evaluationId: string
attachments: PeAttachment[]
readOnly?: boolean
}) {
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const upload = useMutation({
mutationFn: async (file: File) => {
const fd = new FormData()
fd.append('file', file)
// KHÔNG append supplierRowId → BE set NULL → general attachment
fd.append('purpose', String(PeAttachmentPurpose.ComparisonTable))
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
onSuccess: () => {
toast.success('Đã tải lên bảng so sánh.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
const del = useMutation({
mutationFn: async (attId: string) =>
api.delete(`/purchase-evaluations/${evaluationId}/attachments/${attId}`),
onSuccess: () => {
toast.success('Đã xóa.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
},
onError: e => toast.error(getErrorMessage(e)),
})
async function download(att: PeAttachment) {
try {
const res = await api.get(
`/purchase-evaluations/${evaluationId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
)
const url = window.URL.createObjectURL(res.data as Blob)
const a = document.createElement('a')
a.href = url
a.download = att.fileName
a.click()
window.URL.revokeObjectURL(url)
} catch (e) {
toast.error(getErrorMessage(e))
}
}
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0]
if (f) upload.mutate(f)
e.target.value = ''
}
const fmtSize = (b: number) =>
b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)}MB` : `${Math.round(b / 1024)}KB`
return (
<div>
{!readOnly && (
<p className="mb-2 text-[12px] text-slate-500">
File Excel/PDF tổng hợp so sánh giá của tất cả NCC (không gắn với 1 NCC cụ thể).
</p>
)}
{attachments.length === 0 && readOnly && (
<p className="text-sm italic text-slate-400">Chưa bảng so sánh.</p>
)}
{attachments.length > 0 && (
<div className="mb-2 space-y-1.5">
{attachments.map(a => (
<div
key={a.id}
className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm"
>
<Paperclip className="h-4 w-4 shrink-0 text-brand-500" />
<button
onClick={() => download(a)}
className="min-w-0 flex-1 truncate text-left font-medium text-slate-800 hover:text-brand-700 hover:underline"
title={a.fileName}
>
{a.fileName}
</button>
<span className="shrink-0 text-[11px] text-slate-500">{fmtSize(a.fileSize)}</span>
<span className="shrink-0 rounded bg-brand-50 px-1.5 py-0.5 text-[10px] text-brand-700">
{PeAttachmentPurposeLabel[a.purpose] ?? 'Khác'}
</span>
<span className="shrink-0 text-[10px] text-slate-400">
{new Date(a.createdAt).toLocaleDateString('vi-VN')}
</span>
{!readOnly && (
<button
onClick={() => { if (confirm(`Xóa "${a.fileName}"?`)) del.mutate(a.id) }}
className="shrink-0 rounded p-1 text-red-500 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</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.5 rounded border border-dashed border-brand-300 bg-brand-50/50 px-3 py-2 text-xs font-medium text-brand-700 hover:border-brand-500 hover:bg-brand-50 disabled:opacity-50"
>
<Upload className="h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải…' : '+ Tải lên bảng so sánh'}
</button>
</div>
)}
</div>
)
}

View File

@ -121,6 +121,7 @@ export const PeAttachmentPurpose = {
QuoteDocument: 1,
RequirementSpec: 2,
DecisionExport: 3,
ComparisonTable: 4,
Other: 99,
} as const
@ -128,6 +129,7 @@ export const PeAttachmentPurposeLabel: Record<number, string> = {
1: 'Báo giá',
2: 'Yêu cầu KT',
3: 'Phiếu duyệt',
4: 'Bảng so sánh',
99: 'Khác',
}