[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
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:
@ -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 có 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user