[CLAUDE] AwV2: Mig 25 +IsUserSelectable + Designer pin toggle + Workspace filter, bỏ "(clone)"
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Has been cancelled

Hai yêu cầu UAT 2026-05-08:
1. Bỏ "(clone)" auto-append khi clone version mới — version đã đủ phân biệt.
2. Thêm pin toggle để admin chọn workflows nào cho user pick lúc tạo phiếu.

Migration 25 AddIsUserSelectableToApprovalWorkflows:
- ALTER ApprovalWorkflows ADD IsUserSelectable bit NOT NULL DEFAULT 0
- UPDATE backfill SET IsUserSelectable=1 WHERE IsActive=1 (giữ behavior cũ
  cho active versions, archived = false default — admin tự pin nếu cần)

BE:
- Domain ApprovalWorkflow +property IsUserSelectable
- DTO AwDefinitionDto +field
- CreateAwDefinitionCommandHandler set default true cho version mới
- New SetAwUserSelectableCommand + Handler
- API PATCH /api/approval-workflows-v2/{id}/user-selectable (Workflows.Create policy)
- DbInitializer SeedSampleApprovalWorkflowsV2Async set IsUserSelectable=true

FE Designer (fe-admin):
- DefinitionDto +isUserSelectable
- Badge amber "Pin Cho user chọn" khi true (cạnh Đang áp dụng/Archived)
- Button "Pin/PinOff Ghim cho user / Bỏ ghim" trong action group + mutation toggle
- Auto-fill name khi clone: bỏ "(clone)" suffix → giữ nguyên name

FE Workspace (fe-admin + fe-user):
- approvalWorkflows query filter w.isUserSelectable === true
- User dropdown chỉ thấy workflows admin đã pin

Verify: dotnet build pass · 81 test pass · npm build × 2 pass · Mig 25 apply LocalDB OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-08 19:15:23 +07:00
parent a9c0857a84
commit 2a53107602
12 changed files with 3943 additions and 8 deletions

View File

@ -73,17 +73,16 @@ export function PeWorkspaceCreateView({
// Mig 23 — fetch list quy trình duyệt V2 cho User chọn (filter theo
// ApplicableType khớp với defaultType: 1=DuyetNcc / 2=DuyetNccPhuongAn).
// Mig 25 — chỉ hiện workflows admin đã ghim "cho user chọn" (IsUserSelectable=true).
const approvalWorkflows = useQuery({
queryKey: ['approval-workflows-v2-active', defaultType],
queryFn: async () => {
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean }[] }[] }>(
const res = await api.get<{ types: { applicableType: number; history: { id: string; code: string; version: number; name: string; isActive: boolean; isUserSelectable: boolean }[] }[] }>(
'/approval-workflows-v2',
{ params: { applicableType: defaultType } },
)
// Trả về tất cả version (active + archived) cho User pick — UAT cần
// flexibility chọn cả version cũ test compare.
const typeBucket = res.data.types.find(t => t.applicableType === defaultType)
return typeBucket?.history ?? []
return (typeBucket?.history ?? []).filter(w => w.isUserSelectable)
},
})

View File

@ -19,7 +19,7 @@
import { useMemo, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown } from 'lucide-react'
import { GitBranch, Plus, Trash2, CheckCircle2, History, Workflow, ChevronUp, ChevronDown, Pin, PinOff } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
@ -59,6 +59,7 @@ type DefinitionDto = {
name: string
description: string | null
isActive: boolean
isUserSelectable: boolean // Mig 25 — admin toggle cho user pick
activatedAt: string | null
createdAt: string
steps: StepDto[]
@ -214,6 +215,19 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
onError: err => toast.error(getErrorMessage(err)),
})
// Mig 25 — toggle "cho user pick lúc create phiếu" (stick/unstick)
const toggleSelectable = useMutation({
mutationFn: async ({ id, isUserSelectable }: { id: string; isUserSelectable: boolean }) =>
api.patch(`/approval-workflows-v2/${id}/user-selectable`, { isUserSelectable }),
onSuccess: (_data, vars) => {
toast.success(vars.isUserSelectable
? 'Đã ghim — user có thể chọn quy trình này'
: 'Đã bỏ ghim — user không thấy quy trình này')
qc.invalidateQueries({ queryKey: ['approval-workflow-v2-overview'] })
},
onError: err => toast.error(getErrorMessage(err)),
})
return (
<div className="space-y-4">
{type.active ? (
@ -221,6 +235,10 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
def={type.active}
isActive
onClone={d => { setCloneFrom(d); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: type.active!.id,
isUserSelectable: !type.active!.isUserSelectable,
})}
onDelete={() => {
if (confirm(`Xoá version đang áp dụng "${type.active!.code} v${type.active!.version}"?`)) {
del.mutate(type.active!.id)
@ -254,6 +272,10 @@ function TypePanel({ type, onSaved }: { type: TypeSummaryDto; onSaved: () => voi
def={d}
isActive={false}
onClone={dd => { setCloneFrom(dd); setDesignerOpen(true) }}
onToggleSelectable={() => toggleSelectable.mutate({
id: d.id,
isUserSelectable: !d.isUserSelectable,
})}
onDelete={() => {
if (confirm(`Xoá version "${d.code} v${d.version}"?`)) del.mutate(d.id)
}}
@ -280,11 +302,13 @@ function DefinitionCard({
def,
isActive,
onClone,
onToggleSelectable,
onDelete,
}: {
def: DefinitionDto
isActive: boolean
onClone: (d: DefinitionDto) => void
onToggleSelectable: () => void
onDelete: () => void
}) {
return (
@ -307,6 +331,13 @@ function DefinitionCard({
Archived
</span>
)}
{/* Mig 25 — badge IsUserSelectable: ghim cho user pick */}
{def.isUserSelectable && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-700" title="User có thể chọn quy trình này khi tạo phiếu">
<Pin className="h-3 w-3" />
Cho user chọn
</span>
)}
</div>
{def.description && <p className="mt-1 text-xs leading-relaxed text-slate-500">{def.description}</p>}
@ -368,6 +399,16 @@ function DefinitionCard({
<Plus className="h-3.5 w-3.5" />
Tạo từ bản này
</Button>
{/* Mig 25 — toggle stick: cho user chọn quy trình này khi tạo phiếu */}
<Button
variant="outline"
size="sm"
onClick={onToggleSelectable}
title={def.isUserSelectable ? 'Bỏ ghim — user sẽ không thấy quy trình này' : 'Ghim — user có thể chọn quy trình này'}
>
{def.isUserSelectable ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
{def.isUserSelectable ? 'Bỏ ghim' : 'Ghim cho user'}
</Button>
<Button variant="outline" size="sm" onClick={onDelete}>
<Trash2 className="h-3.5 w-3.5" />
Xoá version
@ -400,7 +441,7 @@ function Designer({
const defaultCode = DEFAULT_CODE_BY_TYPE[applicableType] ?? 'QT-V2-001'
const [code, setCode] = useState(cloneFrom?.code ?? defaultCode)
const [name, setName] = useState(cloneFrom ? `${cloneFrom.name} (clone)` : `Quy trình ${applicableTypeLabel}`)
const [name, setName] = useState(cloneFrom ? cloneFrom.name : `Quy trình ${applicableTypeLabel}`)
const [description, setDescription] = useState(cloneFrom?.description ?? '')
const [steps, setSteps] = useState<EditStep[]>(initialSteps)