[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
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:
@ -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 />} />
|
||||
|
||||
170
fe-admin/src/pages/office/AttendanceReportPage.tsx
Normal file
170
fe-admin/src/pages/office/AttendanceReportPage.tsx
Normal 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 có 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>
|
||||
)
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user