[CLAUDE] Office: P11-D ItTicket admin reassign-UI + P11-E AttendanceReport menu-key
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m21s

Task C - ItTicket admin reassign-UI (fe-admin only):
- Per-card reassign dialog on ItTicketsPage kanban (admin override of round-robin auto-assign).
- Reuses existing PUT /it-tickets/{id}/assign (Admin-only) + GET /users picker. No new BE endpoint.
- fe-admin intentionally diverges from fe-user (read-only) — admin-management action.

Task D - AttendanceReport menu-key (P11-E promote, no migration):
- MenuKeys.OffAttendanceReport 'Báo cáo chấm công' leaf under Văn phòng số (order 8), Admin-perm auto via All[].
- DbInitializer idempotent seed + fe-admin menuKeys.ts/Layout staticMap -> existing /attendance/report route.

Pipeline: implementer-backend -> implementer-frontend -> reviewer (PASS, 0 issues). dotnet+npm builds clean. Tests 203 (unchanged - no new BE logic/schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 15:00:30 +07:00
parent 44b9e542fb
commit dbf66489a9
5 changed files with 105 additions and 6 deletions

View File

@ -83,6 +83,8 @@ function resolvePath(key: string): string | null {
Off_DatXe: '/workflow-apps/vehicle',
Off_ItTicket: '/it-tickets',
Off_ChamCong: '/attendance',
// [P11-E S52] Báo cáo chấm công — admin-only leaf (route từ App.tsx S52).
Off_AttendanceReport: '/attendance/report',
Hrm_Dashboard: '/hr/dashboard',
}
if (staticMap[key]) return staticMap[key]

View File

@ -64,6 +64,8 @@ export const MenuKeys = {
OffDatXe: 'Off_DatXe',
OffItTicket: 'Off_ItTicket',
OffChamCong: 'Off_ChamCong',
// P11-E (S52) — Báo cáo chấm công (admin-only leaf dưới Văn phòng số)
OffAttendanceReport: 'Off_AttendanceReport',
HrmDashboard: 'Hrm_Dashboard',
} as const

View File

@ -1,16 +1,27 @@
// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
// Read-only kanban list + MaTicket + người xử lý (auto-assign dept IT) + SLA badge (đỏ khi quá hạn).
// File MIRROR SHA256 identical fe-user counterpart.
import { useQuery } from '@tanstack/react-query'
import { Ticket } from 'lucide-react'
// ⚠️ DIVERGES fe-user (KHÔNG còn SHA256 identical, S52 Task C): fe-admin thêm
// nút "Đổi" + Dialog gán lại người xử lý (PUT /it-tickets/{id}/assign, Admin-only).
// fe-user giữ bản read-only — KHÔNG mirror admin reassign sang đó.
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Ticket } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import {
IT_TICKET_CATEGORY_LABELS, IT_TICKET_PRIORITY_BADGE, IT_TICKET_PRIORITY_LABELS,
IT_TICKET_STATUS_LABELS, type ItTicketDto, type PagedResult,
} from '@/types/workflowApps'
type UserOption = { id: string; fullName: string; email: string }
type Paged<T> = { items: T[] }
function formatSlaDue(iso: string): string {
return new Date(iso).toLocaleString('vi-VN', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
@ -18,11 +29,44 @@ function formatSlaDue(iso: string): string {
}
export function ItTicketsPage() {
const qc = useQueryClient()
const list = useQuery({
queryKey: ['it-tickets'],
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
})
// Reassign Dialog state. `target` = ticket đang gán lại; `pick` = userId đã chọn.
const [target, setTarget] = useState<ItTicketDto | null>(null)
const [pick, setPick] = useState('')
// Admin user list (reuse endpoint sẵn có — KHÔNG thêm BE endpoint).
const users = useQuery({
queryKey: ['users'],
queryFn: async () => (await api.get<Paged<UserOption>>('/users', { params: { page: 1, pageSize: 200 } })).data.items,
enabled: target !== null, // chỉ fetch khi mở dialog
})
const reassign = useMutation({
// 204 NoContent — không đọc JSON body.
mutationFn: (input: { id: string; assignedToUserId: string }) =>
api.put(`/it-tickets/${input.id}/assign`, { assignedToUserId: input.assignedToUserId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['it-tickets'] })
toast.success('Đã gán lại người xử lý')
closeDialog()
},
onError: err => toast.error(getErrorMessage(err)),
})
function openDialog(t: ItTicketDto) {
setTarget(t)
setPick(t.assignedToUserId ?? '') // preselect người xử lý hiện tại
}
function closeDialog() {
setTarget(null)
setPick('')
}
const items = list.data?.items ?? []
// Group by status for kanban-ish display
@ -59,8 +103,20 @@ export function ItTicketsPage() {
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
</div>
<div className="flex items-center justify-between gap-1 pt-0.5">
<span className="text-muted-foreground truncate" title={t.assignedToFullName ?? undefined}>
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
<span className="truncate" title={t.assignedToFullName ?? undefined}>
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
</span>
{/* Admin-only reassign (BE PUT /assign gác [Authorize(Admin)]).
Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
<button
type="button"
onClick={() => openDialog(t)}
className="shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Đổi người xử lý"
>
<Pencil className="h-3 w-3" />
</button>
</span>
{t.slaDueAt && (
<span
@ -87,6 +143,43 @@ export function ItTicketsPage() {
Chưa ticket nào.
</div>
)}
{/* Reassign Dialog — Admin only (BE gác quyền). Select danh sách user. */}
<Dialog
open={target !== null}
onClose={closeDialog}
title="Gán lại người xử lý"
size="sm"
footer={
<>
<Button variant="outline" onClick={closeDialog}>Hủy</Button>
<Button
onClick={() => target && pick && reassign.mutate({ id: target.id, assignedToUserId: pick })}
disabled={!pick || reassign.isPending}
>
{reassign.isPending ? 'Đang lưu…' : 'Lưu'}
</Button>
</>
}
>
{target && (
<div className="space-y-3 text-sm">
<div className="text-xs text-muted-foreground">
Ticket <span className="font-mono">{target.maTicket ?? '—'}</span> · {target.title}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-slate-700">Người xử </label>
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={users.isLoading}>
<option value=""> Chọn người xử </option>
{(users.data ?? []).map(u => (
<option key={u.id} value={u.id}>{u.fullName}</option>
))}
</Select>
{users.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách</div>}
</div>
</div>
)}
</Dialog>
</div>
)
}

View File

@ -122,6 +122,7 @@ public static class MenuKeys
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
public const string OffItTicket = "Off_ItTicket"; // Ticket CNTT helpdesk
public const string OffChamCong = "Off_ChamCong"; // Chấm công GPS (G-P1)
public const string OffAttendanceReport = "Off_AttendanceReport"; // Báo cáo chấm công (P11-E, admin)
public const string HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
public static readonly string[] PurchaseEvaluationTypeCodes =
@ -155,7 +156,7 @@ public static class MenuKeys
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn từ
OffDatXe, OffItTicket, OffChamCong, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3
OffDatXe, OffItTicket, OffChamCong, OffAttendanceReport, HrmDashboard, // Phase 10.3-10.4 — G-O5/G-O6/G-P1/G-H3 + P11-E report
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
];

View File

@ -1785,6 +1785,7 @@ public static class DbInitializer
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
(MenuKeys.OffAttendanceReport, "Báo cáo chấm công", MenuKeys.Off, 8, "FileBarChart"),
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
};