[CLAUDE] PE: upload file dinh kem per-NCC (doi chieu bao gia)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m9s
User request: 'cho cac NCC 1,2 va 3 thi cho them cho upload file dinh
kem cho tung NCC de co the doi chieu'.
Entity PurchaseEvaluationAttachment + PurchaseEvaluationSupplierId nullable
da thiet ke san tu migration 12 — gio wire up BE + FE.
BE (Application/Api):
- PurchaseEvaluationAttachmentFeatures: Upload (multipart + supplierRowId
optional) + Download + Delete. Reuse IFileStorage + LocalFileStorage.
Validator 20MB + MIME whitelist (pdf/doc/docx/xls/xlsx/png/jpg/webp).
- Upload log vao PurchaseEvaluationChangelogs (Attachment + Insert).
- PurchaseEvaluationAttachmentDto + them field Attachments vao bundle.
- GetPurchaseEvaluationQueryHandler Include(x => x.Attachments) +
OrderByDescending(a => a.CreatedAt) projection.
- PurchaseEvaluationsController 3 endpoint:
POST /attachments (IFormFile + [FromForm] supplierRowId/purpose/note)
GET /attachments/{attId}/download (File stream)
DELETE /attachments/{attId}
- Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
FE (fe-admin + fe-user):
- Type PeAttachment + PeAttachmentPurpose/Label (QuoteDocument default)
- PeDetailBundle.attachments: PeAttachment[]
- SuppliersTab thay column Hien thi + Ghi chu bang column File dinh kem
(per-NCC upload + list N attachments + download + delete).
- SupplierAttachmentsCell component: <input type=file> hidden + [+ Them
file] button + inline list attachments voi Paperclip icon + filename
(click tai ve) + size + purpose chip + Trash2 delete.
This commit is contained in:
@ -2,11 +2,11 @@
|
|||||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||||
// → PeApprovalsSection + PeHistorySection).
|
// → PeApprovalsSection + PeHistorySection).
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
@ -16,10 +16,13 @@ import { api } from '@/lib/api'
|
|||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
|
PeAttachmentPurpose,
|
||||||
|
PeAttachmentPurposeLabel,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
PurchaseEvaluationTypeLabel,
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeAttachment,
|
||||||
type PeChangelog,
|
type PeChangelog,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
@ -265,25 +268,33 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
|||||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left">NCC</th>
|
<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">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">Điều khoản TT</th>
|
||||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||||||
<th className="px-3 py-2"></th>
|
<th className="px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{ev.suppliers.map(s => (
|
{ev.suppliers.map(s => (
|
||||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
<tr key={s.id} className={cn('align-top', 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">
|
||||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
<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>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||||
{s.contactName && <div>{s.contactName}</div>}
|
{s.contactName && <div>{s.contactName}</div>}
|
||||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</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">
|
||||||
|
<SupplierAttachmentsCell
|
||||||
|
evaluationId={ev.id}
|
||||||
|
supplierRowId={s.id}
|
||||||
|
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
@ -709,3 +720,121 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
|||||||
</ol>
|
</ol>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Cell upload file đính kèm per-NCC =====
|
||||||
|
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
|
||||||
|
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
|
||||||
|
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
|
||||||
|
function SupplierAttachmentsCell({
|
||||||
|
evaluationId,
|
||||||
|
supplierRowId,
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
evaluationId: string
|
||||||
|
supplierRowId: string
|
||||||
|
attachments: PeAttachment[]
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('supplierRowId', supplierRowId)
|
||||||
|
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
|
||||||
|
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã tải lên.')
|
||||||
|
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 className="space-y-1">
|
||||||
|
{attachments.length === 0 && (
|
||||||
|
<div className="text-[11px] italic text-slate-400">Chưa có file</div>
|
||||||
|
)}
|
||||||
|
{attachments.map(a => (
|
||||||
|
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||||
|
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => download(a)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={a.fileName}
|
||||||
|
>
|
||||||
|
{a.fileName}
|
||||||
|
</button>
|
||||||
|
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -105,6 +105,32 @@ export type PeDetailRow = {
|
|||||||
quotes: PeQuote[]
|
quotes: PeQuote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PeAttachment = {
|
||||||
|
id: string
|
||||||
|
purchaseEvaluationSupplierId: string | null
|
||||||
|
fileName: string
|
||||||
|
storagePath: string
|
||||||
|
fileSize: number
|
||||||
|
contentType: string
|
||||||
|
purpose: number
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PeAttachmentPurpose = {
|
||||||
|
QuoteDocument: 1,
|
||||||
|
RequirementSpec: 2,
|
||||||
|
DecisionExport: 3,
|
||||||
|
Other: 99,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PeAttachmentPurposeLabel: Record<number, string> = {
|
||||||
|
1: 'Báo giá',
|
||||||
|
2: 'Yêu cầu KT',
|
||||||
|
3: 'Phiếu duyệt',
|
||||||
|
99: 'Khác',
|
||||||
|
}
|
||||||
|
|
||||||
export type PeApproval = {
|
export type PeApproval = {
|
||||||
id: string
|
id: string
|
||||||
fromPhase: number
|
fromPhase: number
|
||||||
@ -161,5 +187,6 @@ export type PeDetailBundle = {
|
|||||||
suppliers: PeSupplier[]
|
suppliers: PeSupplier[]
|
||||||
details: PeDetailRow[]
|
details: PeDetailRow[]
|
||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
|
attachments: PeAttachment[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,11 @@
|
|||||||
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
|
||||||
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
|
||||||
// → PeApprovalsSection + PeHistorySection).
|
// → PeApprovalsSection + PeHistorySection).
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Check, Paperclip, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Dialog } from '@/components/ui/Dialog'
|
import { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
@ -16,10 +16,13 @@ import { api } from '@/lib/api'
|
|||||||
import { getErrorMessage } from '@/lib/apiError'
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
|
PeAttachmentPurpose,
|
||||||
|
PeAttachmentPurposeLabel,
|
||||||
PurchaseEvaluationPhase,
|
PurchaseEvaluationPhase,
|
||||||
PurchaseEvaluationPhaseColor,
|
PurchaseEvaluationPhaseColor,
|
||||||
PurchaseEvaluationPhaseLabel,
|
PurchaseEvaluationPhaseLabel,
|
||||||
PurchaseEvaluationTypeLabel,
|
PurchaseEvaluationTypeLabel,
|
||||||
|
type PeAttachment,
|
||||||
type PeChangelog,
|
type PeChangelog,
|
||||||
type PeDetailBundle,
|
type PeDetailBundle,
|
||||||
type PeDetailRow,
|
type PeDetailRow,
|
||||||
@ -265,25 +268,33 @@ function SuppliersTab({ ev }: { ev: PeDetailBundle }) {
|
|||||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left">NCC</th>
|
<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">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">Điều khoản TT</th>
|
||||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
<th className="px-3 py-2 text-left">File đính kèm</th>
|
||||||
<th className="px-3 py-2"></th>
|
<th className="px-3 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{ev.suppliers.map(s => (
|
{ev.suppliers.map(s => (
|
||||||
<tr key={s.id} className={cn(ev.selectedSupplierId === s.supplierId && 'bg-emerald-50')}>
|
<tr key={s.id} className={cn('align-top', 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">
|
||||||
<td className="px-3 py-2">{s.displayName ?? '—'}</td>
|
<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>}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-[12px] text-slate-600">
|
<td className="px-3 py-2 text-[12px] text-slate-600">
|
||||||
{s.contactName && <div>{s.contactName}</div>}
|
{s.contactName && <div>{s.contactName}</div>}
|
||||||
{s.contactPhone && <div>{s.contactPhone}</div>}
|
{s.contactPhone && <div>{s.contactPhone}</div>}
|
||||||
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
{s.contactEmail && <div className="truncate">{s.contactEmail}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">{s.paymentTermText ?? '—'}</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">
|
||||||
|
<SupplierAttachmentsCell
|
||||||
|
evaluationId={ev.id}
|
||||||
|
supplierRowId={s.id}
|
||||||
|
attachments={ev.attachments.filter(a => a.purchaseEvaluationSupplierId === s.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<button
|
<button
|
||||||
@ -709,3 +720,121 @@ function HistoryTab({ ev }: { ev: PeDetailBundle }) {
|
|||||||
</ol>
|
</ol>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Cell upload file đính kèm per-NCC =====
|
||||||
|
// 1 row = 1 NCC. User upload file báo giá (purpose=QuoteDocument mặc định) →
|
||||||
|
// POST multipart với supplierRowId. List N file hiện có + Download/Delete inline.
|
||||||
|
// Storage path: wwwroot/uploads/purchase-evaluations/{id}/{attId}_{safeName}
|
||||||
|
function SupplierAttachmentsCell({
|
||||||
|
evaluationId,
|
||||||
|
supplierRowId,
|
||||||
|
attachments,
|
||||||
|
}: {
|
||||||
|
evaluationId: string
|
||||||
|
supplierRowId: string
|
||||||
|
attachments: PeAttachment[]
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const upload = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('supplierRowId', supplierRowId)
|
||||||
|
fd.append('purpose', String(PeAttachmentPurpose.QuoteDocument))
|
||||||
|
return api.post(`/purchase-evaluations/${evaluationId}/attachments`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Đã tải lên.')
|
||||||
|
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 className="space-y-1">
|
||||||
|
{attachments.length === 0 && (
|
||||||
|
<div className="text-[11px] italic text-slate-400">Chưa có file</div>
|
||||||
|
)}
|
||||||
|
{attachments.map(a => (
|
||||||
|
<div key={a.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
|
||||||
|
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" />
|
||||||
|
<button
|
||||||
|
onClick={() => download(a)}
|
||||||
|
className="min-w-0 flex-1 truncate text-left text-slate-700 hover:text-brand-700 hover:underline"
|
||||||
|
title={a.fileName}
|
||||||
|
>
|
||||||
|
{a.fileName}
|
||||||
|
</button>
|
||||||
|
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(a.fileSize)}</span>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -105,6 +105,32 @@ export type PeDetailRow = {
|
|||||||
quotes: PeQuote[]
|
quotes: PeQuote[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PeAttachment = {
|
||||||
|
id: string
|
||||||
|
purchaseEvaluationSupplierId: string | null
|
||||||
|
fileName: string
|
||||||
|
storagePath: string
|
||||||
|
fileSize: number
|
||||||
|
contentType: string
|
||||||
|
purpose: number
|
||||||
|
note: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PeAttachmentPurpose = {
|
||||||
|
QuoteDocument: 1,
|
||||||
|
RequirementSpec: 2,
|
||||||
|
DecisionExport: 3,
|
||||||
|
Other: 99,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const PeAttachmentPurposeLabel: Record<number, string> = {
|
||||||
|
1: 'Báo giá',
|
||||||
|
2: 'Yêu cầu KT',
|
||||||
|
3: 'Phiếu duyệt',
|
||||||
|
99: 'Khác',
|
||||||
|
}
|
||||||
|
|
||||||
export type PeApproval = {
|
export type PeApproval = {
|
||||||
id: string
|
id: string
|
||||||
fromPhase: number
|
fromPhase: number
|
||||||
@ -161,5 +187,6 @@ export type PeDetailBundle = {
|
|||||||
suppliers: PeSupplier[]
|
suppliers: PeSupplier[]
|
||||||
details: PeDetailRow[]
|
details: PeDetailRow[]
|
||||||
approvals: PeApproval[]
|
approvals: PeApproval[]
|
||||||
|
attachments: PeAttachment[]
|
||||||
workflow: PeWorkflowSummary
|
workflow: PeWorkflowSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,6 +150,42 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
|||||||
public async Task<List<PurchaseEvaluationChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
public async Task<List<PurchaseEvaluationChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||||
=> await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct);
|
=> await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct);
|
||||||
|
|
||||||
|
// ========== Attachments (per-supplier hoặc general) ==========
|
||||||
|
|
||||||
|
// Upload file đính kèm — gắn với NCC cụ thể (supplierRowId) hoặc phiếu tổng.
|
||||||
|
[HttpPost("{id:guid}/attachments")]
|
||||||
|
[RequestSizeLimit(25_000_000)]
|
||||||
|
public async Task<ActionResult<PurchaseEvaluationAttachmentDto>> UploadAttachment(
|
||||||
|
Guid id,
|
||||||
|
IFormFile file,
|
||||||
|
[FromForm] Guid? supplierRowId = null,
|
||||||
|
[FromForm] PurchaseEvaluationAttachmentPurpose purpose = PurchaseEvaluationAttachmentPurpose.QuoteDocument,
|
||||||
|
[FromForm] string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0)
|
||||||
|
return BadRequest(new { detail = "Chưa chọn file." });
|
||||||
|
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var dto = await mediator.Send(new UploadPurchaseEvaluationAttachmentCommand(
|
||||||
|
id, supplierRowId, file.FileName, file.ContentType, file.Length, stream, purpose, note), ct);
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||||
|
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var f = await mediator.Send(new DownloadPurchaseEvaluationAttachmentQuery(id, attId), ct);
|
||||||
|
return File(f.Content, f.ContentType, f.FileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeletePurchaseEvaluationAttachmentCommand(id, attId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Kế thừa HĐ ==========
|
// ========== Kế thừa HĐ ==========
|
||||||
|
|
||||||
// List phiếu đã DaDuyet chưa gen HĐ — dùng cho modal "Tạo HĐ từ phiếu"
|
// List phiếu đã DaDuyet chưa gen HĐ — dùng cho modal "Tạo HĐ từ phiếu"
|
||||||
|
|||||||
@ -83,6 +83,17 @@ public record PurchaseEvaluationWorkflowSummaryDto(
|
|||||||
List<PurchaseEvaluationPhase> ActivePhases,
|
List<PurchaseEvaluationPhase> ActivePhases,
|
||||||
List<PurchaseEvaluationPhase> NextPhases);
|
List<PurchaseEvaluationPhase> NextPhases);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationAttachmentDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid? PurchaseEvaluationSupplierId,
|
||||||
|
string FileName,
|
||||||
|
string StoragePath,
|
||||||
|
long FileSize,
|
||||||
|
string ContentType,
|
||||||
|
PurchaseEvaluationAttachmentPurpose Purpose,
|
||||||
|
string? Note,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record PurchaseEvaluationDetailBundleDto(
|
public record PurchaseEvaluationDetailBundleDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string? MaPhieu,
|
string? MaPhieu,
|
||||||
@ -107,4 +118,5 @@ public record PurchaseEvaluationDetailBundleDto(
|
|||||||
List<PurchaseEvaluationSupplierDto> Suppliers,
|
List<PurchaseEvaluationSupplierDto> Suppliers,
|
||||||
List<PurchaseEvaluationDetailDto> Details,
|
List<PurchaseEvaluationDetailDto> Details,
|
||||||
List<PurchaseEvaluationApprovalDto> Approvals,
|
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||||
|
List<PurchaseEvaluationAttachmentDto> Attachments,
|
||||||
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||||
|
|||||||
@ -0,0 +1,184 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||||
|
using SolutionErp.Domain.Contracts; // ChangelogAction
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// Mirror ContractAttachmentFeatures. Key khác: `PurchaseEvaluationSupplierId`
|
||||||
|
// nullable → upload file gắn với 1 NCC cụ thể (để đối chiếu báo giá) hoặc
|
||||||
|
// tổng quan phiếu. Reuse IFileStorage + LocalFileStorage.
|
||||||
|
|
||||||
|
// ========== UPLOAD ==========
|
||||||
|
|
||||||
|
public record UploadPurchaseEvaluationAttachmentCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
Guid? PurchaseEvaluationSupplierId,
|
||||||
|
string FileName,
|
||||||
|
string ContentType,
|
||||||
|
long FileSize,
|
||||||
|
Stream Content,
|
||||||
|
PurchaseEvaluationAttachmentPurpose Purpose,
|
||||||
|
string? Note) : IRequest<PurchaseEvaluationAttachmentDto>;
|
||||||
|
|
||||||
|
public class UploadPurchaseEvaluationAttachmentCommandValidator
|
||||||
|
: AbstractValidator<UploadPurchaseEvaluationAttachmentCommand>
|
||||||
|
{
|
||||||
|
private const long MaxBytes = 20L * 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
public UploadPurchaseEvaluationAttachmentCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
|
||||||
|
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
|
||||||
|
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
|
||||||
|
.WithMessage("File vượt quá 20 MB.");
|
||||||
|
RuleFor(x => x.ContentType).Must(c => AllowedContentTypes.Contains(c))
|
||||||
|
.WithMessage("Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp.");
|
||||||
|
RuleFor(x => x.Purpose).IsInEnum();
|
||||||
|
RuleFor(x => x.Note).MaximumLength(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UploadPurchaseEvaluationAttachmentCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UploadPurchaseEvaluationAttachmentCommand, PurchaseEvaluationAttachmentDto>
|
||||||
|
{
|
||||||
|
public async Task<PurchaseEvaluationAttachmentDto> Handle(
|
||||||
|
UploadPurchaseEvaluationAttachmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ev = await db.PurchaseEvaluations.FirstOrDefaultAsync(e => e.Id == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
|
// Verify supplier-row thuộc cùng phiếu (nếu gắn)
|
||||||
|
if (request.PurchaseEvaluationSupplierId is Guid sid)
|
||||||
|
{
|
||||||
|
var supOk = await db.PurchaseEvaluationSuppliers
|
||||||
|
.AnyAsync(s => s.Id == sid && s.PurchaseEvaluationId == ev.Id, ct);
|
||||||
|
if (!supOk) throw new NotFoundException("PurchaseEvaluationSupplier", sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
var attId = Guid.NewGuid();
|
||||||
|
var safeName = SanitizeFileName(request.FileName);
|
||||||
|
var relativePath = $"purchase-evaluations/{ev.Id}/{attId}_{safeName}";
|
||||||
|
|
||||||
|
await storage.SaveAsync(relativePath, request.Content, ct);
|
||||||
|
|
||||||
|
var entity = new PurchaseEvaluationAttachment
|
||||||
|
{
|
||||||
|
Id = attId,
|
||||||
|
PurchaseEvaluationId = ev.Id,
|
||||||
|
PurchaseEvaluationSupplierId = request.PurchaseEvaluationSupplierId,
|
||||||
|
FileName = request.FileName,
|
||||||
|
StoragePath = relativePath,
|
||||||
|
FileSize = request.FileSize,
|
||||||
|
ContentType = request.ContentType,
|
||||||
|
Purpose = request.Purpose,
|
||||||
|
Note = request.Note,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationAttachments.Add(entity);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = ev.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Attachment,
|
||||||
|
EntityId = attId,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = ev.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Tải lên file: {entity.FileName}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new PurchaseEvaluationAttachmentDto(
|
||||||
|
entity.Id, entity.PurchaseEvaluationSupplierId, entity.FileName, entity.StoragePath,
|
||||||
|
entity.FileSize, entity.ContentType, entity.Purpose, entity.Note, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string name)
|
||||||
|
{
|
||||||
|
var baseName = Path.GetFileName(name);
|
||||||
|
foreach (var c in Path.GetInvalidFileNameChars())
|
||||||
|
baseName = baseName.Replace(c, '_');
|
||||||
|
baseName = baseName.TrimStart('.');
|
||||||
|
if (string.IsNullOrWhiteSpace(baseName)) baseName = "file";
|
||||||
|
return baseName.Length > 200 ? baseName[^200..] : baseName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DOWNLOAD ==========
|
||||||
|
|
||||||
|
public record DownloadPurchaseEvaluationAttachmentQuery(Guid PurchaseEvaluationId, Guid AttachmentId)
|
||||||
|
: IRequest<PurchaseEvaluationAttachmentFile>;
|
||||||
|
|
||||||
|
public record PurchaseEvaluationAttachmentFile(Stream Content, string FileName, string ContentType);
|
||||||
|
|
||||||
|
public class DownloadPurchaseEvaluationAttachmentQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage) : IRequestHandler<DownloadPurchaseEvaluationAttachmentQuery, PurchaseEvaluationAttachmentFile>
|
||||||
|
{
|
||||||
|
public async Task<PurchaseEvaluationAttachmentFile> Handle(
|
||||||
|
DownloadPurchaseEvaluationAttachmentQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var att = await db.PurchaseEvaluationAttachments.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||||
|
|
||||||
|
var stream = await storage.OpenReadAsync(att.StoragePath, ct);
|
||||||
|
return new PurchaseEvaluationAttachmentFile(stream, att.FileName, att.ContentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DELETE ==========
|
||||||
|
|
||||||
|
public record DeletePurchaseEvaluationAttachmentCommand(Guid PurchaseEvaluationId, Guid AttachmentId) : IRequest;
|
||||||
|
|
||||||
|
public class DeletePurchaseEvaluationAttachmentCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IFileStorage storage,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<DeletePurchaseEvaluationAttachmentCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeletePurchaseEvaluationAttachmentCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var att = await db.PurchaseEvaluationAttachments
|
||||||
|
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("Attachment", request.AttachmentId);
|
||||||
|
var ev = await db.PurchaseEvaluations.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == request.PurchaseEvaluationId, ct);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationAttachments.Remove(att);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Attachment,
|
||||||
|
EntityId = att.Id,
|
||||||
|
Action = ChangelogAction.Delete,
|
||||||
|
PhaseAtChange = ev?.Phase ?? PurchaseEvaluationPhase.DangSoanThao,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Xóa file: {att.FileName}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
try { await storage.DeleteAsync(att.StoragePath, ct); }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -312,6 +312,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
.Include(x => x.Suppliers)
|
.Include(x => x.Suppliers)
|
||||||
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
||||||
.Include(x => x.Approvals)
|
.Include(x => x.Approvals)
|
||||||
|
.Include(x => x.Attachments)
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
@ -390,6 +391,12 @@ public class GetPurchaseEvaluationQueryHandler(
|
|||||||
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
|
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
|
||||||
a.Decision, a.Comment, a.ApprovedAt))
|
a.Decision, a.Comment, a.ApprovedAt))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
e.Attachments
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Select(a => new PurchaseEvaluationAttachmentDto(
|
||||||
|
a.Id, a.PurchaseEvaluationSupplierId, a.FileName, a.StoragePath,
|
||||||
|
a.FileSize, a.ContentType, a.Purpose, a.Note, a.CreatedAt))
|
||||||
|
.ToList(),
|
||||||
new PurchaseEvaluationWorkflowSummaryDto(
|
new PurchaseEvaluationWorkflowSummaryDto(
|
||||||
policy.Name, policy.Description,
|
policy.Name, policy.Description,
|
||||||
policy.ActivePhases.ToList(),
|
policy.ActivePhases.ToList(),
|
||||||
|
|||||||
Reference in New Issue
Block a user