[CLAUDE] Office: IT staff tự reassign ticket — authz Admin-OR-IT + capability endpoint (S54)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m17s

- BE: GetAssignableItStaffQuery {canReassign,staff} capability endpoint + AssignItTicketHandler authz Admin-OR-dept-IT (Forbidden) + assignee-must-IT (Conflict); controller /assign hạ [Authorize(Roles=Admin)]→[Authorize] (handler enforce fine-grained data-driven)
- FE: fe-admin + fe-user ItTicketsPage SHA256-identical (reverse S53 divergence), nút gate by canReassign, dropdown từ /assignable-staff (không /users)
- Test: +13 authz guard (203→216 PASS), reviewer PASS (role-string Admin chain-verified real)
- No migration (DepartmentId reuse), no menu change

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 16:12:14 +07:00
parent 18d397f095
commit ca4b60277b
13 changed files with 587 additions and 34 deletions

View File

@ -1,14 +1,20 @@
// 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'
// S54: reassign cho Admin HOẶC tổ IT (BE capability /assignable-staff). fe-user MIRROR cùng logic.
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,
IT_TICKET_STATUS_LABELS, type AssignableStaffResult, type ItTicketDto, type PagedResult,
} from '@/types/workflowApps'
function formatSlaDue(iso: string): string {
@ -18,11 +24,46 @@ 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('')
// BE capability: /assignable-staff trả { canReassign, staff } — canReassign quyết hiện nút
// trên MỌI card, nên fetch on mount (KHÔNG gate enabled theo dialog). User thường → canReassign=false, staff=[].
const staffQ = useQuery({
queryKey: ['it-tickets', 'assignable-staff'],
queryFn: async () => (await api.get<AssignableStaffResult>('/it-tickets/assignable-staff')).data,
})
const canReassign = staffQ.data?.canReassign ?? false
const staff = staffQ.data?.staff ?? []
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 +100,21 @@ 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>
{/* Reassign Admin OR tổ IT (BE capability canReassign). Nút nhỏ cạnh người xử lý → mở Dialog gán lại. */}
{canReassign && (
<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 +141,43 @@ export function ItTicketsPage() {
Chưa ticket nào.
</div>
)}
{/* Reassign Dialog — Admin OR tổ IT (BE gác quyền + assignee phải thuộc tổ IT). Select danh sách /assignable-staff. */}
<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={staffQ.isLoading}>
<option value=""> Chọn người xử </option>
{staff.map(u => (
<option key={u.id} value={u.id}>{u.fullName}</option>
))}
</Select>
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách</div>}
</div>
</div>
)}
</Dialog>
</div>
)
}

View File

@ -115,5 +115,8 @@ export interface OtRequestDto { id: string; maDonTu: string | null; requesterUse
export interface TravelRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; destination: string; startDate: string; endDate: string; numDays: number; purpose: string; estimatedCost: number | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string; slaDueAt: string | null; slaBreached: boolean }
// S54 — reassign capability flag từ BE GET /it-tickets/assignable-staff (Admin OR tổ IT). canReassign=false + staff=[] cho user thường.
export type AssignableStaff = { id: string; fullName: string }
export type AssignableStaffResult = { canReassign: boolean; staff: AssignableStaff[] }
export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null }
export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number }