[CLAUDE] Office: P11-E AttendanceReport+Excel+OtPolicy + P11-F MaTicket codegen (Wave 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m10s

P11-F: MaTicket gen-on-create qua WorkflowAppCodeGen (IT/2026/NNN Serializable atomic, kanban no-workflow). P11-E: GetAttendanceReportQuery monthly aggregate (day-type weekday/weekend/holiday OT x OtPolicy multiplier in-memory) + AttendanceReportExcelExporter (ClosedXML) + 2 endpoint Admin-only + fe-admin AttendanceReportPage. Migration-free. +5 test (186->191). reviewer PASS (gotcha #44 role-string verified, 0 blocker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 12:34:48 +07:00
parent e9ee97fb3b
commit 6a664298fa
17 changed files with 719 additions and 7 deletions

View File

@ -39,6 +39,7 @@ import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
import { AttendanceReportPage } from '@/pages/office/AttendanceReportPage'
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
function App() {
@ -100,6 +101,8 @@ function App() {
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
<Route path="/it-tickets" element={<ItTicketsPage />} />
<Route path="/attendance" element={<MyAttendancePage />} />
{/* Báo cáo chấm công (P11-E) — admin-only, reachable qua button trên trang Chấm công */}
<Route path="/attendance/report" element={<AttendanceReportPage />} />
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />

View File

@ -0,0 +1,170 @@
// Báo cáo chấm công tháng + OT quy đổi — Phase 11 P11-E (S?? 2026-06-08).
// fe-admin ONLY: endpoint /attendances/report* là [Authorize(Roles=Admin)] → fe-user KHÔNG có page này.
// Filter Năm/Tháng/Phòng ban → TanStack Query → Table + footer Tổng. Xuất Excel qua api.get responseType:'blob'
// (api instance đã inject JWT qua interceptor + hỗ trợ refresh-token retry — chuẩn hơn raw fetch).
import { useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { Download, ClipboardList } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Select } from '@/components/ui/Select'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import type { Paged, Department } from '@/types/master'
import type { AttendanceReportDto } from '@/types/workflowApps'
// Format decimal gọn: bỏ trailing zero, tối đa 2 chữ số thập phân (vd 8 / 8.5 / 7.25).
function fmtNum(n: number): string {
return Number(n.toFixed(2)).toLocaleString('vi-VN', { maximumFractionDigits: 2 })
}
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
export function AttendanceReportPage() {
const now = new Date()
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth() + 1)
const [deptId, setDeptId] = useState('')
const departments = useQuery({
queryKey: ['departments-all-attendance-report'],
queryFn: async () =>
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
})
const report = useQuery({
queryKey: ['attendance-report', year, month, deptId],
queryFn: async () => {
const res = await api.get<AttendanceReportDto>('/attendances/report', {
params: { year, month, departmentId: deptId || undefined },
})
return res.data
},
})
const exportExcel = useMutation({
mutationFn: async () => {
const res = await api.get('/attendances/report/excel', {
params: { year, month, departmentId: deptId || undefined },
responseType: 'blob',
})
const fallback = `BaoCao-ChamCong-${year}-${String(month).padStart(2, '0')}.xlsx`
const filename =
res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? fallback
return { blob: res.data as Blob, filename }
},
onSuccess: ({ blob, filename }) => {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
toast.success('Đã tải file Excel')
},
onError: err => toast.error(getErrorMessage(err)),
})
const rows = report.data?.rows ?? []
return (
<div className="p-6">
<PageHeader
title="Báo cáo chấm công"
description="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban."
actions={
<Button onClick={() => exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}>
<Download className="h-4 w-4" />
{exportExcel.isPending ? 'Đang xuất…' : 'Xuất Excel'}
</Button>
}
/>
{/* ===== Bộ lọc ===== */}
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
<div className="w-28 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Năm</label>
<Input
type="number"
min={2000}
max={2100}
value={year}
onChange={e => setYear(Number(e.target.value) || now.getFullYear())}
/>
</div>
<div className="w-36 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Tháng</label>
<Select value={month} onChange={e => setMonth(Number(e.target.value))}>
{MONTHS.map(m => (
<option key={m} value={m}>Tháng {m}</option>
))}
</Select>
</div>
<div className="min-w-56 flex-1 space-y-1.5">
<label className="block text-xs font-medium text-slate-600">Phòng ban</label>
<Select value={deptId} onChange={e => setDeptId(e.target.value)}>
<option value="">Tất cả phòng ban</option>
{(departments.data ?? []).map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
</div>
</div>
{/* ===== Bảng kết quả ===== */}
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 text-left font-medium">STT</th>
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
<th className="px-3 py-2 text-right font-medium">Ngày công</th>
<th className="px-3 py-2 text-right font-medium">Tổng giờ làm</th>
<th className="px-3 py-2 text-right font-medium">OT thường</th>
<th className="px-3 py-2 text-right font-medium">OT cuối tuần</th>
<th className="px-3 py-2 text-right font-medium">OT lễ</th>
<th className="px-3 py-2 text-right font-medium">OT quy đi</th>
</tr>
</thead>
<tbody>
{report.isLoading && (
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải</td></tr>
)}
{!report.isLoading && rows.length === 0 && (
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
Không dữ liệu chấm công cho kỳ đã chọn.
</td></tr>
)}
{rows.map((r, i) => (
<tr key={r.userId} className="border-b border-slate-100 hover:bg-slate-50">
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
<td className="px-3 py-2 font-medium text-slate-900">{r.fullName}</td>
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
<td className="px-3 py-2 text-right tabular-nums">{r.daysPresent}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(r.totalWorkHours)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
<td className="px-3 py-2 text-right font-semibold tabular-nums text-slate-900">{fmtNum(r.otWeighted)}</td>
</tr>
))}
</tbody>
{!report.isLoading && rows.length > 0 && report.data && (
<tfoot className="border-t-2 border-slate-300 bg-slate-50 font-semibold text-slate-800">
<tr>
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
<td className="px-3 py-2.5" colSpan={3}></td>
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
)
}

View File

@ -1,15 +1,19 @@
// Chấm công của tôi — Phase 10.4 G-P1 (S38 2026-05-28).
// SKELETON: web GPS check-in/out + tháng calendar view.
// File MIRROR SHA256 identical fe-user counterpart.
// ⚠️ DIVERGED from fe-user (P11-E S?? 2026-06-08): fe-admin có thêm button "Báo cáo" (admin-only)
// → /attendance/report. fe-user KHÔNG có (endpoint report là [Authorize(Roles=Admin)]).
// → file này KHÔNG còn SHA256-identical với fe-user counterpart (cố ý, mirror rule §3.9).
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Clock, LogIn, LogOut, MapPin } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { BarChart3, Clock, LogIn, LogOut, MapPin } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '@/components/PageHeader'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
import { getErrorMessage } from '@/lib/apiError'
import { cn } from '@/lib/cn'
import { useAuth } from '@/contexts/AuthContext'
import type { AttendanceDto } from '@/types/workflowApps'
function formatTime(iso: string | null): string {
@ -19,6 +23,9 @@ function formatTime(iso: string | null): string {
export function MyAttendancePage() {
const qc = useQueryClient()
const navigate = useNavigate()
const { user } = useAuth()
const isAdmin = user?.roles.includes('Admin') ?? false
const now = new Date()
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth() + 1)
@ -87,7 +94,18 @@ export function MyAttendancePage() {
return (
<div className="space-y-4">
<PageHeader title="Chấm công" description="Web GPS check-in/out + lịch sử tháng" />
<PageHeader
title="Chấm công"
description="Web GPS check-in/out + lịch sử tháng"
actions={
isAdmin ? (
<Button variant="outline" onClick={() => navigate('/attendance/report')}>
<BarChart3 className="h-4 w-4" />
Báo cáo
</Button>
) : undefined
}
/>
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">

View File

@ -116,4 +116,25 @@ export interface TravelRequestDto { id: string; maDonTu: string | null; requeste
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 }
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).
export interface AttendanceReportRowDto {
userId: string
fullName: string
departmentName: string | null
daysPresent: number
totalWorkHours: number
otRaw: number
otWeekday: number
otWeekend: number
otHoliday: number
otWeighted: number
}
export interface AttendanceReportDto {
year: number
month: number
rows: AttendanceReportRowDto[]
grandTotalWorkHours: number
grandTotalOtWeighted: number
}
export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number }