[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S38 G-O4+G-O5+G-O6+G-P1+G-H3 SKELETON full-stack
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m53s
Phase 10.3-10.4 SKELETON 5 plan combo finish — Mig 39+40 + BE skeleton 7 module + FE 2 app SHA256 IDENTICAL + 11 menu key. UAT visible end-to-end. ⚠️ SKELETON Phase 1 trade-off rõ: - Status flat 5-state WorkflowAppStatus enum share Leave/OT/Travel/Vehicle - ApproveV2 workflow advance DEFER Phase 11 (Drafter Create OK, Approve flow chưa wire) - LevelOpinions per-module DEFER Phase 11 - LeaveBalance calc + Auto-assign + SLA timer DEFER Phase 11 - CodeGen atomic + MaDonTu/MaTicket gen DEFER Phase 11 - Vehicle catalog + Driver catalog DEFER Phase 11 (free text VehicleLicense) - ItTicketComments thread DEFER Phase 11 (free text Resolution field) Mig 39 (em main solo): 5 entity Workflow Apps schema - LeaveRequest (G-O4, FK LeaveType Hrm Mig 35, ApplicableType=5) - OtRequest (G-O4, FK OtPolicy optional, ApplicableType=6) - TravelRequest (G-O4, reuse ApplicableType=4 Proposal) - VehicleBooking (G-O5, free text vehicle, ApplicableType=7) - ItTicket (G-O6, NO workflow V2 — kanban status flow) Mig 40 (em main solo): Attendance entity (G-P1) - GPS lat/long check-in/out + Source enum Web/Mobile/Device - UNIQUE composite (UserId, AttendanceDate) - WorkHours computed simple diff (NO OtPolicy multiplier yet) BE CQRS (em main solo, single mega ~1100 LOC): - WorkflowAppsFeatures.cs 7 region (5 module Create+List + Attendance CheckIn/Out/GetMonth + HrDashboard) - 7 Controller: /api/leave-requests + /ot-requests + /travel-requests + /vehicle-bookings + /it-tickets + /attendances + /hr/dashboard - Class-level [Authorize] any authenticated - 13 endpoint total FE 2 app (em main solo fallback gotcha #53 risk): - types/workflowApps.ts × 2 SHA256 IDENTICAL 77470e182a15de88 (all DTOs + Status badge) - WorkflowAppsListPage.tsx × 2 IDENTICAL 58139d0301a60ddf — generic declarative KIND_CONFIG handles 4 module (Leave/OT/Travel/Vehicle) - ItTicketsPage.tsx × 2 IDENTICAL d3062de2f54c794c — kanban 5 status column - MyAttendancePage.tsx × 2 IDENTICAL 86da48ae147db012 — GPS check-in/out + tháng calendar - HrmDashboardPage.tsx × 2 IDENTICAL d9c6c12a5a8694f8 — 4 KPI card + gender ratio + status breakdown - Pattern 16-bis 9× cumulative (App.tsx +4 routes + menuKeys +8 const + Layout staticMap +7 entry) - 7 amber banner "Skeleton Phase 1 — full feature Phase 11" rõ ràng UAT Menu seed: +11 const + SeedMenuTreeAsync 8 row (Off_DonTu sub-group + 3 leaf + Off_DatXe + Off_ItTicket + Off_ChamCong + Hrm_Dashboard). DbInitializer Sample workflow seed DEFER (workflows V2 already seeded từ S29+S37 reuse — admin clone tạo riêng per ApplicableType=5/6/7). Verify: - dotnet build PASS 0 error 2 pre-existing warning - dotnet test 130/130 PASS baseline preserve - npm build × 2 PASS clean - SHA256 verify 5 file × 2 app all IDENTICAL Plan G-* progress 11/11 ✅ (100% COMPLETE): ✅ G-H1 (S33) + G-O1 (S34) + G-H2 (S35) + G-O2 (S36) + G-O3 (S37) + ✅ G-O4 + G-O5 + G-O6 + G-P1 + G-H3 (S38 skeleton) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -28,6 +28,10 @@ import { MeetingRoomsPage } from '@/pages/office/MeetingRoomsPage'
|
||||
import { ProposalCreatePage } from '@/pages/office/ProposalCreatePage'
|
||||
import { ProposalDetailPage } from '@/pages/office/ProposalDetailPage'
|
||||
import { ProposalsListPage } from '@/pages/office/ProposalsListPage'
|
||||
import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
||||
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
||||
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
||||
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -74,6 +78,10 @@ function App() {
|
||||
<Route path="/proposals" element={<ProposalsListPage />} />
|
||||
<Route path="/proposals/new" element={<ProposalCreatePage />} />
|
||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||
<Route path="/workflow-apps/:kind" element={<WorkflowAppsListPage />} />
|
||||
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
||||
<Route path="/attendance" element={<MyAttendancePage />} />
|
||||
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="*"
|
||||
|
||||
@ -95,6 +95,14 @@ function resolvePath(key: string): string | null {
|
||||
Off_DeXuat_List: '/proposals',
|
||||
Off_DeXuat_Create: '/proposals/new',
|
||||
Off_DeXuat_Inbox: '/proposals?status=2&inboxOnly=true',
|
||||
// Phase 10.3-10.4 S38 — Workflow Apps skeleton (8 entries)
|
||||
Off_DonTu_Leave: '/workflow-apps/leave',
|
||||
Off_DonTu_Ot: '/workflow-apps/ot',
|
||||
Off_DonTu_Travel: '/workflow-apps/travel',
|
||||
Off_DatXe: '/workflow-apps/vehicle',
|
||||
Off_ItTicket: '/it-tickets',
|
||||
Off_ChamCong: '/attendance',
|
||||
Hrm_Dashboard: '/hr/dashboard',
|
||||
}
|
||||
if (staticMap[key]) return staticMap[key]
|
||||
|
||||
|
||||
@ -53,6 +53,15 @@ export const MenuKeys = {
|
||||
OffDeXuatList: 'Off_DeXuat_List',
|
||||
OffDeXuatCreate: 'Off_DeXuat_Create',
|
||||
OffDeXuatInbox: 'Off_DeXuat_Inbox',
|
||||
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1+G-H3 (S38) — Workflow Apps skeleton
|
||||
OffDonTu: 'Off_DonTu',
|
||||
OffDonTuLeave: 'Off_DonTu_Leave',
|
||||
OffDonTuOt: 'Off_DonTu_Ot',
|
||||
OffDonTuTravel: 'Off_DonTu_Travel',
|
||||
OffDatXe: 'Off_DatXe',
|
||||
OffItTicket: 'Off_ItTicket',
|
||||
OffChamCong: 'Off_ChamCong',
|
||||
HrmDashboard: 'Hrm_Dashboard',
|
||||
} as const
|
||||
|
||||
export type MenuKey = typeof MenuKeys[keyof typeof MenuKeys]
|
||||
|
||||
85
fe-user/src/pages/hrm/HrmDashboardPage.tsx
Normal file
85
fe-user/src/pages/hrm/HrmDashboardPage.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
// HR Dashboard — Phase 10.4 G-H3 (S38 2026-05-28).
|
||||
// 4 KPI card aggregate + gender ratio + birthdays + new hires.
|
||||
// File MIRROR SHA256 identical fe-user counterpart.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Cake, TrendingUp, UserCheck, Users } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { HrDashboardDto } from '@/types/workflowApps'
|
||||
|
||||
const CARDS = [
|
||||
{ key: 'totalEmployees', label: 'Tổng nhân viên', icon: Users, color: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||
{ key: 'activeEmployees', label: 'Đang làm việc', icon: UserCheck, color: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
|
||||
{ key: 'birthdaysThisWeek', label: 'Sinh nhật 7 ngày tới', icon: Cake, color: 'bg-amber-50 text-amber-700 border-amber-200' },
|
||||
{ key: 'newHiresThisMonth', label: 'Mới vào tháng này', icon: TrendingUp, color: 'bg-violet-50 text-violet-700 border-violet-200' },
|
||||
] as const
|
||||
|
||||
export function HrmDashboardPage() {
|
||||
const data = useQuery({
|
||||
queryKey: ['hr-dashboard'],
|
||||
queryFn: async () => (await api.get<HrDashboardDto>('/hr/dashboard')).data,
|
||||
})
|
||||
|
||||
const d = data.data
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Dashboard Nhân sự" description="KPI tổng quan + sinh nhật + tuyển dụng" />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{CARDS.map((card) => {
|
||||
const Icon = card.icon
|
||||
const value = d ? (d as any)[card.key] : '—'
|
||||
return (
|
||||
<div key={card.key} className={cn('rounded-lg border p-4', card.color)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium opacity-80">{card.label}</div>
|
||||
<div className="text-3xl font-bold mt-1 tabular-nums">{data.isLoading ? '—' : value}</div>
|
||||
</div>
|
||||
<Icon className="h-8 w-8 opacity-60" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{d && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="font-medium text-sm mb-3">Phân bố giới tính</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground">Nam</div>
|
||||
<div className="text-2xl font-bold text-blue-700 tabular-nums">{d.maleCount}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-muted-foreground">Nữ</div>
|
||||
<div className="text-2xl font-bold text-pink-700 tabular-nums">{d.femaleCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="font-medium text-sm mb-3">Trạng thái nhân sự</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Đang làm việc</span>
|
||||
<span className="font-semibold text-emerald-700 tabular-nums">{d.activeEmployees}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Nghỉ phép</span>
|
||||
<span className="font-semibold text-amber-700 tabular-nums">{d.onLeaveEmployees}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Đã nghỉ việc</span>
|
||||
<span className="font-semibold text-slate-500 tabular-nums">{d.resignedEmployees}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
fe-user/src/pages/office/ItTicketsPage.tsx
Normal file
74
fe-user/src/pages/office/ItTicketsPage.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
// Ticket CNTT — Phase 10.3 G-O6 (S38 2026-05-28).
|
||||
// SKELETON Phase 1: read-only kanban list. Auto-assign + SLA timer DEFER Phase 11.
|
||||
// File MIRROR SHA256 identical fe-user counterpart.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Ticket } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { api } from '@/lib/api'
|
||||
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,
|
||||
} from '@/types/workflowApps'
|
||||
|
||||
export function ItTicketsPage() {
|
||||
const list = useQuery({
|
||||
queryKey: ['it-tickets'],
|
||||
queryFn: async () => (await api.get<PagedResult<ItTicketDto>>('/it-tickets', { params: { pageSize: 100 } })).data,
|
||||
})
|
||||
|
||||
const items = list.data?.items ?? []
|
||||
|
||||
// Group by status for kanban-ish display
|
||||
const grouped: Record<number, ItTicketDto[]> = { 1: [], 2: [], 3: [], 4: [], 5: [] }
|
||||
items.forEach((t) => {
|
||||
if (grouped[t.status]) grouped[t.status].push(t)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Ticket CNTT" description="Helpdesk — báo lỗi và yêu cầu hỗ trợ kỹ thuật" />
|
||||
|
||||
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
||||
⚠️ <strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo ticket + Auto-assign round-robin + SLA timer defer Phase 11 polish.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 5, 4].map((statusKey) => (
|
||||
<div key={statusKey} className="rounded-lg border bg-card p-3">
|
||||
<h3 className="font-medium text-sm mb-2">
|
||||
{IT_TICKET_STATUS_LABELS[statusKey]} <span className="text-xs text-muted-foreground">({grouped[statusKey].length})</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{list.isLoading && <div className="text-xs text-muted-foreground">Đang tải...</div>}
|
||||
{!list.isLoading && grouped[statusKey].length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">Trống</div>
|
||||
)}
|
||||
{grouped[statusKey].map((t) => (
|
||||
<div key={t.id} className="rounded border p-2 text-xs space-y-1 bg-background">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">{t.maTicket ?? '—'}</span>
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px]', IT_TICKET_PRIORITY_BADGE[t.priority])}>
|
||||
{IT_TICKET_PRIORITY_LABELS[t.priority]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium truncate">{t.title}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{IT_TICKET_CATEGORY_LABELS[t.category]} · {t.requesterFullName}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<div className="rounded-lg border bg-card p-8 text-center text-muted-foreground">
|
||||
<Ticket className="mx-auto h-10 w-10 mb-3 opacity-50" />
|
||||
Chưa có ticket nào. Form tạo ticket sẽ kích hoạt Phase 11.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
fe-user/src/pages/office/MyAttendancePage.tsx
Normal file
169
fe-user/src/pages/office/MyAttendancePage.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
// 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.
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { 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 type { AttendanceDto } from '@/types/workflowApps'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function MyAttendancePage() {
|
||||
const qc = useQueryClient()
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth() + 1)
|
||||
const [gpsStatus, setGpsStatus] = useState<string>('Chưa lấy vị trí')
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['attendances-me', { year, month }],
|
||||
queryFn: async () => (await api.get<AttendanceDto[]>('/attendances/me', { params: { year, month } })).data,
|
||||
})
|
||||
|
||||
const today = list.data?.find((a) => a.attendanceDate.startsWith(now.toISOString().slice(0, 10)))
|
||||
|
||||
const getGps = (): Promise<{ lat: number; long: number; accuracy: number } | null> =>
|
||||
new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
setGpsStatus('Trình duyệt không hỗ trợ GPS')
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
setGpsStatus('Đang lấy vị trí...')
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setGpsStatus(`✓ Đã lấy vị trí (sai số ${pos.coords.accuracy.toFixed(0)}m)`)
|
||||
resolve({ lat: pos.coords.latitude, long: pos.coords.longitude, accuracy: pos.coords.accuracy })
|
||||
},
|
||||
(err) => {
|
||||
setGpsStatus(`✗ Lỗi GPS: ${err.message}`)
|
||||
resolve(null)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000 },
|
||||
)
|
||||
})
|
||||
|
||||
const checkIn = useMutation({
|
||||
mutationFn: async () => {
|
||||
const gps = await getGps()
|
||||
await api.post('/attendances/check-in', {
|
||||
latitude: gps?.lat ?? null,
|
||||
longitude: gps?.long ?? null,
|
||||
accuracy: gps?.accuracy ?? null,
|
||||
note: null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã check-in')
|
||||
qc.invalidateQueries({ queryKey: ['attendances-me'] })
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const checkOut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const gps = await getGps()
|
||||
await api.post('/attendances/check-out', {
|
||||
latitude: gps?.lat ?? null,
|
||||
longitude: gps?.long ?? null,
|
||||
accuracy: gps?.accuracy ?? null,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Đã check-out')
|
||||
qc.invalidateQueries({ queryKey: ['attendances-me'] })
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Chấm công" description="Web GPS check-in/out + lịch sử tháng" />
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Hôm nay {now.toLocaleDateString('vi-VN')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" /> {gpsStatus}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => checkIn.mutate()}
|
||||
disabled={checkIn.isPending || !!today?.checkInAt}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
{today?.checkInAt ? `Vào: ${formatTime(today.checkInAt)}` : 'Check-in'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => checkOut.mutate()}
|
||||
disabled={checkOut.isPending || !today?.checkInAt || !!today?.checkOutAt}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{today?.checkOutAt ? `Ra: ${formatTime(today.checkOutAt)}` : 'Check-out'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<select value={month} onChange={(e) => setMonth(Number(e.target.value))} className="rounded-md border px-2 py-1 text-sm">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||
<option key={m} value={m}>Tháng {m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={year} onChange={(e) => setYear(Number(e.target.value))} className="rounded-md border px-2 py-1 text-sm">
|
||||
{[2025, 2026, 2027].map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Ngày</th>
|
||||
<th className="px-3 py-2 text-left">Vào</th>
|
||||
<th className="px-3 py-2 text-left">Ra</th>
|
||||
<th className="px-3 py-2 text-right">Giờ làm</th>
|
||||
<th className="px-3 py-2 text-left">Ghi chú</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-muted-foreground">Đang tải...</td></tr>
|
||||
)}
|
||||
{!list.isLoading && (list.data?.length ?? 0) === 0 && (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-muted-foreground">
|
||||
<Clock className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
Chưa có dữ liệu chấm công tháng này.
|
||||
</td></tr>
|
||||
)}
|
||||
{list.data?.map((a) => (
|
||||
<tr key={a.id} className={cn('border-b', !a.checkOutAt && 'bg-amber-50/30')}>
|
||||
<td className="px-3 py-2">{new Date(a.attendanceDate).toLocaleDateString('vi-VN')}</td>
|
||||
<td className="px-3 py-2 tabular-nums">{formatTime(a.checkInAt)}</td>
|
||||
<td className="px-3 py-2 tabular-nums">{formatTime(a.checkOutAt)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{a.workHours?.toFixed(2) ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{a.note ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
155
fe-user/src/pages/office/WorkflowAppsListPage.tsx
Normal file
155
fe-user/src/pages/office/WorkflowAppsListPage.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
// Generic Workflow Apps List Page — Phase 10.3 G-O4+G-O5+G-O6 (S38 2026-05-28).
|
||||
// SKELETON Phase 1: read-only list. Create form + workflow actions DEFER Phase 11.
|
||||
// Handles 4 module via URL `:kind` param: leave / ot / travel / vehicle.
|
||||
// File MIRROR SHA256 identical fe-user counterpart.
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CalendarOff, Clock, Plane, Car, FileSignature } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
WORKFLOW_APP_STATUS_BADGE, WORKFLOW_APP_STATUS_LABELS,
|
||||
type PagedResult,
|
||||
} from '@/types/workflowApps'
|
||||
|
||||
type Kind = 'leave' | 'ot' | 'travel' | 'vehicle'
|
||||
|
||||
const KIND_CONFIG: Record<Kind, {
|
||||
title: string
|
||||
description: string
|
||||
endpoint: string
|
||||
columns: Array<{ key: string; label: string; render: (item: any) => React.ReactNode }>
|
||||
}> = {
|
||||
leave: {
|
||||
title: 'Đơn xin nghỉ phép',
|
||||
description: 'Danh sách đơn nghỉ phép — Workflow V2 ApplicableType=5',
|
||||
endpoint: '/leave-requests',
|
||||
columns: [
|
||||
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
|
||||
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
|
||||
{ key: 'startDate', label: 'Từ', render: (x) => new Date(x.startDate).toLocaleDateString('vi-VN') },
|
||||
{ key: 'endDate', label: 'Đến', render: (x) => new Date(x.endDate).toLocaleDateString('vi-VN') },
|
||||
{ key: 'numDays', label: 'Ngày', render: (x) => x.numDays },
|
||||
{ key: 'reason', label: 'Lý do', render: (x) => <span className="truncate max-w-xs block">{x.reason}</span> },
|
||||
],
|
||||
},
|
||||
ot: {
|
||||
title: 'Đơn đăng ký OT',
|
||||
description: 'Danh sách đơn OT — Workflow V2 ApplicableType=6',
|
||||
endpoint: '/ot-requests',
|
||||
columns: [
|
||||
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
|
||||
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
|
||||
{ key: 'otDate', label: 'Ngày OT', render: (x) => new Date(x.otDate).toLocaleDateString('vi-VN') },
|
||||
{ key: 'hours', label: 'Giờ', render: (x) => x.hours },
|
||||
{ key: 'reason', label: 'Lý do', render: (x) => <span className="truncate max-w-xs block">{x.reason}</span> },
|
||||
],
|
||||
},
|
||||
travel: {
|
||||
title: 'Đơn đi công tác',
|
||||
description: 'Danh sách đăng ký công tác',
|
||||
endpoint: '/travel-requests',
|
||||
columns: [
|
||||
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
|
||||
{ key: 'requesterFullName', label: 'Người xin', render: (x) => x.requesterFullName },
|
||||
{ key: 'destination', label: 'Địa điểm', render: (x) => x.destination },
|
||||
{ key: 'startDate', label: 'Từ', render: (x) => new Date(x.startDate).toLocaleDateString('vi-VN') },
|
||||
{ key: 'endDate', label: 'Đến', render: (x) => new Date(x.endDate).toLocaleDateString('vi-VN') },
|
||||
{ key: 'purpose', label: 'Mục đích', render: (x) => <span className="truncate max-w-xs block">{x.purpose}</span> },
|
||||
],
|
||||
},
|
||||
vehicle: {
|
||||
title: 'Đặt xe công',
|
||||
description: 'Danh sách booking xe — Workflow V2 ApplicableType=7',
|
||||
endpoint: '/vehicle-bookings',
|
||||
columns: [
|
||||
{ key: 'maDonTu', label: 'Mã', render: (x) => <span className="font-mono text-xs">{x.maDonTu ?? '—'}</span> },
|
||||
{ key: 'requesterFullName', label: 'Người đặt', render: (x) => x.requesterFullName },
|
||||
{ key: 'vehicleLicense', label: 'Biển số', render: (x) => <span className="font-mono text-xs">{x.vehicleLicense}</span> },
|
||||
{ key: 'destination', label: 'Đến', render: (x) => x.destination },
|
||||
{ key: 'startAt', label: 'Bắt đầu', render: (x) => new Date(x.startAt).toLocaleString('vi-VN') },
|
||||
{ key: 'driverName', label: 'Tài xế', render: (x) => x.driverName ?? '—' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<Kind, any> = {
|
||||
leave: CalendarOff, ot: Clock, travel: Plane, vehicle: Car,
|
||||
}
|
||||
|
||||
export function WorkflowAppsListPage() {
|
||||
const { kind = 'leave' } = useParams<{ kind: Kind }>()
|
||||
const config = KIND_CONFIG[kind as Kind]
|
||||
const Icon = ICON_MAP[kind as Kind] ?? FileSignature
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: [config.endpoint, { page: 1 }],
|
||||
queryFn: async () => (await api.get<PagedResult<any>>(config.endpoint, { params: { page: 1, pageSize: 50 } })).data,
|
||||
enabled: !!config,
|
||||
})
|
||||
|
||||
const items = list.data?.items ?? []
|
||||
|
||||
if (!config) {
|
||||
return <div className="text-red-600">Module không tồn tại: {kind}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title={config.title} description={config.description} />
|
||||
|
||||
<div className="rounded-lg border bg-amber-50/50 p-3 text-sm text-amber-900">
|
||||
⚠️ <strong>Skeleton Phase 1 (S38):</strong> Read-only list. Form tạo + workflow Approve/Reject defer Phase 11 polish.
|
||||
Em chủ trì kích hoạt full ApproveV2 wire khi anh main yêu cầu.
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
{config.columns.map((c) => (
|
||||
<th key={c.key} className="px-4 py-2 text-left font-medium">{c.label}</th>
|
||||
))}
|
||||
<th className="px-4 py-2 text-left font-medium">Trạng thái</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!list.isLoading && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={config.columns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<Icon className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||
Chưa có dữ liệu.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((item: any) => (
|
||||
<tr key={item.id} className="border-b">
|
||||
{config.columns.map((c) => (
|
||||
<td key={c.key} className="px-4 py-2">{c.render(item)}</td>
|
||||
))}
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||
WORKFLOW_APP_STATUS_BADGE[item.status],
|
||||
)}
|
||||
>
|
||||
{WORKFLOW_APP_STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
fe-user/src/types/workflowApps.ts
Normal file
62
fe-user/src/types/workflowApps.ts
Normal file
@ -0,0 +1,62 @@
|
||||
// Phase 10.3-10.4 Workflow Apps types (S38 — Mig 39+40 skeleton).
|
||||
// Skeleton Phase 1: Status flat 5-state + List read-only. Create defer Phase 11.
|
||||
// File MIRROR SHA256 identical fe-user counterpart.
|
||||
|
||||
export const WorkflowAppStatus = {
|
||||
Nhap: 1,
|
||||
DaGuiDuyet: 2,
|
||||
TraLai: 3,
|
||||
TuChoi: 4,
|
||||
DaDuyet: 5,
|
||||
} as const
|
||||
|
||||
export const WORKFLOW_APP_STATUS_LABELS: Record<number, string> = {
|
||||
1: 'Nháp', 2: 'Đã gửi duyệt', 3: 'Trả lại', 4: 'Từ chối', 5: 'Đã duyệt',
|
||||
}
|
||||
|
||||
export const WORKFLOW_APP_STATUS_BADGE: Record<number, string> = {
|
||||
1: 'bg-slate-100 text-slate-700 border-slate-300',
|
||||
2: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||
3: 'bg-orange-100 text-orange-800 border-orange-300',
|
||||
4: 'bg-red-100 text-red-800 border-red-300',
|
||||
5: 'bg-emerald-100 text-emerald-800 border-emerald-300',
|
||||
}
|
||||
|
||||
export const ItTicketCategory = {
|
||||
Hardware: 1, Software: 2, Network: 3, Account: 4, Other: 99,
|
||||
} as const
|
||||
export const IT_TICKET_CATEGORY_LABELS: Record<number, string> = {
|
||||
1: 'Phần cứng', 2: 'Phần mềm', 3: 'Mạng', 4: 'Tài khoản', 99: 'Khác',
|
||||
}
|
||||
|
||||
export const ItTicketPriority = { Low: 1, Medium: 2, High: 3, Urgent: 4 } as const
|
||||
export const IT_TICKET_PRIORITY_LABELS: Record<number, string> = {
|
||||
1: 'Thấp', 2: 'TB', 3: 'Cao', 4: 'Khẩn',
|
||||
}
|
||||
export const IT_TICKET_PRIORITY_BADGE: Record<number, string> = {
|
||||
1: 'bg-slate-100 text-slate-700', 2: 'bg-blue-100 text-blue-800',
|
||||
3: 'bg-amber-100 text-amber-800', 4: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
export const ItTicketStatus = { Open: 1, InProgress: 2, Resolved: 3, Closed: 4, Reopened: 5 } as const
|
||||
export const IT_TICKET_STATUS_LABELS: Record<number, string> = {
|
||||
1: 'Mới', 2: 'Đang xử lý', 3: 'Đã giải quyết', 4: 'Đã đóng', 5: 'Mở lại',
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
|
||||
export interface LeaveRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; leaveTypeId: string; startDate: string; endDate: string; numDays: number; reason: string; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
|
||||
export interface OtRequestDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; otDate: string; startTime: string; endTime: string; hours: number; reason: string; otPolicyId: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
|
||||
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 }
|
||||
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 }
|
||||
Reference in New Issue
Block a user