[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
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:
@ -1,8 +1,6 @@
|
||||
// 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).
|
||||
// ⚠️ 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 đó.
|
||||
// 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'
|
||||
@ -16,12 +14,9 @@ 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'
|
||||
|
||||
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',
|
||||
@ -39,12 +34,14 @@ export function ItTicketsPage() {
|
||||
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
|
||||
// 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.
|
||||
@ -107,16 +104,17 @@ export function ItTicketsPage() {
|
||||
<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>
|
||||
{/* 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
|
||||
@ -144,7 +142,7 @@ export function ItTicketsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reassign Dialog — Admin only (BE gác quyền). Select danh sách user. */}
|
||||
{/* 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}
|
||||
@ -169,13 +167,13 @@ export function ItTicketsPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-slate-700">Người xử lý</label>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={users.isLoading}>
|
||||
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={staffQ.isLoading}>
|
||||
<option value="">— Chọn người xử lý —</option>
|
||||
{(users.data ?? []).map(u => (
|
||||
{staff.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>}
|
||||
{staffQ.isLoading && <div className="text-xs text-muted-foreground">Đang tải danh sách…</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -115,6 +115,9 @@ 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 }
|
||||
|
||||
// P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi (admin-only). Mirror BE AttendanceReportDto/RowDto (decimal → number).
|
||||
|
||||
Reference in New Issue
Block a user