[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
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:
@ -83,6 +83,8 @@ function resolvePath(key: string): string | null {
|
|||||||
Off_DatXe: '/workflow-apps/vehicle',
|
Off_DatXe: '/workflow-apps/vehicle',
|
||||||
Off_ItTicket: '/it-tickets',
|
Off_ItTicket: '/it-tickets',
|
||||||
Off_ChamCong: '/attendance',
|
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',
|
Hrm_Dashboard: '/hr/dashboard',
|
||||||
}
|
}
|
||||||
if (staticMap[key]) return staticMap[key]
|
if (staticMap[key]) return staticMap[key]
|
||||||
|
|||||||
@ -64,6 +64,8 @@ export const MenuKeys = {
|
|||||||
OffDatXe: 'Off_DatXe',
|
OffDatXe: 'Off_DatXe',
|
||||||
OffItTicket: 'Off_ItTicket',
|
OffItTicket: 'Off_ItTicket',
|
||||||
OffChamCong: 'Off_ChamCong',
|
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',
|
HrmDashboard: 'Hrm_Dashboard',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,27 @@
|
|||||||
// Ticket CNTT — Phase 10.3 G-O6 (S38) + P11-D auto-assign round-robin + SLA timer (S52).
|
// 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).
|
// 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.
|
// ⚠️ DIVERGES fe-user (KHÔNG còn SHA256 identical, S52 Task C): fe-admin thêm
|
||||||
import { useQuery } from '@tanstack/react-query'
|
// nút "Đổi" + Dialog gán lại người xử lý (PUT /it-tickets/{id}/assign, Admin-only).
|
||||||
import { Ticket } from 'lucide-react'
|
// 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 { 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 { api } from '@/lib/api'
|
||||||
|
import { getErrorMessage } from '@/lib/apiError'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import {
|
import {
|
||||||
IT_TICKET_CATEGORY_LABELS, IT_TICKET_PRIORITY_BADGE, IT_TICKET_PRIORITY_LABELS,
|
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 ItTicketDto, type PagedResult,
|
||||||
} from '@/types/workflowApps'
|
} from '@/types/workflowApps'
|
||||||
|
|
||||||
|
type UserOption = { id: string; fullName: string; email: string }
|
||||||
|
type Paged<T> = { items: T[] }
|
||||||
|
|
||||||
function formatSlaDue(iso: string): string {
|
function formatSlaDue(iso: string): string {
|
||||||
return new Date(iso).toLocaleString('vi-VN', {
|
return new Date(iso).toLocaleString('vi-VN', {
|
||||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
@ -18,11 +29,44 @@ function formatSlaDue(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ItTicketsPage() {
|
export function ItTicketsPage() {
|
||||||
|
const qc = useQueryClient()
|
||||||
const list = useQuery({
|
const list = useQuery({
|
||||||
queryKey: ['it-tickets'],
|
queryKey: ['it-tickets'],
|
||||||
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
|
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 ?? []
|
const items = list.data?.items ?? []
|
||||||
|
|
||||||
// Group by status for kanban-ish display
|
// Group by status for kanban-ish display
|
||||||
@ -59,8 +103,20 @@ export function ItTicketsPage() {
|
|||||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-1 pt-0.5">
|
<div className="flex items-center justify-between gap-1 pt-0.5">
|
||||||
<span className="text-muted-foreground truncate" title={t.assignedToFullName ?? undefined}>
|
<span className="flex items-center gap-1 min-w-0 text-muted-foreground">
|
||||||
👤 {t.assignedToFullName ?? <span className="italic">Chưa giao</span>}
|
<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>
|
</span>
|
||||||
{t.slaDueAt && (
|
{t.slaDueAt && (
|
||||||
<span
|
<span
|
||||||
@ -87,6 +143,43 @@ export function ItTicketsPage() {
|
|||||||
Chưa có ticket nào.
|
Chưa có ticket nào.
|
||||||
</div>
|
</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ử lý</label>
|
||||||
|
<Select value={pick} onChange={e => setPick(e.target.value)} disabled={users.isLoading}>
|
||||||
|
<option value="">— Chọn người xử lý —</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,6 +122,7 @@ public static class MenuKeys
|
|||||||
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
|
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
|
||||||
public const string OffItTicket = "Off_ItTicket"; // Ticket CNTT helpdesk
|
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 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 const string HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
|
||||||
|
|
||||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
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
|
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
|
||||||
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
|
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuất
|
||||||
OffDonTu, OffDonTuLeave, OffDonTuOt, OffDonTuTravel, // Phase 10.3 G-O4 — Đơn 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,
|
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1785,6 +1785,7 @@ public static class DbInitializer
|
|||||||
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
|
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
|
||||||
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
|
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
|
||||||
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
|
(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.
|
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
|
||||||
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
|
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user