[CLAUDE] FE-Admin: Lock 3 cấp/bước + filter NV theo Phòng (V2 UAT iter 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m14s
User feedback Session 17 sau khi UAT Designer V2 lần đầu:
- "Chốt cứng 1 phòng 3 cấp đi nhé (Logic vẫn giữ như thế nhưng giới
hạn lại, không thay đổi Logic)"
- "Liên kết đúng Phòng A → Thì Select nhân viên phòng A thôi"
- "User có thể cùng cấp với nhau" (không bắt unique level name)
Files: fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx
- FIXED_LEVELS_PER_STEP = 3 const + makeEmptyLevels()/makeEmptyStep()
helpers. Initial state mỗi Step có sẵn 3 levels (C1/C2/C3).
- copyFromDefinition pad/truncate về đúng 3 cấp (defensive cho data
legacy >3 hoặc <3).
- Bỏ button "+ Thêm cấp" + nút Trash xóa cấp + chevron move cấp.
Vẫn giữ Add/Remove + reorder Step (Bước).
- Filter Select NV theo s.departmentId (usersForDept helper):
deptId=null → fallback all (chưa chọn phòng)
deptId set → chỉ NV.DepartmentId === deptId
- Đổi Phòng → reset 3 approver về '' (NV cũ có thể không thuộc Phòng
mới). User select lại 3 NV.
- Phòng required (* + required attr Select) — empty Phòng disable
Select NV với placeholder "Chọn Phòng trước".
- Empty filtered users → hint amber "Phòng chưa có NV, vào /system/users".
- Save validate: phải có Phòng + đúng 3 cấp + tất cả approverUserId
thuộc đúng deptId (defensive double-check).
- ApproverUser type +departmentId (đã có sẵn ở UserDto BE+FE types).
- pageSize 200→500 đảm bảo load đủ NV.
Logic BE KHÔNG đổi: Service iterate Levels OrderBy Order. UI giới hạn
3 cấp chỉ là quy ước, BE vẫn handle N cấp nếu DB có.
Verify: npm build fe-admin OK, 1924 modules, 0 TS error.
This commit is contained in:
@ -1,11 +1,16 @@
|
|||||||
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
// Quy trình duyệt MỚI (Mig 22 — Session 17, 2026-05-08).
|
||||||
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
|
// Schema riêng UAT trước khi drop legacy. Cấu trúc:
|
||||||
// Quy trình (Mã + Tên + ApplicableType)
|
// Quy trình (Mã + Tên + ApplicableType)
|
||||||
// Bước 1 — Phòng A
|
// Bước 1 — Phòng A (FIXED 3 cấp)
|
||||||
// Cấp 1 — NV X (1 user CỤ THỂ qua ApproverUserId)
|
// Cấp 1 — NV X thuộc Phòng A
|
||||||
// Cấp 2 — NV Y
|
// Cấp 2 — NV Y thuộc Phòng A
|
||||||
|
// Cấp 3 — NV Z thuộc Phòng A
|
||||||
//
|
//
|
||||||
// Khác Designer cũ (PE workflow): Levels match 1 NV chính xác (KHÔNG OR-of-many).
|
// Iteration 2 (UAT feedback 2026-05-08):
|
||||||
|
// - LOCK CỨNG 3 cấp/bước (không Add/Remove/Move). Logic backend giữ nguyên
|
||||||
|
// iterate Levels OrderBy Order — chỉ giới hạn UI.
|
||||||
|
// - Cùng 1 phòng có thể chọn 3 NV cùng cấp (không bắt khác cấp).
|
||||||
|
// - Select NV CHỈ filter theo Phòng đã chọn (đổi Phòng → reset 3 approver).
|
||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
@ -63,6 +68,12 @@ type TypeSummaryDto = {
|
|||||||
type EditLevel = { name: string; approverUserId: string }
|
type EditLevel = { name: string; approverUserId: string }
|
||||||
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
type EditStep = { name: string; departmentId: string | null; levels: EditLevel[] }
|
||||||
|
|
||||||
|
type ApproverUser = { id: string; fullName: string; email: string; departmentId: string | null }
|
||||||
|
|
||||||
|
// Lock cứng 3 cấp/bước per UAT 2026-05-08.
|
||||||
|
// Logic BE iterate Levels OrderBy Order vẫn cho phép N cấp — đây chỉ là giới hạn UI.
|
||||||
|
const FIXED_LEVELS_PER_STEP = 3
|
||||||
|
|
||||||
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
// FE typeCode → BE int (giống MenuKeys ApplicableType)
|
||||||
const TYPE_CODE_TO_INT: Record<string, number> = {
|
const TYPE_CODE_TO_INT: Record<string, number> = {
|
||||||
DuyetNcc: 1,
|
DuyetNcc: 1,
|
||||||
@ -75,14 +86,47 @@ const DEFAULT_CODE_BY_TYPE: Record<number, string> = {
|
|||||||
3: 'QT-HD-V2-001',
|
3: 'QT-HD-V2-001',
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
function makeEmptyLevels(): EditLevel[] {
|
||||||
return d.steps.map(s => ({
|
return Array.from({ length: FIXED_LEVELS_PER_STEP }, (_, i) => ({
|
||||||
name: s.name,
|
name: `Cấp ${i + 1}`,
|
||||||
departmentId: s.departmentId,
|
approverUserId: '',
|
||||||
levels: s.levels.map(l => ({ name: l.name ?? '', approverUserId: l.approverUserId })),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeEmptyStep(stepNo: number, deptId: string | null = null): EditStep {
|
||||||
|
return {
|
||||||
|
name: `Phòng ${stepNo}`,
|
||||||
|
departmentId: deptId,
|
||||||
|
levels: makeEmptyLevels(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad/truncate về đúng FIXED_LEVELS_PER_STEP cấp khi clone version cũ
|
||||||
|
// (phòng khi DB có data legacy >3 hoặc <3 levels).
|
||||||
|
function copyFromDefinition(d: DefinitionDto): EditStep[] {
|
||||||
|
return d.steps.map(s => {
|
||||||
|
const levels = s.levels.slice(0, FIXED_LEVELS_PER_STEP).map((l, i) => ({
|
||||||
|
name: l.name ?? `Cấp ${i + 1}`,
|
||||||
|
approverUserId: l.approverUserId,
|
||||||
|
}))
|
||||||
|
while (levels.length < FIXED_LEVELS_PER_STEP) {
|
||||||
|
levels.push({ name: `Cấp ${levels.length + 1}`, approverUserId: '' })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
departmentId: s.departmentId,
|
||||||
|
levels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter NV theo Phòng. Nếu Phòng = null → fallback all (chưa chọn phòng).
|
||||||
|
function usersForDept(all: ApproverUser[] | undefined, deptId: string | null): ApproverUser[] {
|
||||||
|
if (!all) return []
|
||||||
|
if (!deptId) return all
|
||||||
|
return all.filter(u => u.departmentId === deptId)
|
||||||
|
}
|
||||||
|
|
||||||
export function ApprovalWorkflowsV2Page() {
|
export function ApprovalWorkflowsV2Page() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { typeCode } = useParams<{ typeCode?: string }>()
|
const { typeCode } = useParams<{ typeCode?: string }>()
|
||||||
@ -334,10 +378,7 @@ function Designer({
|
|||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const initialSteps: EditStep[] = useMemo(
|
const initialSteps: EditStep[] = useMemo(
|
||||||
() =>
|
() => (cloneFrom ? copyFromDefinition(cloneFrom) : [makeEmptyStep(1)]),
|
||||||
cloneFrom
|
|
||||||
? copyFromDefinition(cloneFrom)
|
|
||||||
: [{ name: 'Phòng 1', departmentId: null, levels: [{ name: 'Cấp 1', approverUserId: '' }] }],
|
|
||||||
[cloneFrom],
|
[cloneFrom],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -350,8 +391,8 @@ function Designer({
|
|||||||
const usersList = useQuery({
|
const usersList = useQuery({
|
||||||
queryKey: ['users-for-approver-v2'],
|
queryKey: ['users-for-approver-v2'],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await api.get<{ items: { id: string; fullName: string; email: string }[] }>('/users', {
|
(await api.get<{ items: ApproverUser[] }>('/users', {
|
||||||
params: { page: 1, pageSize: 200 },
|
params: { page: 1, pageSize: 500 },
|
||||||
})).data.items,
|
})).data.items,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -363,11 +404,23 @@ function Designer({
|
|||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// Validate có user trong tất cả Cấp
|
// Validate: mỗi Bước phải có Phòng + 3 cấp đầy đủ + NV thuộc đúng Phòng.
|
||||||
for (const s of steps) {
|
for (const s of steps) {
|
||||||
if (s.levels.length === 0) throw new Error(`Bước "${s.name}" chưa có cấp duyệt nào.`)
|
if (!s.departmentId) {
|
||||||
|
throw new Error(`Bước "${s.name}" chưa chọn Phòng.`)
|
||||||
|
}
|
||||||
|
if (s.levels.length !== FIXED_LEVELS_PER_STEP) {
|
||||||
|
throw new Error(`Bước "${s.name}" phải có đúng ${FIXED_LEVELS_PER_STEP} cấp.`)
|
||||||
|
}
|
||||||
for (const l of s.levels) {
|
for (const l of s.levels) {
|
||||||
if (!l.approverUserId) throw new Error(`Bước "${s.name}" có cấp chưa chọn nhân viên duyệt.`)
|
if (!l.approverUserId) {
|
||||||
|
throw new Error(`Bước "${s.name}" còn cấp chưa chọn nhân viên duyệt.`)
|
||||||
|
}
|
||||||
|
// Defensive: Select đã filter, nhưng double-check để tránh bypass UI.
|
||||||
|
const u = usersList.data?.find(x => x.id === l.approverUserId)
|
||||||
|
if (u && u.departmentId !== s.departmentId) {
|
||||||
|
throw new Error(`Bước "${s.name}": NV "${u.fullName}" không thuộc Phòng đã chọn.`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await api.post('/approval-workflows-v2', {
|
await api.post('/approval-workflows-v2', {
|
||||||
@ -411,15 +464,6 @@ function Designer({
|
|||||||
setSteps(next)
|
setSteps(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveLevel(stepIdx: number, levelIdx: number, dir: -1 | 1) {
|
|
||||||
const step = steps[stepIdx]
|
|
||||||
const newIdx = levelIdx + dir
|
|
||||||
if (newIdx < 0 || newIdx >= step.levels.length) return
|
|
||||||
const next = [...step.levels]
|
|
||||||
;[next[levelIdx], next[newIdx]] = [next[newIdx], next[levelIdx]]
|
|
||||||
setSteps(steps.map((x, i) => (i === stepIdx ? { ...x, levels: next } : x)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
@ -454,16 +498,14 @@ function Designer({
|
|||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
<div className="space-y-2 rounded-lg border border-slate-200 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Các bước duyệt — mỗi bước = 1 Phòng × N cấp NV ({steps.length} bước)</Label>
|
<Label>
|
||||||
|
Các bước duyệt — mỗi bước = 1 Phòng × <span className="font-bold text-violet-700">cố định 3 cấp NV</span> ({steps.length} bước)
|
||||||
|
</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setSteps([...steps, {
|
onClick={() => setSteps([...steps, makeEmptyStep(steps.length + 1)])}
|
||||||
name: `Phòng ${steps.length + 1}`,
|
|
||||||
departmentId: departmentsList.data?.[0]?.id ?? null,
|
|
||||||
levels: [{ name: 'Cấp 1', approverUserId: usersList.data?.[0]?.id ?? '' }],
|
|
||||||
}])}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
Thêm bước
|
Thêm bước
|
||||||
@ -486,14 +528,19 @@ function Designer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Label className="text-[11px]">Phòng (hint hiển thị)</Label>
|
<Label className="text-[11px]">Phòng * (chọn để filter NV duyệt)</Label>
|
||||||
<Select
|
<Select
|
||||||
value={s.departmentId ?? ''}
|
value={s.departmentId ?? ''}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setSteps(steps.map((x, i) => (i === idx ? { ...x, departmentId: e.target.value || null } : x)))
|
// Đổi Phòng → reset 3 approver vì NV cũ có thể không thuộc Phòng mới.
|
||||||
}
|
const newDeptId = e.target.value || null
|
||||||
|
setSteps(steps.map((x, i) => (i === idx
|
||||||
|
? { ...x, departmentId: newDeptId, levels: x.levels.map(l => ({ ...l, approverUserId: '' })) }
|
||||||
|
: x)))
|
||||||
|
}}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<option value="">— Không —</option>
|
<option value="">— Chọn Phòng —</option>
|
||||||
{departmentsList.data?.map(d => (
|
{departmentsList.data?.map(d => (
|
||||||
<option key={d.id} value={d.id}>{d.name}</option>
|
<option key={d.id} value={d.id}>{d.name}</option>
|
||||||
))}
|
))}
|
||||||
@ -530,94 +577,62 @@ function Designer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Levels */}
|
{/* Levels — LOCK 3 cấp/bước. NV filter theo Phòng đã chọn. */}
|
||||||
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
|
<div className="mt-2 ml-9 space-y-1.5 border-l-2 border-violet-200 pl-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-violet-700">
|
||||||
Cấp duyệt ({s.levels.length})
|
Cấp duyệt (cố định {FIXED_LEVELS_PER_STEP} cấp)
|
||||||
</span>
|
</span>
|
||||||
<button
|
{!s.departmentId && (
|
||||||
type="button"
|
<span className="rounded bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||||
onClick={() =>
|
⚠ Chọn Phòng để load NV
|
||||||
setSteps(steps.map((x, i) =>
|
|
||||||
i === idx
|
|
||||||
? { ...x, levels: [...x.levels, { name: `Cấp ${x.levels.length + 1}`, approverUserId: usersList.data?.[0]?.id ?? '' }] }
|
|
||||||
: x,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
className="rounded bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-700 hover:bg-violet-100"
|
|
||||||
>
|
|
||||||
+ Thêm cấp
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{s.levels.map((l, li) => (
|
|
||||||
<div key={li} className="flex items-center gap-1.5">
|
|
||||||
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
|
|
||||||
C{li + 1}
|
|
||||||
</span>
|
</span>
|
||||||
<Input
|
)}
|
||||||
value={l.name}
|
</div>
|
||||||
onChange={e =>
|
{s.levels.map((l, li) => {
|
||||||
setSteps(steps.map((x, i) =>
|
const filteredUsers = usersForDept(usersList.data, s.departmentId)
|
||||||
i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x,
|
return (
|
||||||
))
|
<div key={li} className="flex items-center gap-1.5">
|
||||||
}
|
<span className="rounded-full bg-violet-100 px-2 py-1 font-mono text-[10px] font-bold text-violet-700">
|
||||||
placeholder={`Cấp ${li + 1}`}
|
C{li + 1}
|
||||||
className="h-7 max-w-[120px] text-xs"
|
</span>
|
||||||
/>
|
<Input
|
||||||
<Select
|
value={l.name}
|
||||||
value={l.approverUserId}
|
onChange={e =>
|
||||||
onChange={e =>
|
setSteps(steps.map((x, i) =>
|
||||||
setSteps(steps.map((x, i) =>
|
i === idx ? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, name: e.target.value } : y)) } : x,
|
||||||
i === idx
|
))
|
||||||
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
}
|
||||||
: x,
|
placeholder={`Cấp ${li + 1}`}
|
||||||
))
|
className="h-7 max-w-[120px] text-xs"
|
||||||
}
|
/>
|
||||||
className="h-7 flex-1 text-xs"
|
<Select
|
||||||
>
|
value={l.approverUserId}
|
||||||
<option value="">— Chọn NV duyệt —</option>
|
onChange={e =>
|
||||||
{usersList.data?.map(u => (
|
setSteps(steps.map((x, i) =>
|
||||||
<option key={u.id} value={u.id}>
|
i === idx
|
||||||
{u.fullName} ({u.email})
|
? { ...x, levels: x.levels.map((y, j) => (j === li ? { ...y, approverUserId: e.target.value } : y)) }
|
||||||
|
: x,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
disabled={!s.departmentId}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{s.departmentId ? '— Chọn NV duyệt —' : '— Chọn Phòng trước —'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
{filteredUsers.map(u => (
|
||||||
</Select>
|
<option key={u.id} value={u.id}>
|
||||||
<button
|
{u.fullName} ({u.email})
|
||||||
type="button"
|
</option>
|
||||||
onClick={() => moveLevel(idx, li, -1)}
|
))}
|
||||||
disabled={li === 0}
|
</Select>
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
</div>
|
||||||
title="Lên"
|
)
|
||||||
>
|
})}
|
||||||
<ChevronUp className="h-3 w-3" />
|
{s.departmentId && usersForDept(usersList.data, s.departmentId).length === 0 && (
|
||||||
</button>
|
<div className="rounded border border-dashed border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] italic text-amber-700">
|
||||||
<button
|
⚠ Phòng này chưa có nhân viên. Vào /system/users gán NV vào Phòng trước, sau đó quay lại chọn duyệt.
|
||||||
type="button"
|
|
||||||
onClick={() => moveLevel(idx, li, 1)}
|
|
||||||
disabled={li === s.levels.length - 1}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
|
||||||
title="Xuống"
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setSteps(steps.map((x, i) =>
|
|
||||||
i === idx ? { ...x, levels: x.levels.filter((_, j) => j !== li) } : x,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-600"
|
|
||||||
title="Xóa cấp"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{s.levels.length === 0 && (
|
|
||||||
<div className="rounded border border-dashed border-slate-300 px-2 py-1.5 text-[11px] italic text-slate-400">
|
|
||||||
Chưa có cấp. Bấm "+ Thêm cấp" để chỉ định NV duyệt.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -628,8 +643,9 @@ function Designer({
|
|||||||
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||||
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
<GitBranch className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
Quy tắc duyệt: tuần tự trong cùng Bước (Cấp 1 → Cấp 2 → ...), hết Cấp thì sang Bước kế.
|
Quy tắc: mỗi Bước cố định {FIXED_LEVELS_PER_STEP} Cấp duyệt thuộc cùng Phòng (Cấp 1 → Cấp 2 → Cấp 3 tuần tự,
|
||||||
Mỗi Cấp = 1 nhân viên cụ thể (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
|
hoặc cùng cấp tùy tên gọi — logic vẫn iterate Order asc). Hết Cấp ở Bước hiện tại → sang Bước kế.
|
||||||
|
Mỗi Cấp = 1 NV cụ thể trong Phòng (KHÔNG OR-of-many). Hết tất cả Bước = Đã duyệt.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user