[CLAUDE] PurchaseEvaluation: PE gắn Hạng mục công việc (Mig 49) + mở quyền Pe all-role + menu Cá nhân + khóa 14 demo user
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m24s
Sếp chốt deadline 15:00 (Zalo 11:02-11:17): flow tạo phiếu chọn quy trình → dự án → HẠNG MỤC → NCC/TP; phiếu dạng «Dự án – Hạng mục»; all-user thấy Duyệt NCC + master config; clear data cũ. - Mig 49 AddWorkItemToPurchaseEvaluation: PE.WorkItemId Guid? loose-Guid + index (KHÔNG FK vật lý — convention PE, database-agent design). Validator NotEmpty (create) + FK-guard AnyAsync(IsActive) → Conflict + UpdateDraft NULL-SAFE (client không gửi → giữ, chống null-hóa bug-class S42). 3 projection ListItemDto LEFT-join WorkItems. - FE ×2 app: PeWorkspaceCreateView select «c. Hạng mục *» + PeHeaderForm (load existing + PUT gửi lại, SHA256 IDENTICAL) + PeDetailTabs (header «Dự án – Hạng mục» + FormRow + inline khóa) + types. Route reuse /catalogs/work-items. - Perm: SeedAllRolesReviewReadPermissionsAsync extend Pe_* 11 key (factory — Pe leaf không nằm All) CanRead+CanCreate upgrade-only mọi role; PeWf_*/AwV2 GIỮ Admin. HRM/Office/Master/Catalogs CanRead (S57). Master write-lock Admin,CatalogManager ×3 controller. - Menu «Cá nhân» (Personal root 30, mirror Puro) + Chấm công re-parent + HrmConfig→Master + parentBackfill idempotent + admin bỏ ẩn Master (đảo S29). - LockDemoSampleUsersAsync: khóa 14/16 sample (GIỮ nv.cao+nv.truong IT-pool + catalog.manager) — ungated idempotent, IsActive=0+Lockout+SecurityStamp rotate. - Tests +12 PeWorkItemGuardTests (validator/FK-guard/null-safe) → 240 PASS. npm ×2 + BE 0W/0E. - Excel (3) đối chiếu: 62/71/3 identical S55 — no data change. - Gate: em main evidence-checklist (2 reviewer-spawn die-0-byte — resume-kill; backstop 12 guard-test + authz-key/role-string/Mig-49 evidence-lệnh). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -191,6 +191,8 @@ export function PeDetailTabs({
|
||||
<span>{PurchaseEvaluationTypeLabel[evaluation.type]}</span>
|
||||
<span>·</span>
|
||||
<span>{evaluation.projectName}</span>
|
||||
{/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (lời sếp) */}
|
||||
{evaluation.workItemName && <><span>–</span><span>{evaluation.workItemName}</span></>}
|
||||
{evaluation.drafterName && <><span>·</span><span>Soạn: {evaluation.drafterName}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
@ -672,6 +674,8 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
)}
|
||||
</div>
|
||||
<FormRow label="b. Dự án" value={ev.projectName} />
|
||||
{/* S57bis — Hạng mục công việc (WorkItem master). Phiếu cũ null → "—". */}
|
||||
<FormRow label="c. Hạng mục công việc" value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode} — ` : ''}${ev.workItemName}` : '—'} />
|
||||
{(ev.diaDiem || ev.moTa || ev.paymentTerms) && (
|
||||
<div className="mt-3 rounded bg-slate-50 px-3 py-2 text-[12px] text-slate-600">
|
||||
{ev.diaDiem && <div><span className="text-slate-400">Địa điểm:</span> {ev.diaDiem}</div>}
|
||||
@ -699,6 +703,11 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
|
||||
<Label className="text-[11px]">b. Dự án (khóa)</Label>
|
||||
<Input value={ev.projectName} disabled className="bg-slate-100" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
{/* S57bis — hạng mục khóa ở inline-edit; đổi qua "Sửa header phiếu" (PeHeaderForm). */}
|
||||
<Label className="text-[11px]">c. Hạng mục công việc (khóa)</Label>
|
||||
<Input value={ev.workItemName ? `${ev.workItemCode ? `${ev.workItemCode} — ` : ''}${ev.workItemName}` : '—'} disabled className="bg-slate-100" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Địa điểm</Label>
|
||||
<Input
|
||||
|
||||
@ -24,6 +24,15 @@ import type { Paged, Project } from '@/types/master'
|
||||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||||
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
|
||||
|
||||
// S57bis — Hạng mục công việc (WorkItem master, mirror PeWorkspaceCreateView).
|
||||
type WorkItemOption = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
category?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function PeHeaderForm({
|
||||
editId,
|
||||
defaultType,
|
||||
@ -49,10 +58,18 @@ export function PeHeaderForm({
|
||||
enabled: !!editId,
|
||||
})
|
||||
|
||||
// S57bis — list Hạng mục công việc (active only, mirror PeWorkspaceCreateView).
|
||||
const workItems = useQuery({
|
||||
queryKey: ['catalogs', 'work-items'],
|
||||
queryFn: async () => (await api.get<WorkItemOption[]>('/catalogs/work-items')).data,
|
||||
select: rows => rows.filter(r => r.isActive !== false),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
type: initialType as number,
|
||||
tenGoiThau: '',
|
||||
projectId: '',
|
||||
workItemId: '',
|
||||
diaDiem: '',
|
||||
moTa: '',
|
||||
paymentTerms: '',
|
||||
@ -82,6 +99,7 @@ export function PeHeaderForm({
|
||||
type: existing.data.type,
|
||||
tenGoiThau: existing.data.tenGoiThau,
|
||||
projectId: existing.data.projectId,
|
||||
workItemId: existing.data.workItemId ?? '', // S57bis — load để PUT gửi lại, tránh null-hóa
|
||||
diaDiem: existing.data.diaDiem ?? '',
|
||||
moTa: existing.data.moTa ?? '',
|
||||
paymentTerms: existing.data.paymentTerms ?? '',
|
||||
@ -113,6 +131,7 @@ export function PeHeaderForm({
|
||||
return api.put(`/purchase-evaluations/${editId}`, {
|
||||
id: editId,
|
||||
tenGoiThau: form.tenGoiThau,
|
||||
workItemId: form.workItemId || null, // S57bis — BE null-safe: null = giữ nguyên
|
||||
diaDiem: form.diaDiem || null,
|
||||
moTa: form.moTa || null,
|
||||
paymentTerms: form.paymentTerms || null,
|
||||
@ -123,6 +142,7 @@ export function PeHeaderForm({
|
||||
type: form.type,
|
||||
tenGoiThau: form.tenGoiThau,
|
||||
projectId: form.projectId,
|
||||
workItemId: form.workItemId || null, // S57bis — create require (BE validator NotEmpty)
|
||||
diaDiem: form.diaDiem || null,
|
||||
moTa: form.moTa || null,
|
||||
paymentTerms: form.paymentTerms || null,
|
||||
@ -187,6 +207,23 @@ export function PeHeaderForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* S57bis — phiếu dạng "Dự án – Hạng mục công việc" (sếp chốt). Edit-mode
|
||||
CHO đổi (BE UpdateDraft hỗ trợ); phiếu cũ null → bắt đầu rỗng, không ép. */}
|
||||
<Label>Hạng mục công việc {!editId && '*'}</Label>
|
||||
<Select
|
||||
value={form.workItemId}
|
||||
onChange={e => setForm({ ...form, workItemId: e.target.value })}
|
||||
>
|
||||
<option value="">{editId ? '— (giữ nguyên / chưa gắn)' : '-- Chọn --'}</option>
|
||||
{workItems.data?.map(w => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<Label className="mb-0">Ngân sách (đối chiếu chi phí)</Label>
|
||||
@ -272,7 +309,7 @@ export function PeHeaderForm({
|
||||
)}
|
||||
<Button
|
||||
onClick={() => mut.mutate()}
|
||||
disabled={!form.tenGoiThau || !form.projectId || mut.isPending}
|
||||
disabled={!form.tenGoiThau || !form.projectId || (!editId && !form.workItemId) || mut.isPending}
|
||||
>
|
||||
{editId ? 'Lưu' : 'Tạo phiếu'}
|
||||
</Button>
|
||||
|
||||
@ -24,6 +24,16 @@ import type { Paged, Project } from '@/types/master'
|
||||
const parseVnd = (s: string): number => Number(s.replace(/[^\d]/g, '')) || 0
|
||||
const formatVndInput = (n: number): string => (n > 0 ? n.toLocaleString('vi-VN') : '')
|
||||
|
||||
// S57bis — Hạng mục công việc (WorkItem master). Reuse catalog endpoint
|
||||
// /catalogs/work-items (cùng query key CatalogsPage + ContractDetailsTab dùng).
|
||||
type WorkItemOption = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
category?: string | null
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
// Preset điều khoản thanh toán phổ biến — user chọn 1 trong list, hoặc "Khác"
|
||||
// để nhập tay. Save as plain text (không JSON như cũ — code-style không phù
|
||||
// hợp UI cho end-user). User 2026-05-07 chỉnh.
|
||||
@ -55,6 +65,7 @@ export function PeWorkspaceCreateView({
|
||||
type: defaultType,
|
||||
tenGoiThau: '',
|
||||
projectId: '',
|
||||
workItemId: '',
|
||||
diaDiem: '',
|
||||
moTa: '',
|
||||
paymentTerms: '',
|
||||
@ -75,6 +86,13 @@ export function PeWorkspaceCreateView({
|
||||
queryFn: async () => (await api.get<{ items: Project[] }>('/projects', { params: { pageSize: 1000 } })).data.items,
|
||||
})
|
||||
|
||||
// S57bis — list Hạng mục công việc (active only — filter client nếu BE trả isActive).
|
||||
const workItems = useQuery({
|
||||
queryKey: ['catalogs', 'work-items'],
|
||||
queryFn: async () => (await api.get<WorkItemOption[]>('/catalogs/work-items')).data,
|
||||
select: rows => rows.filter(r => r.isActive !== false),
|
||||
})
|
||||
|
||||
// Mig 23 — fetch list quy trình duyệt V2 (filter ApplicableType khớp defaultType).
|
||||
// Mig 25 — chỉ hiện workflows admin đã ghim "cho user chọn" (IsUserSelectable=true).
|
||||
const approvalWorkflows = useQuery({
|
||||
@ -110,6 +128,7 @@ export function PeWorkspaceCreateView({
|
||||
type: form.type,
|
||||
tenGoiThau: form.tenGoiThau,
|
||||
projectId: form.projectId,
|
||||
workItemId: form.workItemId || null,
|
||||
diaDiem: form.diaDiem || null,
|
||||
moTa: form.moTa || null,
|
||||
paymentTerms: form.paymentTerms || null,
|
||||
@ -126,7 +145,7 @@ export function PeWorkspaceCreateView({
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.approvalWorkflowId && !create.isPending
|
||||
const canSubmit = !!form.tenGoiThau && !!form.projectId && !!form.workItemId && !!form.approvalWorkflowId && !create.isPending
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
@ -190,6 +209,26 @@ export function PeWorkspaceCreateView({
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-[11px]">c. Hạng mục công việc *</Label>
|
||||
<Select
|
||||
value={form.workItemId}
|
||||
onChange={e => setForm({ ...form, workItemId: e.target.value })}
|
||||
required
|
||||
>
|
||||
<option value="">— Chọn hạng mục công việc —</option>
|
||||
{workItems.data?.map(w => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.category ? `[${w.category}] ` : ''}{w.code} — {w.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{workItems.data && workItems.data.length === 0 && (
|
||||
<p className="mt-1 text-[11px] text-amber-700">
|
||||
⚠ Chưa có hạng mục công việc nào. Vào Danh mục → Hạng mục công việc để tạo trước.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Địa điểm</Label>
|
||||
<Input
|
||||
|
||||
@ -65,6 +65,8 @@ export const MenuKeys = {
|
||||
OffItTicket: 'Off_ItTicket',
|
||||
OffChamCong: 'Off_ChamCong',
|
||||
HrmDashboard: 'Hrm_Dashboard',
|
||||
// [S57] Nhóm "Cá nhân" (mirror Puro) — Chấm công re-parent Off → Personal
|
||||
Personal: 'Personal',
|
||||
} as const
|
||||
|
||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||
|
||||
@ -112,6 +112,9 @@ export type PeListItem = {
|
||||
phase: number
|
||||
projectId: string
|
||||
projectName: string
|
||||
// S57bis — Hạng mục công việc (nullable cho phiếu cũ). BE list DTO trả workItemName.
|
||||
workItemId: string | null
|
||||
workItemName: string | null
|
||||
selectedSupplierId: string | null
|
||||
selectedSupplierName: string | null
|
||||
contractId: string | null
|
||||
@ -386,6 +389,11 @@ export type PeDetailBundle = {
|
||||
moTa: string | null
|
||||
projectId: string
|
||||
projectName: string
|
||||
// S57bis — Hạng mục công việc (WorkItem master) gắn header phiếu. Nullable cho
|
||||
// phiếu cũ tạo trước khi field này tồn tại. BE PE DTO trả 3 field này.
|
||||
workItemId: string | null
|
||||
workItemName: string | null
|
||||
workItemCode: string | null
|
||||
departmentId: string | null
|
||||
departmentName: string | null
|
||||
drafterUserId: string | null
|
||||
|
||||
Reference in New Issue
Block a user