[CLAUDE] PE: Workflow designer admin UI + Ý kiến 4 phòng ban (P1 Session 5)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m51s
==== Task 1: PE Workflow Designer admin ====
BE (mirror Contract WorkflowAdminFeatures pattern):
- Application/PurchaseEvaluations/PeWorkflowAdminFeatures.cs ~250 LOC:
- GetPeWorkflowAdminOverviewQuery → list 2 EvaluationType (DuyetNcc / DuyetNccPhuongAn) với Active + History versions + count phiếu đang dùng
- CreatePeWorkflowDefinitionCommand + Validator: auto-increment Version per Code, deactivate Active cũ trong cùng EvaluationType (1 active per type invariant)
- DTOs: PeWorkflowStepApproverDto / PeWorkflowStepDto / PeWorkflowDefinitionDto / PeWorkflowTypeSummaryDto / PeWorkflowAdminOverviewDto
- Phase validation 1..7 (state thường, không bao gồm 99=TuChoi)
- Api/Controllers/PeWorkflowsController.cs: 2 endpoint GET /api/pe-workflows + POST. Reuse policy "Workflows.Read" + "Workflows.Create" (admin chung quyền cho cả 2 nhóm WF).
FE:
- pages/system/PeWorkflowsPage.tsx ~500 LOC mirror WorkflowsPage:
- Landing 2-card grid khi /system/pe-workflows (chưa pick type)
- TypePanel khi /system/pe-workflows/:typeCode (DuyetNcc / DuyetNccPhuongAn)
- DefinitionCard read-only view với active badge + version + steps + approvers (Role/User chip)
- PeWorkflowDesigner dialog: clone từ existing, edit Code/Name/Description, add/remove steps, +Role / +User approvers per step, save → version mới + deactivate cũ
- App.tsx route /system/pe-workflows + /system/pe-workflows/:typeCode
- Layout đã có resolver PeWf_<Code> → /system/pe-workflows/<code> từ session 3
==== Task 2: Ý kiến 4 phòng ban PE ====
Domain:
- PurchaseEvaluationDepartmentOpinion entity (AuditableEntity) — PEId + Kind + Opinion text + SignedAt + UserId + UserName denorm
- PeDepartmentKind enum (PheDuyet / Ccm / MuaHang / SmPm)
- PE entity + collection navigation DepartmentOpinions
Infrastructure:
- PurchaseEvaluationDepartmentOpinionConfiguration EF: UNIQUE(PEId, Kind) — max 1 row per phòng ban per phiếu (UPDATE in-place)
- ApplicationDbContext + IApplicationDbContext DbSet
- Migration 15 AddPurchaseEvaluationDepartmentOpinions (15 migration total / 52 DB tables)
Application:
- PeDepartmentOpinionFeatures.cs: UpsertPeDepartmentOpinionCommand (sign=true → set SignedAt+UserId, sign=false chỉ lưu text giữ chữ ký cũ) + DeletePeDepartmentOpinionCommand
- DTO bundle update: + DepartmentOpinions list trong PurchaseEvaluationDetailBundleDto
- GetPurchaseEvaluationQueryHandler load DepartmentOpinions + KindLabel resolution
API:
- POST /api/purchase-evaluations/{id}/opinions (upsert)
- DELETE /api/purchase-evaluations/{id}/opinions/{kind}
FE:
- types/purchaseEvaluation.ts: + PeDepartmentKind enum + PeDepartmentKindLabel + PeDepartmentOpinion type + departmentOpinions vào bundle
- PeDetailTabs Section "5. Ý kiến 4 phòng ban (sign-off)" — 2x2 grid OpinionBox per kind:
- Read mode (readOnly menu Duyệt): hiển thị text + chữ ký
- Edit mode: textarea + 2 button "Lưu text" / "Lưu & Ký"
- Badge "Đã ký" emerald + tên người ký + ngày khi signedAt != null
==== Task 3: User seed verify ====
Seed `SeedDemoUsersAsync` đã match đúng user list authoritative (5 PRO TPB+NV / 7 CCM TPB+NV / 1 ISO / 1 CEO) từ prior commit. DbInitializer reconcile sẽ tự sync khi API restart. Typo trong list user (soluttions / trương) đã fixed sensibly trong seed.
==== Build verify ====
- dotnet build clean (0 error)
- fe-admin TS build pass (1 module mới PeWorkflowsPage)
- fe-user TS build pass (PE detail mirror)
Total: 8 file mới (BE 4 + FE 1 + Migration 2 + 1 Domain) + 13 file modified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -18,12 +18,15 @@ import { cn } from '@/lib/cn'
|
||||
import {
|
||||
PeAttachmentPurpose,
|
||||
PeAttachmentPurposeLabel,
|
||||
PeDepartmentKind,
|
||||
PeDepartmentKindLabel,
|
||||
PurchaseEvaluationPhase,
|
||||
PurchaseEvaluationPhaseColor,
|
||||
PurchaseEvaluationPhaseLabel,
|
||||
PurchaseEvaluationTypeLabel,
|
||||
type PeAttachment,
|
||||
type PeChangelog,
|
||||
type PeDepartmentOpinion,
|
||||
type PeDetailBundle,
|
||||
type PeDetailRow,
|
||||
type PeQuote,
|
||||
@ -108,6 +111,9 @@ export function PeDetailTabs({
|
||||
<Section title={`4. Hạng mục + Báo giá (${evaluation.details.length})`}>
|
||||
<ItemsTab ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
<Section title="5. Ý kiến 4 phòng ban (sign-off)">
|
||||
<DepartmentOpinionsSection ev={evaluation} readOnly={readOnly} />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -122,6 +128,131 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Section 5 — Ý kiến 4 phòng ban =====
|
||||
// Render 2x2 grid 4 box (Phê duyệt / CCM / MuaHàng / SM-PM). Mỗi box hiển
|
||||
// thị Opinion text + chữ ký (UserName + SignedAt) nếu đã ký, hoặc form nhập
|
||||
// + 2 button "Lưu" + "Lưu & Ký" khi chưa ký / readOnly=false.
|
||||
function DepartmentOpinionsSection({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||
const KINDS: { kind: number; label: string }[] = [
|
||||
{ kind: PeDepartmentKind.PheDuyet, label: PeDepartmentKindLabel[PeDepartmentKind.PheDuyet] },
|
||||
{ kind: PeDepartmentKind.Ccm, label: PeDepartmentKindLabel[PeDepartmentKind.Ccm] },
|
||||
{ kind: PeDepartmentKind.MuaHang, label: PeDepartmentKindLabel[PeDepartmentKind.MuaHang] },
|
||||
{ kind: PeDepartmentKind.SmPm, label: PeDepartmentKindLabel[PeDepartmentKind.SmPm] },
|
||||
]
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{KINDS.map(k => {
|
||||
const existing = ev.departmentOpinions.find(o => o.kind === k.kind) ?? null
|
||||
return (
|
||||
<OpinionBox
|
||||
key={k.kind}
|
||||
evaluationId={ev.id}
|
||||
kind={k.kind}
|
||||
kindLabel={k.label}
|
||||
existing={existing}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpinionBox({
|
||||
evaluationId,
|
||||
kind,
|
||||
kindLabel,
|
||||
existing,
|
||||
readOnly,
|
||||
}: {
|
||||
evaluationId: string
|
||||
kind: number
|
||||
kindLabel: string
|
||||
existing: PeDepartmentOpinion | null
|
||||
readOnly: boolean
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [text, setText] = useState(existing?.opinion ?? '')
|
||||
const isSigned = !!existing?.signedAt
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (sign: boolean) =>
|
||||
api.post(`/purchase-evaluations/${evaluationId}/opinions`, {
|
||||
kind,
|
||||
opinion: text || null,
|
||||
sign,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã lưu ý kiến.')
|
||||
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
|
||||
},
|
||||
onError: e => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border bg-white p-3',
|
||||
isSigned ? 'border-emerald-200' : 'border-slate-200',
|
||||
)}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[13px] font-semibold uppercase tracking-wide text-slate-700">{kindLabel}</h4>
|
||||
{isSigned && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
<Check className="h-3 w-3" /> Đã ký
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<>
|
||||
<div className="min-h-[60px] whitespace-pre-wrap text-sm text-slate-800">
|
||||
{existing?.opinion ?? <span className="italic text-slate-400">— chưa có ý kiến</span>}
|
||||
</div>
|
||||
{isSigned && (
|
||||
<div className="mt-2 border-t border-slate-100 pt-1.5 text-[11px] text-slate-500">
|
||||
Ký bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder="Nhập ý kiến…"
|
||||
className="w-full resize-none rounded border border-slate-200 px-2 py-1.5 text-sm focus:border-brand-300 focus:outline-none focus:ring-1 focus:ring-brand-200"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-slate-500">
|
||||
{isSigned
|
||||
? <>Ký bởi <strong>{existing?.userName ?? '—'}</strong> · {new Date(existing!.signedAt!).toLocaleString('vi-VN')}</>
|
||||
: 'Chưa ký'}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => save.mutate(false)}
|
||||
disabled={save.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
Lưu text
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => save.mutate(true)}
|
||||
disabled={save.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{isSigned ? 'Cập nhật chữ ký' : 'Lưu & Ký'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Exports cho Panel 3 — Approvals history + Changelog =====
|
||||
|
||||
export function PeApprovalsSection({ ev }: { ev: PeDetailBundle }) {
|
||||
|
||||
Reference in New Issue
Block a user