[CLAUDE] PurchaseEvaluation: UAT dot 2 - an Tra lai/Tu choi khi tu duyet phieu minh soan + quick-add NCC ngay form + NCC go-tim sort A-Z + upload nhieu file 1 lan
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m25s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m25s
- PeWorkflowPanel x2: nguoi duyet == nguoi soan (drafterUserId == currentUser.id) -> an ca "Tra lai" + "Tu choi" (anh chot: tra cho chinh minh vo nghia, huy phieu = nho cap khac tu choi / xoa phieu Nhap). - SuppliersController: POST tao NCC mo cho moi user dang nhap (anh chot - nghiep vu di thau phat sinh NTP moi lien tuc); PUT/DELETE van khoa Admin+CatalogManager (S57). - PeDetailTabs AddSupplierDialog x2: Select -> SearchableSelect (go-tim bo dau, sort A-Z theo ma) + nut "+ NCC moi" quick-create (Ma/Ten/Loai/SDT/Email) -> POST /suppliers -> auto-select vao phieu. - Upload file bao gia + bang so sanh x2: input multiple + upload tuan tu tung file (UAT "moi lan chi chon duoc 1 file"). - SHA256 mirror x2 app, build tsc+vite x2 PASS, BE 0 err, test 240/240 PASS local.
This commit is contained in:
@ -11,6 +11,7 @@ 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'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { SearchableSelect } from '@/components/ui/SearchableSelect'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -41,6 +42,7 @@ import {
|
|||||||
type PeSupplier,
|
type PeSupplier,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||||
|
import { SupplierType, SupplierTypeLabel } from '@/types/master'
|
||||||
import type { Paged, Supplier } from '@/types/master'
|
import type { Paged, Supplier } from '@/types/master'
|
||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
@ -1328,6 +1330,28 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
|||||||
const hasError = !!(phoneError || emailError)
|
const hasError = !!(phoneError || emailError)
|
||||||
const showQuote = !!detailId
|
const showQuote = !!detailId
|
||||||
|
|
||||||
|
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
|
||||||
|
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
|
||||||
|
const createSup = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
|
||||||
|
code: newSup.code.trim(),
|
||||||
|
name: newSup.name.trim(),
|
||||||
|
type: newSup.type,
|
||||||
|
phone: newSup.phone.trim() || null,
|
||||||
|
email: newSup.email.trim() || null,
|
||||||
|
})).data,
|
||||||
|
onSuccess: async created => {
|
||||||
|
toast.success('Đã tạo NCC mới vào danh mục.')
|
||||||
|
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
|
||||||
|
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
|
||||||
|
setShowNew(false)
|
||||||
|
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const mut = useMutation({
|
const mut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Step 1: tạo NCC tham gia (PE.Suppliers row)
|
// Step 1: tạo NCC tham gia (PE.Suppliers row)
|
||||||
@ -1375,28 +1399,79 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>NCC (master)</Label>
|
<Label>NCC (master)</Label>
|
||||||
<Select
|
{/* S59 UAT (3 ý): gõ-tìm (SearchableSelect) + sort A-Z theo mã + "+ NCC mới"
|
||||||
|
tạo nhanh vào danh mục dùng ngay. Auto-fill liên hệ từ master giữ nguyên. */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<SearchableSelect
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
options={(suppliers.data ?? [])
|
||||||
|
.map(s => ({ value: s.id, label: `${s.code} — ${s.name}` }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'vi', { numeric: true }))}
|
||||||
value={form.supplierId}
|
value={form.supplierId}
|
||||||
onChange={e => {
|
onChange={id => {
|
||||||
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
|
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
|
||||||
// (contactPerson/phone/email/note). User vẫn override được sau đó.
|
// (contactPerson/phone/email/note). User vẫn override được sau đó.
|
||||||
const picked = suppliers.data?.find(s => s.id === e.target.value)
|
const picked = suppliers.data?.find(s => s.id === id)
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
supplierId: e.target.value,
|
supplierId: id,
|
||||||
contactName: picked?.contactPerson ?? '',
|
contactName: picked?.contactPerson ?? '',
|
||||||
contactPhone: picked?.phone ?? '',
|
contactPhone: picked?.phone ?? '',
|
||||||
contactEmail: picked?.email ?? '',
|
contactEmail: picked?.email ?? '',
|
||||||
note: picked?.note ?? '',
|
note: picked?.note ?? '',
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
|
placeholder="-- Chọn NCC (gõ để lọc) --"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(v => !v)}
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-lg border border-dashed border-brand-300 px-2.5 text-[11px] font-medium text-brand-700 hover:bg-brand-50"
|
||||||
>
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<Plus className="h-3 w-3" /> NCC mới
|
||||||
{suppliers.data?.map(s => (
|
</button>
|
||||||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
</div>
|
||||||
|
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600">✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.</p>}
|
||||||
|
{showNew && (
|
||||||
|
<div className="mt-2 space-y-2 rounded-lg border border-dashed border-brand-200 bg-brand-50/40 p-2.5">
|
||||||
|
<p className="text-[11px] font-medium text-brand-700">Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Mã *</Label>
|
||||||
|
<Input value={newSup.code} onChange={e => setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Loại *</Label>
|
||||||
|
<Select value={newSup.type} onChange={e => setNewSup({ ...newSup, type: Number(e.target.value) as SupplierType })}>
|
||||||
|
{Object.values(SupplierType).map(t => (
|
||||||
|
<option key={t} value={t}>{SupplierTypeLabel[t]}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600">✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.</p>}
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-[11px]">Tên *</Label>
|
||||||
|
<Input value={newSup.name} onChange={e => setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">SĐT</Label>
|
||||||
|
<Input value={newSup.phone} onChange={e => setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Email</Label>
|
||||||
|
<Input value={newSup.email} onChange={e => setNewSup({ ...newSup, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => createSup.mutate()}
|
||||||
|
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
|
||||||
@ -2324,10 +2399,13 @@ function SupplierAttachmentsCell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0]
|
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
|
||||||
if (f) upload.mutate(f)
|
const files = Array.from(e.target.files ?? [])
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
|
for (const f of files) {
|
||||||
|
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtSize = (b: number) =>
|
const fmtSize = (b: number) =>
|
||||||
@ -2389,6 +2467,7 @@ function SupplierAttachmentsCell({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||||
onChange={onPick}
|
onChange={onPick}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@ -2468,10 +2547,13 @@ function GeneralAttachmentsSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0]
|
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
|
||||||
if (f) upload.mutate(f)
|
const files = Array.from(e.target.files ?? [])
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
|
for (const f of files) {
|
||||||
|
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtSize = (b: number) =>
|
const fmtSize = (b: number) =>
|
||||||
@ -2548,6 +2630,7 @@ function GeneralAttachmentsSection({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||||
onChange={onPick}
|
onChange={onPick}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
|||||||
@ -264,6 +264,11 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
||||||
const isForwardApprove = !isSendBack && !isCancel
|
const isForwardApprove = !isSendBack && !isCancel
|
||||||
|
// S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
|
||||||
|
// người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
|
||||||
|
// (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
|
||||||
|
// = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
|
||||||
|
if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
|
||||||
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
||||||
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
|
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
|
||||||
const isDisabled = blockedByV2Level
|
const isDisabled = blockedByV2Level
|
||||||
|
|||||||
@ -11,6 +11,7 @@ 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'
|
||||||
import { Label } from '@/components/ui/Label'
|
import { Label } from '@/components/ui/Label'
|
||||||
|
import { SearchableSelect } from '@/components/ui/SearchableSelect'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { Textarea } from '@/components/ui/Textarea'
|
import { Textarea } from '@/components/ui/Textarea'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -41,6 +42,7 @@ import {
|
|||||||
type PeSupplier,
|
type PeSupplier,
|
||||||
} from '@/types/purchaseEvaluation'
|
} from '@/types/purchaseEvaluation'
|
||||||
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
import { BudgetPhase, type BudgetListItem } from '@/types/budget'
|
||||||
|
import { SupplierType, SupplierTypeLabel } from '@/types/master'
|
||||||
import type { Paged, Supplier } from '@/types/master'
|
import type { Paged, Supplier } from '@/types/master'
|
||||||
|
|
||||||
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
const fmtMoney = (v: number) => v.toLocaleString('vi-VN')
|
||||||
@ -1328,6 +1330,28 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
|||||||
const hasError = !!(phoneError || emailError)
|
const hasError = !!(phoneError || emailError)
|
||||||
const showQuote = !!detailId
|
const showQuote = !!detailId
|
||||||
|
|
||||||
|
// S59 UAT "Không tự thêm dc tên NTP mới" — anh chốt mở POST /suppliers cho mọi
|
||||||
|
// user đăng nhập (Sửa/Xóa vẫn Admin/CatalogManager). Tạo xong auto-select vào phiếu.
|
||||||
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [newSup, setNewSup] = useState({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
|
||||||
|
const createSup = useMutation({
|
||||||
|
mutationFn: async () => (await api.post<{ id: string }>('/suppliers', {
|
||||||
|
code: newSup.code.trim(),
|
||||||
|
name: newSup.name.trim(),
|
||||||
|
type: newSup.type,
|
||||||
|
phone: newSup.phone.trim() || null,
|
||||||
|
email: newSup.email.trim() || null,
|
||||||
|
})).data,
|
||||||
|
onSuccess: async created => {
|
||||||
|
toast.success('Đã tạo NCC mới vào danh mục.')
|
||||||
|
await qc.invalidateQueries({ queryKey: ['all-suppliers'] })
|
||||||
|
setForm(prev => ({ ...prev, supplierId: created.id, contactPhone: newSup.phone.trim(), contactEmail: newSup.email.trim() }))
|
||||||
|
setShowNew(false)
|
||||||
|
setNewSup({ code: '', name: '', type: SupplierType.NhaThauPhu as SupplierType, phone: '', email: '' })
|
||||||
|
},
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
const mut = useMutation({
|
const mut = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Step 1: tạo NCC tham gia (PE.Suppliers row)
|
// Step 1: tạo NCC tham gia (PE.Suppliers row)
|
||||||
@ -1375,28 +1399,79 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>NCC (master)</Label>
|
<Label>NCC (master)</Label>
|
||||||
<Select
|
{/* S59 UAT (3 ý): gõ-tìm (SearchableSelect) + sort A-Z theo mã + "+ NCC mới"
|
||||||
|
tạo nhanh vào danh mục dùng ngay. Auto-fill liên hệ từ master giữ nguyên. */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<SearchableSelect
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
options={(suppliers.data ?? [])
|
||||||
|
.map(s => ({ value: s.id, label: `${s.code} — ${s.name}` }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label, 'vi', { numeric: true }))}
|
||||||
value={form.supplierId}
|
value={form.supplierId}
|
||||||
onChange={e => {
|
onChange={id => {
|
||||||
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
|
// Session 20 turn 10: auto-fill các field NCC từ master data sẵn có
|
||||||
// (contactPerson/phone/email/note). User vẫn override được sau đó.
|
// (contactPerson/phone/email/note). User vẫn override được sau đó.
|
||||||
const picked = suppliers.data?.find(s => s.id === e.target.value)
|
const picked = suppliers.data?.find(s => s.id === id)
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
supplierId: e.target.value,
|
supplierId: id,
|
||||||
contactName: picked?.contactPerson ?? '',
|
contactName: picked?.contactPerson ?? '',
|
||||||
contactPhone: picked?.phone ?? '',
|
contactPhone: picked?.phone ?? '',
|
||||||
contactEmail: picked?.email ?? '',
|
contactEmail: picked?.email ?? '',
|
||||||
note: picked?.note ?? '',
|
note: picked?.note ?? '',
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
|
placeholder="-- Chọn NCC (gõ để lọc) --"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNew(v => !v)}
|
||||||
|
className="inline-flex h-8 shrink-0 items-center gap-1 whitespace-nowrap rounded-lg border border-dashed border-brand-300 px-2.5 text-[11px] font-medium text-brand-700 hover:bg-brand-50"
|
||||||
>
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<Plus className="h-3 w-3" /> NCC mới
|
||||||
{suppliers.data?.map(s => (
|
</button>
|
||||||
<option key={s.id} value={s.id}>{s.code} — {s.name}</option>
|
</div>
|
||||||
|
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600">✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.</p>}
|
||||||
|
{showNew && (
|
||||||
|
<div className="mt-2 space-y-2 rounded-lg border border-dashed border-brand-200 bg-brand-50/40 p-2.5">
|
||||||
|
<p className="text-[11px] font-medium text-brand-700">Tạo NCC mới vào danh mục (dùng ngay cho phiếu này)</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Mã *</Label>
|
||||||
|
<Input value={newSup.code} onChange={e => setNewSup({ ...newSup, code: e.target.value.toUpperCase() })} placeholder="VD: NTP-THANGLONG" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Loại *</Label>
|
||||||
|
<Select value={newSup.type} onChange={e => setNewSup({ ...newSup, type: Number(e.target.value) as SupplierType })}>
|
||||||
|
{Object.values(SupplierType).map(t => (
|
||||||
|
<option key={t} value={t}>{SupplierTypeLabel[t]}</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
{form.supplierId && <p className="mt-1 text-[10px] text-emerald-600">✓ Đã tự điền từ Master — bạn có thể sửa lại nếu cần.</p>}
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-[11px]">Tên *</Label>
|
||||||
|
<Input value={newSup.name} onChange={e => setNewSup({ ...newSup, name: e.target.value })} placeholder="CÔNG TY ..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">SĐT</Label>
|
||||||
|
<Input value={newSup.phone} onChange={e => setNewSup({ ...newSup, phone: e.target.value })} placeholder="0987654321" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[11px]">Email</Label>
|
||||||
|
<Input value={newSup.email} onChange={e => setNewSup({ ...newSup, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={() => createSup.mutate()}
|
||||||
|
disabled={!newSup.code.trim() || !newSup.name.trim() || createSup.isPending}
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
>
|
||||||
|
{createSup.isPending ? 'Đang tạo…' : 'Tạo & chọn'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<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>Hiển thị</Label><Input value={form.displayName} onChange={e => setForm({ ...form, displayName: e.target.value })} placeholder="vd TGN-30 ngày" /></div>
|
||||||
@ -2324,10 +2399,13 @@ function SupplierAttachmentsCell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0]
|
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
|
||||||
if (f) upload.mutate(f)
|
const files = Array.from(e.target.files ?? [])
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
|
for (const f of files) {
|
||||||
|
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtSize = (b: number) =>
|
const fmtSize = (b: number) =>
|
||||||
@ -2389,6 +2467,7 @@ function SupplierAttachmentsCell({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||||
onChange={onPick}
|
onChange={onPick}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@ -2468,10 +2547,13 @@ function GeneralAttachmentsSection({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const f = e.target.files?.[0]
|
// S59 UAT "mỗi lần chỉ chọn được 1 file" → input multiple, upload tuần tự từng file.
|
||||||
if (f) upload.mutate(f)
|
const files = Array.from(e.target.files ?? [])
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
|
for (const f of files) {
|
||||||
|
try { await upload.mutateAsync(f) } catch { /* toast lỗi đã hiện ở onError */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtSize = (b: number) =>
|
const fmtSize = (b: number) =>
|
||||||
@ -2548,6 +2630,7 @@ function GeneralAttachmentsSection({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.webp"
|
||||||
onChange={onPick}
|
onChange={onPick}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
|||||||
@ -35,8 +35,6 @@ export function PeWorkflowPanel({
|
|||||||
const [target, setTarget] = useState<number | null>(null)
|
const [target, setTarget] = useState<number | null>(null)
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
|
// Mig 28 (S21 t4) — F1 mode Trả lại. Default Drafter (backward compat S17).
|
||||||
// S23 t2 fix: default sang first available F1 mode (mode đang gửi duyệt) khi
|
|
||||||
// admin tick — Drafter (= trả về Người soạn) chỉ default khi không có F1 nào.
|
|
||||||
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
|
const [returnMode, setReturnMode] = useState<WorkflowReturnMode>(WorkflowReturnMode.Drafter)
|
||||||
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
|
const [returnTargetUserId, setReturnTargetUserId] = useState<string | null>(null)
|
||||||
// Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in
|
// Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in
|
||||||
@ -46,8 +44,7 @@ export function PeWorkflowPanel({
|
|||||||
const { user: currentUser } = useAuth()
|
const { user: currentUser } = useAuth()
|
||||||
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
|
||||||
|
|
||||||
// Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại). Null nếu
|
// Mig 29 (S21 t5) — F1 options per-Level (Cấp Approver hiện tại).
|
||||||
// V1 legacy hoặc pointer chưa init → fallback chỉ "Trả về Drafter".
|
|
||||||
const levelOptions = evaluation.currentLevelOptions
|
const levelOptions = evaluation.currentLevelOptions
|
||||||
|
|
||||||
// S23 t2 fix bro UAT: khi admin tick AllowReturnToAssignee/OneLevel/OneStep
|
// S23 t2 fix bro UAT: khi admin tick AllowReturnToAssignee/OneLevel/OneStep
|
||||||
@ -69,16 +66,18 @@ export function PeWorkflowPanel({
|
|||||||
// List approvers đã ký (cho mode Assignee dropdown pick)
|
// List approvers đã ký (cho mode Assignee dropdown pick)
|
||||||
const signedApprovers = (evaluation.levelOpinions ?? [])
|
const signedApprovers = (evaluation.levelOpinions ?? [])
|
||||||
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
|
.map(o => ({ userId: o.approverUserId, fullName: o.approverFullName ?? 'NV' }))
|
||||||
// Dedupe by userId (1 NV có thể ký nhiều cấp nếu workflow đặt như vậy)
|
|
||||||
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
.filter((v, i, arr) => arr.findIndex(x => x.userId === v.userId) === i)
|
||||||
|
|
||||||
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
// Mig 24 — V2 schema chỉ cho phép approver trong CurrentApproval.approvers
|
||||||
// thao tác cấp hiện tại. UAT S22+1: disable cả 3 button (Duyệt + Trả lại
|
// thao tác cấp hiện tại. UAT S22+1 feedback: "không quyền thao tác = ko quyền
|
||||||
// + Từ chối) khi actor không match. BE mirror EnsureCanRejectV2Async.
|
// mọi hành động" — disable cả 3 button (Duyệt + Trả lại + Từ chối) khi actor
|
||||||
|
// không match currentLevel.ApproverUserId. BE mirror guard trong
|
||||||
|
// EnsureCanRejectV2Async (defense-in-depth — UI disable + BE reject).
|
||||||
// Admin bypass.
|
// Admin bypass.
|
||||||
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
const v2Approvers = evaluation.currentApproval?.approvers ?? []
|
||||||
const actorInV2Level = isAdmin
|
const actorInV2Level = isAdmin
|
||||||
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
|
|| (currentUser?.id && v2Approvers.some(a => a.userId === currentUser.id))
|
||||||
|
// V2 active = phiếu pin V2 + Phase=ChoDuyet + có currentApproval
|
||||||
const isV2Pending = !!evaluation.currentApproval
|
const isV2Pending = !!evaluation.currentApproval
|
||||||
const blockedByV2Level = isV2Pending && !actorInV2Level
|
const blockedByV2Level = isV2Pending && !actorInV2Level
|
||||||
|
|
||||||
@ -221,13 +220,15 @@ export function PeWorkflowPanel({
|
|||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phiếu V1 legacy không có flow → fallback hiển thị phase summary đơn giản */}
|
||||||
{!flow && (
|
{!flow && (
|
||||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||||
Phiếu này dùng quy trình cũ — workflow chi tiết không khả dụng.
|
Phiếu này dùng quy trình cũ — workflow chi tiết không khả dụng.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mig 24 — V2 banner Bước/Cấp + danh sách NV duyệt */}
|
{/* Mig 24 — V2 banner: hiển thị Bước/Cấp hiện tại + danh sách NV được duyệt.
|
||||||
|
Nếu actor không có trong list → banner amber + nút Duyệt sẽ disable. */}
|
||||||
{isV2Pending && evaluation.currentApproval && !readOnly && (
|
{isV2Pending && evaluation.currentApproval && !readOnly && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded border px-3 py-2 text-[11px]',
|
'rounded border px-3 py-2 text-[11px]',
|
||||||
@ -263,6 +264,11 @@ export function PeWorkflowPanel({
|
|||||||
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
|
||||||
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
const isCancel = p === PurchaseEvaluationPhase.TuChoi
|
||||||
const isForwardApprove = !isSendBack && !isCancel
|
const isForwardApprove = !isSendBack && !isCancel
|
||||||
|
// S59 anh chốt (UAT: "nhân viên tạo phiếu thì trả lại và từ chối cho ai?"):
|
||||||
|
// người duyệt CHÍNH LÀ người soạn phiếu → ẩn cả Trả lại + Từ chối
|
||||||
|
// (trả cho chính mình vô nghĩa — đang sửa inline được; hủy phiếu sai
|
||||||
|
// = nhờ cấp khác Từ chối, phiếu Nháp có nút Xóa riêng).
|
||||||
|
if ((isSendBack || isCancel) && evaluation.drafterUserId === currentUser?.id) return null
|
||||||
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
// Mig 24 + UAT S22+1 — disable cả 3 button khi actor không match
|
||||||
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
|
// currentLevel.ApproverUserId. "Không quyền = ko quyền mọi hành động."
|
||||||
const isDisabled = blockedByV2Level
|
const isDisabled = blockedByV2Level
|
||||||
@ -301,9 +307,9 @@ export function PeWorkflowPanel({
|
|||||||
|
|
||||||
{target !== null && (() => {
|
{target !== null && (() => {
|
||||||
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
const isCancel = target === PurchaseEvaluationPhase.TuChoi
|
||||||
// isSendBack sync với button label L205-207 + payload isReject L64-68
|
// isSendBack sync với button label + payload isReject (gotcha #45).
|
||||||
// (gotcha #45). Include cả DangSoanThao (legacy Mig 16) lẫn TraLai
|
// Include cả DangSoanThao (legacy Mig 16) lẫn TraLai (Session 17 spec)
|
||||||
// (Session 17 spec) — cả 2 là Trả lại Drafter sửa.
|
// — cả 2 là Trả lại Drafter sửa.
|
||||||
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
const isSendBack = (target === PurchaseEvaluationPhase.DangSoanThao
|
||||||
|| target === PurchaseEvaluationPhase.TraLai)
|
|| target === PurchaseEvaluationPhase.TraLai)
|
||||||
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
&& evaluation.phase !== PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
|||||||
@ -31,7 +31,9 @@ public class SuppliersController(IMediator mediator) : ControllerBase
|
|||||||
|
|
||||||
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role review/test;
|
// [S57] Master write khóa Admin+CatalogManager (đọc mở cho mọi role review/test;
|
||||||
// chống nhân viên sửa/xóa NCC production qua API khi menu hiện cho toàn bộ phận).
|
// chống nhân viên sửa/xóa NCC production qua API khi menu hiện cho toàn bộ phận).
|
||||||
[Authorize(Roles = "Admin,CatalogManager")]
|
// [S59] anh chốt (UAT "Không tự thêm dc tên NTP mới"): TẠO MỚI mở cho mọi user
|
||||||
|
// đăng nhập — nghiệp vụ đi thầu phát sinh NCC/NTP liên tục, quick-add ngay form
|
||||||
|
// thêm NCC vào phiếu. SỬA/XÓA vẫn khóa Admin+CatalogManager (2 action dưới).
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<Guid>> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
|
public async Task<ActionResult<Guid>> Create([FromBody] CreateSupplierCommand cmd, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user