[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:
@ -35,6 +35,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 (
|
||||
@ -91,6 +95,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="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
|
||||
@ -73,6 +73,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-admin/src/pages/hrm/HrmDashboardPage.tsx
Normal file
85
fe-admin/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-admin/src/pages/office/ItTicketsPage.tsx
Normal file
74
fe-admin/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-admin/src/pages/office/MyAttendancePage.tsx
Normal file
169
fe-admin/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-admin/src/pages/office/WorkflowAppsListPage.tsx
Normal file
155
fe-admin/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-admin/src/types/workflowApps.ts
Normal file
62
fe-admin/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 }
|
||||
@ -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 }
|
||||
@ -0,0 +1,33 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/attendances")]
|
||||
[Authorize]
|
||||
public class AttendancesController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpPost("check-in")]
|
||||
public async Task<IActionResult> CheckIn([FromBody] CheckInCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
|
||||
[HttpPost("check-out")]
|
||||
public async Task<IActionResult> CheckOut([FromBody] CheckOutCommand cmd)
|
||||
{
|
||||
await mediator.Send(cmd);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> GetMyMonth([FromQuery] int? year, [FromQuery] int? month)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
return Ok(await mediator.Send(new GetMyAttendanceQuery(year ?? now.Year, month ?? now.Month)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/hr/dashboard")]
|
||||
[Authorize]
|
||||
public class HrDashboardController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
=> Ok(await mediator.Send(new GetHrDashboardQuery()));
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/it-tickets")]
|
||||
[Authorize]
|
||||
public class ItTicketsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] int? category, [FromQuery] int? priority,
|
||||
[FromQuery] Guid? assignedToUserId, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
=> Ok(await mediator.Send(new GetItTicketsQuery(status, category, priority, assignedToUserId, requesterUserId, page, pageSize)));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateItTicketCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/status")]
|
||||
public async Task<IActionResult> UpdateStatus(Guid id, [FromBody] UpdateItTicketStatusBody body)
|
||||
{
|
||||
await mediator.Send(new UpdateItTicketStatusCommand(id, body.Status, body.Resolution));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public record UpdateItTicketStatusBody(int Status, string? Resolution);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/leave-requests")]
|
||||
[Authorize]
|
||||
public class LeaveRequestsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
=> Ok(await mediator.Send(new GetLeaveRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateLeaveRequestCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ot-requests")]
|
||||
[Authorize]
|
||||
public class OtRequestsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
=> Ok(await mediator.Send(new GetOtRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOtRequestCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/travel-requests")]
|
||||
[Authorize]
|
||||
public class TravelRequestsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
=> Ok(await mediator.Send(new GetTravelRequestsQuery(status, requesterUserId, page, pageSize)));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateTravelRequestCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/vehicle-bookings")]
|
||||
[Authorize]
|
||||
public class VehicleBookingsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetList([FromQuery] int? status, [FromQuery] Guid? requesterUserId, [FromQuery] int page = 1, [FromQuery] int pageSize = 50)
|
||||
=> Ok(await mediator.Send(new GetVehicleBookingsQuery(status, requesterUserId, page, pageSize)));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateVehicleBookingCommand cmd)
|
||||
{
|
||||
var id = await mediator.Send(cmd);
|
||||
return Created(string.Empty, new { id });
|
||||
}
|
||||
}
|
||||
@ -119,5 +119,17 @@ public interface IApplicationDbContext
|
||||
DbSet<ProposalLevelOpinion> ProposalLevelOpinions { get; }
|
||||
DbSet<ProposalCodeSequence> ProposalCodeSequences { get; }
|
||||
|
||||
// Phase 10.3 G-O4+G-O5+G-O6 (Mig 39 — S38) — Workflow Apps skeleton.
|
||||
// 5 entity (Leave/OT/Travel/VehicleBooking/ItTicket) status flat 5-state.
|
||||
// ApproveV2 + LevelOpinions per-module DEFER Phase 11.
|
||||
DbSet<LeaveRequest> LeaveRequests { get; }
|
||||
DbSet<OtRequest> OtRequests { get; }
|
||||
DbSet<TravelRequest> TravelRequests { get; }
|
||||
DbSet<VehicleBooking> VehicleBookings { get; }
|
||||
DbSet<ItTicket> ItTickets { get; }
|
||||
|
||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||
DbSet<Attendance> Attendances { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -0,0 +1,539 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28).
|
||||
// SKELETON Phase 1 — UAT visible cho 5 module Workflow Apps + Attendance.
|
||||
// Scope: Create + List + GetById + Update draft only.
|
||||
// DEFER Phase 11: ApproveV2 workflow advance + LevelOpinions + CodeGen atomic
|
||||
// + LeaveBalance business logic + Overlap check + Auto-assign + SLA timer + Monthly report.
|
||||
|
||||
// =========================================================================
|
||||
// REGION 1: LeaveRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record LeaveRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
Guid LeaveTypeId, DateTime StartDate, DateTime EndDate, decimal NumDays, string Reason,
|
||||
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateLeaveRequestCommand(Guid LeaveTypeId, DateTime StartDate, DateTime EndDate,
|
||||
decimal NumDays, string Reason, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateLeaveRequestValidator : AbstractValidator<CreateLeaveRequestCommand>
|
||||
{
|
||||
public CreateLeaveRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EndDate).GreaterThanOrEqualTo(x => x.StartDate);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateLeaveRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateLeaveRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateLeaveRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new LeaveRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
LeaveTypeId = req.LeaveTypeId,
|
||||
StartDate = req.StartDate,
|
||||
EndDate = req.EndDate,
|
||||
NumDays = req.NumDays,
|
||||
Reason = req.Reason.Trim(),
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.LeaveRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetLeaveRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<LeaveRequestDto>>;
|
||||
|
||||
public class GetLeaveRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetLeaveRequestsQuery, PagedResult<LeaveRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<LeaveRequestDto>> Handle(GetLeaveRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.LeaveRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new LeaveRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.LeaveTypeId, x.StartDate, x.EndDate, x.NumDays, x.Reason,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<LeaveRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 2: OtRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record OtRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime, decimal Hours, string Reason,
|
||||
Guid? OtPolicyId, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateOtRequestCommand(DateTime OtDate, TimeSpan StartTime, TimeSpan EndTime,
|
||||
decimal Hours, string Reason, Guid? OtPolicyId, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateOtRequestValidator : AbstractValidator<CreateOtRequestCommand>
|
||||
{
|
||||
public CreateOtRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.Hours).GreaterThan(0);
|
||||
RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateOtRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateOtRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateOtRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new OtRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
OtDate = req.OtDate,
|
||||
StartTime = req.StartTime,
|
||||
EndTime = req.EndTime,
|
||||
Hours = req.Hours,
|
||||
Reason = req.Reason.Trim(),
|
||||
OtPolicyId = req.OtPolicyId,
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.OtRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetOtRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<OtRequestDto>>;
|
||||
|
||||
public class GetOtRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetOtRequestsQuery, PagedResult<OtRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<OtRequestDto>> Handle(GetOtRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.OtRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new OtRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.OtDate, x.StartTime, x.EndTime, x.Hours, x.Reason, x.OtPolicyId,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<OtRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 3: TravelRequest (G-O4)
|
||||
// =========================================================================
|
||||
|
||||
public record TravelRequestDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
string Destination, DateTime StartDate, DateTime EndDate, int NumDays, string Purpose,
|
||||
decimal? EstimatedCost, int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateTravelRequestCommand(string Destination, DateTime StartDate, DateTime EndDate,
|
||||
int NumDays, string Purpose, decimal? EstimatedCost, Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateTravelRequestValidator : AbstractValidator<CreateTravelRequestCommand>
|
||||
{
|
||||
public CreateTravelRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.NumDays).GreaterThan(0);
|
||||
RuleFor(x => x.EstimatedCost).GreaterThanOrEqualTo(0).When(x => x.EstimatedCost.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateTravelRequestHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateTravelRequestCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateTravelRequestCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new TravelRequest
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
Destination = req.Destination.Trim(),
|
||||
StartDate = req.StartDate,
|
||||
EndDate = req.EndDate,
|
||||
NumDays = req.NumDays,
|
||||
Purpose = req.Purpose.Trim(),
|
||||
EstimatedCost = req.EstimatedCost,
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.TravelRequests.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetTravelRequestsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<TravelRequestDto>>;
|
||||
|
||||
public class GetTravelRequestsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetTravelRequestsQuery, PagedResult<TravelRequestDto>>
|
||||
{
|
||||
public async Task<PagedResult<TravelRequestDto>> Handle(GetTravelRequestsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.TravelRequests.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new TravelRequestDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.Destination, x.StartDate, x.EndDate, x.NumDays, x.Purpose, x.EstimatedCost,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<TravelRequestDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 4: VehicleBooking (G-O5)
|
||||
// =========================================================================
|
||||
|
||||
public record VehicleBookingDto(Guid Id, string? MaDonTu, Guid RequesterUserId, string RequesterFullName,
|
||||
string VehicleLicense, string? VehicleName, DateTime StartAt, DateTime EndAt,
|
||||
string Destination, string Purpose, string? DriverName,
|
||||
int Status, Guid? ApprovalWorkflowId, int? CurrentApprovalLevelOrder, DateTime CreatedAt);
|
||||
|
||||
public record CreateVehicleBookingCommand(string VehicleLicense, string? VehicleName,
|
||||
DateTime StartAt, DateTime EndAt, string Destination, string Purpose, string? DriverName,
|
||||
Guid? ApprovalWorkflowId) : IRequest<Guid>;
|
||||
|
||||
public class CreateVehicleBookingValidator : AbstractValidator<CreateVehicleBookingCommand>
|
||||
{
|
||||
public CreateVehicleBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.VehicleLicense).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Destination).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Purpose).NotEmpty().MaximumLength(1000);
|
||||
RuleFor(x => x.EndAt).GreaterThan(x => x.StartAt);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateVehicleBookingHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateVehicleBookingCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateVehicleBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new VehicleBooking
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
VehicleLicense = req.VehicleLicense.Trim(),
|
||||
VehicleName = req.VehicleName?.Trim(),
|
||||
StartAt = req.StartAt,
|
||||
EndAt = req.EndAt,
|
||||
Destination = req.Destination.Trim(),
|
||||
Purpose = req.Purpose.Trim(),
|
||||
DriverName = req.DriverName?.Trim(),
|
||||
ApprovalWorkflowId = req.ApprovalWorkflowId,
|
||||
Status = WorkflowAppStatus.Nhap,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.VehicleBookings.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetVehicleBookingsQuery(int? Status, Guid? RequesterUserId, int Page = 1, int PageSize = 50)
|
||||
: IRequest<PagedResult<VehicleBookingDto>>;
|
||||
|
||||
public class GetVehicleBookingsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetVehicleBookingsQuery, PagedResult<VehicleBookingDto>>
|
||||
{
|
||||
public async Task<PagedResult<VehicleBookingDto>> Handle(GetVehicleBookingsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.VehicleBookings.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new VehicleBookingDto(x.Id, x.MaDonTu, x.RequesterUserId, x.RequesterFullName,
|
||||
x.VehicleLicense, x.VehicleName, x.StartAt, x.EndAt, x.Destination, x.Purpose, x.DriverName,
|
||||
(int)x.Status, x.ApprovalWorkflowId, x.CurrentApprovalLevelOrder, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<VehicleBookingDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 5: ItTicket (G-O6) — kanban status (NO workflow V2)
|
||||
// =========================================================================
|
||||
|
||||
public record ItTicketDto(Guid Id, string? MaTicket, Guid RequesterUserId, string RequesterFullName,
|
||||
string Title, string Description, int Category, int Priority, int Status,
|
||||
Guid? AssignedToUserId, string? AssignedToFullName, DateTime? ResolvedAt, string? Resolution, DateTime CreatedAt);
|
||||
|
||||
public record CreateItTicketCommand(string Title, string Description, int Category, int Priority) : IRequest<Guid>;
|
||||
|
||||
public class CreateItTicketValidator : AbstractValidator<CreateItTicketCommand>
|
||||
{
|
||||
public CreateItTicketValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(300);
|
||||
RuleFor(x => x.Description).NotEmpty().MaximumLength(5000);
|
||||
RuleFor(x => x.Category).GreaterThan(0);
|
||||
RuleFor(x => x.Priority).InclusiveBetween(1, 4);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CreateItTicketCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateItTicketCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var e = new ItTicket
|
||||
{
|
||||
RequesterUserId = cu.UserId.Value,
|
||||
RequesterFullName = cu.FullName ?? "(unknown)",
|
||||
Title = req.Title.Trim(),
|
||||
Description = req.Description.Trim(),
|
||||
Category = (ItTicketCategory)req.Category,
|
||||
Priority = (ItTicketPriority)req.Priority,
|
||||
Status = ItTicketStatus.Open,
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.ItTickets.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record GetItTicketsQuery(int? Status, int? Category, int? Priority, Guid? AssignedToUserId,
|
||||
Guid? RequesterUserId, int Page = 1, int PageSize = 50) : IRequest<PagedResult<ItTicketDto>>;
|
||||
|
||||
public class GetItTicketsHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetItTicketsQuery, PagedResult<ItTicketDto>>
|
||||
{
|
||||
public async Task<PagedResult<ItTicketDto>> Handle(GetItTicketsQuery q, CancellationToken ct)
|
||||
{
|
||||
var page = q.Page < 1 ? 1 : q.Page;
|
||||
var pageSize = q.PageSize is < 1 or > 200 ? 50 : q.PageSize;
|
||||
var query = db.ItTickets.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (q.Status.HasValue) query = query.Where(x => (int)x.Status == q.Status.Value);
|
||||
if (q.Category.HasValue) query = query.Where(x => (int)x.Category == q.Category.Value);
|
||||
if (q.Priority.HasValue) query = query.Where(x => (int)x.Priority == q.Priority.Value);
|
||||
if (q.AssignedToUserId.HasValue) query = query.Where(x => x.AssignedToUserId == q.AssignedToUserId.Value);
|
||||
if (q.RequesterUserId.HasValue) query = query.Where(x => x.RequesterUserId == q.RequesterUserId.Value);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query.OrderByDescending(x => x.CreatedAt).Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(x => new ItTicketDto(x.Id, x.MaTicket, x.RequesterUserId, x.RequesterFullName,
|
||||
x.Title, x.Description, (int)x.Category, (int)x.Priority, (int)x.Status,
|
||||
x.AssignedToUserId, x.AssignedToFullName, x.ResolvedAt, x.Resolution, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<ItTicketDto>(items, total, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateItTicketStatusCommand(Guid Id, int Status, string? Resolution) : IRequest;
|
||||
|
||||
public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<UpdateItTicketStatusCommand>
|
||||
{
|
||||
public async Task Handle(UpdateItTicketStatusCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
||||
t.Status = (ItTicketStatus)req.Status;
|
||||
if (req.Resolution != null) t.Resolution = req.Resolution.Trim();
|
||||
if (t.Status == ItTicketStatus.Resolved && t.ResolvedAt is null)
|
||||
t.ResolvedAt = clock.UtcNow;
|
||||
t.UpdatedAt = clock.UtcNow;
|
||||
t.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 6: Attendance (G-P1)
|
||||
// =========================================================================
|
||||
|
||||
public record AttendanceDto(Guid Id, Guid UserId, string UserFullName, DateTime AttendanceDate,
|
||||
DateTime? CheckInAt, DateTime? CheckOutAt, int SourceIn, int SourceOut,
|
||||
decimal? CheckInLatitude, decimal? CheckInLongitude, decimal? WorkHours, decimal? OtHours, string? Note);
|
||||
|
||||
public record CheckInCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy, string? Note) : IRequest<Guid>;
|
||||
|
||||
public class CheckInHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CheckInCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CheckInCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var today = clock.Now.Date;
|
||||
var existing = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
if (existing.CheckInAt.HasValue)
|
||||
throw new ConflictException("Đã check-in hôm nay.");
|
||||
existing.CheckInAt = clock.UtcNow;
|
||||
existing.CheckInLatitude = req.Latitude;
|
||||
existing.CheckInLongitude = req.Longitude;
|
||||
existing.CheckInAccuracy = req.Accuracy;
|
||||
existing.SourceIn = AttendanceSource.Web;
|
||||
existing.Note = req.Note?.Trim();
|
||||
existing.UpdatedAt = clock.UtcNow;
|
||||
existing.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return existing.Id;
|
||||
}
|
||||
var e = new Attendance
|
||||
{
|
||||
UserId = cu.UserId.Value,
|
||||
UserFullName = cu.FullName ?? "(unknown)",
|
||||
AttendanceDate = today,
|
||||
CheckInAt = clock.UtcNow,
|
||||
CheckInLatitude = req.Latitude,
|
||||
CheckInLongitude = req.Longitude,
|
||||
CheckInAccuracy = req.Accuracy,
|
||||
SourceIn = AttendanceSource.Web,
|
||||
SourceOut = AttendanceSource.Web,
|
||||
Note = req.Note?.Trim(),
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
db.Attendances.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record CheckOutCommand(decimal? Latitude, decimal? Longitude, decimal? Accuracy) : IRequest;
|
||||
|
||||
public class CheckOutHandler(IApplicationDbContext db, ICurrentUser cu, IDateTime clock)
|
||||
: IRequestHandler<CheckOutCommand>
|
||||
{
|
||||
public async Task Handle(CheckOutCommand req, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var today = clock.Now.Date;
|
||||
var att = await db.Attendances.FirstOrDefaultAsync(x => x.UserId == cu.UserId.Value && x.AttendanceDate == today, ct);
|
||||
if (att is null) throw new NotFoundException("Attendance", today);
|
||||
if (!att.CheckInAt.HasValue)
|
||||
throw new ConflictException("Chưa check-in hôm nay.");
|
||||
att.CheckOutAt = clock.UtcNow;
|
||||
att.CheckOutLatitude = req.Latitude;
|
||||
att.CheckOutLongitude = req.Longitude;
|
||||
att.CheckOutAccuracy = req.Accuracy;
|
||||
att.SourceOut = AttendanceSource.Web;
|
||||
// Simple WorkHours calc: diff in hours
|
||||
if (att.CheckOutAt.HasValue && att.CheckInAt.HasValue)
|
||||
att.WorkHours = (decimal)(att.CheckOutAt.Value - att.CheckInAt.Value).TotalHours;
|
||||
att.UpdatedAt = clock.UtcNow;
|
||||
att.UpdatedBy = cu.UserId;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record GetMyAttendanceQuery(int Year, int Month) : IRequest<List<AttendanceDto>>;
|
||||
|
||||
public class GetMyAttendanceHandler(IApplicationDbContext db, ICurrentUser cu)
|
||||
: IRequestHandler<GetMyAttendanceQuery, List<AttendanceDto>>
|
||||
{
|
||||
public async Task<List<AttendanceDto>> Handle(GetMyAttendanceQuery q, CancellationToken ct)
|
||||
{
|
||||
if (cu.UserId is null) throw new UnauthorizedException();
|
||||
var monthStart = new DateTime(q.Year, q.Month, 1);
|
||||
var monthEnd = monthStart.AddMonths(1);
|
||||
return await db.Attendances.AsNoTracking()
|
||||
.Where(x => x.UserId == cu.UserId.Value && x.AttendanceDate >= monthStart && x.AttendanceDate < monthEnd && !x.IsDeleted)
|
||||
.OrderBy(x => x.AttendanceDate)
|
||||
.Select(x => new AttendanceDto(x.Id, x.UserId, x.UserFullName, x.AttendanceDate,
|
||||
x.CheckInAt, x.CheckOutAt, (int)x.SourceIn, (int)x.SourceOut,
|
||||
x.CheckInLatitude, x.CheckInLongitude, x.WorkHours, x.OtHours, x.Note))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REGION 7: HR Dashboard (G-H3)
|
||||
// =========================================================================
|
||||
|
||||
public record HrDashboardDto(int TotalEmployees, int ActiveEmployees, int OnLeaveEmployees, int ResignedEmployees,
|
||||
int MaleCount, int FemaleCount, int BirthdaysThisWeek, int NewHiresThisMonth);
|
||||
|
||||
public record GetHrDashboardQuery : IRequest<HrDashboardDto>;
|
||||
|
||||
public class GetHrDashboardHandler(IApplicationDbContext db, IDateTime clock)
|
||||
: IRequestHandler<GetHrDashboardQuery, HrDashboardDto>
|
||||
{
|
||||
public async Task<HrDashboardDto> Handle(GetHrDashboardQuery q, CancellationToken ct)
|
||||
{
|
||||
var now = clock.Now;
|
||||
var weekEnd = now.AddDays(7);
|
||||
var monthStart = new DateTime(now.Year, now.Month, 1);
|
||||
|
||||
var total = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted, ct);
|
||||
var active = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 1, ct);
|
||||
var onLeave = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 2, ct);
|
||||
var resigned = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && (int)x.EmployeeStatus == 3, ct);
|
||||
var male = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Male, ct);
|
||||
var female = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.Gender == Domain.Hrm.Gender.Female, ct);
|
||||
|
||||
// Birthdays in next 7 days — compare month+day
|
||||
var birthdays = await db.EmployeeProfiles.AsNoTracking()
|
||||
.Where(x => !x.IsDeleted && x.DateOfBirth.HasValue)
|
||||
.Select(x => x.DateOfBirth!.Value)
|
||||
.ToListAsync(ct);
|
||||
var bdaysThisWeek = birthdays.Count(d =>
|
||||
{
|
||||
var thisYearBday = new DateTime(now.Year, d.Month, d.Day);
|
||||
return thisYearBday >= now.Date && thisYearBday <= weekEnd.Date;
|
||||
});
|
||||
_ = bdaysThisWeek; // silence unused if
|
||||
|
||||
var monthStartDateOnly = DateOnly.FromDateTime(monthStart);
|
||||
var newHires = await db.EmployeeProfiles.CountAsync(x => !x.IsDeleted && x.HireDate.HasValue && x.HireDate >= monthStartDateOnly, ct);
|
||||
|
||||
return new HrDashboardDto(total, active, onLeave, resigned, male, female, bdaysThisWeek, newHires);
|
||||
}
|
||||
}
|
||||
@ -111,6 +111,16 @@ public static class MenuKeys
|
||||
public const string OffDeXuatCreate = "Off_DeXuat_Create"; // Tạo đề xuất mới
|
||||
public const string OffDeXuatInbox = "Off_DeXuat_Inbox"; // Inbox phê duyệt
|
||||
|
||||
// Phase 10.3-10.4 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28) — Skeleton Workflow Apps.
|
||||
public const string OffDonTu = "Off_DonTu"; // sub-group Đơn từ
|
||||
public const string OffDonTuLeave = "Off_DonTu_Leave"; // Đơn nghỉ phép
|
||||
public const string OffDonTuOt = "Off_DonTu_Ot"; // Đơn OT
|
||||
public const string OffDonTuTravel = "Off_DonTu_Travel"; // Đơn công tác
|
||||
public const string OffDatXe = "Off_DatXe"; // Đặt xe công
|
||||
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 HrmDashboard = "Hrm_Dashboard"; // Dashboard HRM (G-H3)
|
||||
|
||||
public static readonly string[] PurchaseEvaluationTypeCodes =
|
||||
["DuyetNcc", "DuyetNccPhuongAn"];
|
||||
|
||||
@ -140,6 +150,8 @@ public static class MenuKeys
|
||||
Off, OffDanhBa, // Phase 10.2 G-O1 — Văn phòng số
|
||||
OffPhongHop, OffPhongHopView, OffPhongHopManage, OffPhongHopBook, // Phase 10.2 G-O2 — Phòng họp
|
||||
OffDeXuat, OffDeXuatList, OffDeXuatCreate, OffDeXuatInbox, // Phase 10.3 G-O3 — Đề xuấ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
|
||||
System, Users, Roles, Permissions, MenuVisibility, Workflows, PeWorkflows,
|
||||
ApprovalWorkflowsV2, ApprovalWorkflowDuyetNccV2, ApprovalWorkflowDuyetNccPhuongAnV2, // Mig 22
|
||||
];
|
||||
|
||||
45
src/Backend/SolutionErp.Domain/Office/Attendance.cs
Normal file
45
src/Backend/SolutionErp.Domain/Office/Attendance.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.4 G-P1 (Mig 40 — S38 2026-05-28) — Chấm công.
|
||||
// Pure web GPS check-in (NO device integration per anh main chốt S32).
|
||||
// Mỗi row = 1 ngày × 1 user. Composite UNIQUE (UserId, AttendanceDate).
|
||||
// Monthly report aggregate query — OT calc tham chiếu Hrm_OtPolicy (Mig 35).
|
||||
public class Attendance : AuditableEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public string UserFullName { get; set; } = string.Empty; // denorm
|
||||
|
||||
public DateTime AttendanceDate { get; set; } // chỉ Date (Time=00:00)
|
||||
|
||||
public DateTime? CheckInAt { get; set; } // UTC datetime full timestamp
|
||||
public DateTime? CheckOutAt { get; set; }
|
||||
|
||||
// GPS coordinates check-in (lat, long, accuracy meters)
|
||||
public decimal? CheckInLatitude { get; set; }
|
||||
public decimal? CheckInLongitude { get; set; }
|
||||
public decimal? CheckInAccuracy { get; set; }
|
||||
public decimal? CheckOutLatitude { get; set; }
|
||||
public decimal? CheckOutLongitude { get; set; }
|
||||
public decimal? CheckOutAccuracy { get; set; }
|
||||
|
||||
public AttendanceSource SourceIn { get; set; } = AttendanceSource.Web;
|
||||
public AttendanceSource SourceOut { get; set; } = AttendanceSource.Web;
|
||||
|
||||
public string? IpAddressIn { get; set; }
|
||||
public string? IpAddressOut { get; set; }
|
||||
|
||||
public string? Note { get; set; } // "Đi muộn do tắc đường"
|
||||
|
||||
// Computed total work hours (decimal hours, vd 8.5 = 8h30min)
|
||||
public decimal? WorkHours { get; set; }
|
||||
public decimal? OtHours { get; set; } // hours beyond shift end
|
||||
}
|
||||
|
||||
public enum AttendanceSource
|
||||
{
|
||||
Web = 1,
|
||||
Mobile = 2,
|
||||
Device = 3, // device integration future (vân tay/face recog)
|
||||
}
|
||||
@ -22,3 +22,42 @@ public enum ProposalStatus
|
||||
TuChoi = 4, // Terminal — không thể edit/resubmit
|
||||
DaDuyet = 5, // Terminal — workflow complete tất cả Cấp
|
||||
}
|
||||
|
||||
// Phase 10.3 G-O4 (Mig 39 — S38 2026-05-28) — 5-state generic workflow status share.
|
||||
// Dùng chung cho LeaveRequest + OtRequest + TravelRequest + VehicleBooking + ItTicket.
|
||||
// Skeleton Phase 1: ApproveV2 wire DEFER Phase 11 polish.
|
||||
public enum WorkflowAppStatus
|
||||
{
|
||||
Nhap = 1,
|
||||
DaGuiDuyet = 2,
|
||||
TraLai = 3,
|
||||
TuChoi = 4,
|
||||
DaDuyet = 5,
|
||||
}
|
||||
|
||||
// G-O6 IT Ticket category + priority enum.
|
||||
public enum ItTicketCategory
|
||||
{
|
||||
Hardware = 1,
|
||||
Software = 2,
|
||||
Network = 3,
|
||||
Account = 4,
|
||||
Other = 99,
|
||||
}
|
||||
|
||||
public enum ItTicketPriority
|
||||
{
|
||||
Low = 1,
|
||||
Medium = 2,
|
||||
High = 3,
|
||||
Urgent = 4,
|
||||
}
|
||||
|
||||
public enum ItTicketStatus
|
||||
{
|
||||
Open = 1,
|
||||
InProgress = 2,
|
||||
Resolved = 3,
|
||||
Closed = 4,
|
||||
Reopened = 5,
|
||||
}
|
||||
|
||||
26
src/Backend/SolutionErp.Domain/Office/ItTicket.cs
Normal file
26
src/Backend/SolutionErp.Domain/Office/ItTicket.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.3 G-O6 (Mig 39 — S38) — Ticket CNTT helpdesk.
|
||||
// KHÔNG dùng Workflow V2 (kanban status flow Open → InProgress → Resolved → Closed).
|
||||
// Auto-assign round-robin + SLA timer DEFER Phase 11.
|
||||
public class ItTicket : AuditableEntity
|
||||
{
|
||||
public string? MaTicket { get; set; } // "IT/2026/001"
|
||||
public Guid RequesterUserId { get; set; }
|
||||
public string RequesterFullName { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public ItTicketCategory Category { get; set; }
|
||||
public ItTicketPriority Priority { get; set; } = ItTicketPriority.Medium;
|
||||
public ItTicketStatus Status { get; set; } = ItTicketStatus.Open;
|
||||
|
||||
public Guid? AssignedToUserId { get; set; } // IT staff được assign (admin set manual hoặc round-robin defer)
|
||||
public string? AssignedToFullName { get; set; } // denorm
|
||||
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public string? Resolution { get; set; } // ghi chú giải pháp (free text replace ItTicketComments thread defer Phase 11)
|
||||
}
|
||||
22
src/Backend/SolutionErp.Domain/Office/LeaveRequest.cs
Normal file
22
src/Backend/SolutionErp.Domain/Office/LeaveRequest.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.3 G-O4 (Mig 39 — S38 2026-05-28) — Đơn xin nghỉ phép.
|
||||
// Workflow V2 dynamic ApplicableType=LeaveRequest=5 (Mig 37 enum extend).
|
||||
// Skeleton Phase 1: status flat WorkflowAppStatus + ApproveV2 wire DEFER Phase 11.
|
||||
// LeaveBalance calculation business logic DEFER Phase 11.
|
||||
public class LeaveRequest : AuditableEntity
|
||||
{
|
||||
public string? MaDonTu { get; set; } // "DT/LR/2026/001"
|
||||
public Guid RequesterUserId { get; set; }
|
||||
public string RequesterFullName { get; set; } = string.Empty; // denorm
|
||||
public Guid LeaveTypeId { get; set; } // FK Hrm_LeaveType (Mig 35)
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public decimal NumDays { get; set; } // computed client-side or BE
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public Guid? ApprovalWorkflowId { get; set; } // pin ApplicableType=5
|
||||
public int? CurrentApprovalLevelOrder { get; set; }
|
||||
}
|
||||
22
src/Backend/SolutionErp.Domain/Office/OtRequest.cs
Normal file
22
src/Backend/SolutionErp.Domain/Office/OtRequest.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.3 G-O4 (Mig 39 — S38) — Đơn đăng ký làm thêm giờ (OT).
|
||||
// Workflow V2 ApplicableType=OtRequest=6 (Mig 37 enum extend).
|
||||
// Reference OtPolicy (Hrm Mig 35) cho multiplier weekday/weekend/holiday.
|
||||
public class OtRequest : AuditableEntity
|
||||
{
|
||||
public string? MaDonTu { get; set; } // "DT/OT/2026/001"
|
||||
public Guid RequesterUserId { get; set; }
|
||||
public string RequesterFullName { get; set; } = string.Empty;
|
||||
public DateTime OtDate { get; set; }
|
||||
public TimeSpan StartTime { get; set; }
|
||||
public TimeSpan EndTime { get; set; }
|
||||
public decimal Hours { get; set; } // computed (EndTime - StartTime)
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public Guid? OtPolicyId { get; set; } // FK Hrm_OtPolicy (Mig 35) optional
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public Guid? ApprovalWorkflowId { get; set; }
|
||||
public int? CurrentApprovalLevelOrder { get; set; }
|
||||
}
|
||||
21
src/Backend/SolutionErp.Domain/Office/TravelRequest.cs
Normal file
21
src/Backend/SolutionErp.Domain/Office/TravelRequest.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.3 G-O4 (Mig 39 — S38) — Đơn đăng ký đi công tác.
|
||||
// Reuse workflow ApplicableType=ProposalGeneral=4 (KHÔNG có enum riêng — share Proposal pool).
|
||||
public class TravelRequest : AuditableEntity
|
||||
{
|
||||
public string? MaDonTu { get; set; } // "DT/TR/2026/001"
|
||||
public Guid RequesterUserId { get; set; }
|
||||
public string RequesterFullName { get; set; } = string.Empty;
|
||||
public string Destination { get; set; } = string.Empty; // "Hà Nội", "TP.HCM", ...
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public int NumDays { get; set; } // computed
|
||||
public string Purpose { get; set; } = string.Empty;
|
||||
public decimal? EstimatedCost { get; set; } // dự toán chi phí
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public Guid? ApprovalWorkflowId { get; set; }
|
||||
public int? CurrentApprovalLevelOrder { get; set; }
|
||||
}
|
||||
23
src/Backend/SolutionErp.Domain/Office/VehicleBooking.cs
Normal file
23
src/Backend/SolutionErp.Domain/Office/VehicleBooking.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using SolutionErp.Domain.Common;
|
||||
|
||||
namespace SolutionErp.Domain.Office;
|
||||
|
||||
// Phase 10.3 G-O5 (Mig 39 — S38) — Đặt xe công.
|
||||
// Workflow V2 ApplicableType=VehicleBooking=7 (Mig 37 enum extend).
|
||||
// Skeleton Phase 1: free text vehicle license/name (NO Vehicle catalog table — defer Phase 11).
|
||||
public class VehicleBooking : AuditableEntity
|
||||
{
|
||||
public string? MaDonTu { get; set; } // "DT/VB/2026/001"
|
||||
public Guid RequesterUserId { get; set; }
|
||||
public string RequesterFullName { get; set; } = string.Empty;
|
||||
public string VehicleLicense { get; set; } = string.Empty; // free text "30A-12345"
|
||||
public string? VehicleName { get; set; } // "Xe 7 chỗ Innova"
|
||||
public DateTime StartAt { get; set; }
|
||||
public DateTime EndAt { get; set; }
|
||||
public string Destination { get; set; } = string.Empty;
|
||||
public string Purpose { get; set; } = string.Empty;
|
||||
public string? DriverName { get; set; } // free text "Anh Tài 0901xxx" — defer Driver catalog Phase 11
|
||||
public WorkflowAppStatus Status { get; set; } = WorkflowAppStatus.Nhap;
|
||||
public Guid? ApprovalWorkflowId { get; set; }
|
||||
public int? CurrentApprovalLevelOrder { get; set; }
|
||||
}
|
||||
@ -108,6 +108,16 @@ public class ApplicationDbContext
|
||||
public DbSet<ProposalLevelOpinion> ProposalLevelOpinions => Set<ProposalLevelOpinion>();
|
||||
public DbSet<ProposalCodeSequence> ProposalCodeSequences => Set<ProposalCodeSequence>();
|
||||
|
||||
// Phase 10.3 G-O4+G-O5+G-O6 (Mig 39 — S38) — Workflow Apps skeleton 5 entity.
|
||||
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
|
||||
public DbSet<OtRequest> OtRequests => Set<OtRequest>();
|
||||
public DbSet<TravelRequest> TravelRequests => Set<TravelRequest>();
|
||||
public DbSet<VehicleBooking> VehicleBookings => Set<VehicleBooking>();
|
||||
public DbSet<ItTicket> ItTickets => Set<ItTicket>();
|
||||
|
||||
// Phase 10.4 G-P1 (Mig 40 — S38) — Chấm công web GPS.
|
||||
public DbSet<Attendance> Attendances => Set<Attendance>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Configurations;
|
||||
|
||||
// EF Mig 39 G-O4+G-O5+G-O6 (S38) — 5 entity Workflow Apps skeleton.
|
||||
// Cookie-cutter mirror Proposal pattern (Mig 38). Status flat WorkflowAppStatus 5-state.
|
||||
// All share workflow V2 ApprovalWorkflowId pin (Mig 37 enum extend +5/+6/+7 done).
|
||||
// Skeleton Phase 1: NO LevelOpinions table (defer Phase 11 — share global table OR per-module).
|
||||
|
||||
public class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LeaveRequest> e)
|
||||
{
|
||||
e.ToTable("LeaveRequests");
|
||||
e.Property(x => x.MaDonTu).HasMaxLength(50);
|
||||
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Reason).HasMaxLength(1000).IsRequired();
|
||||
e.Property(x => x.NumDays).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
|
||||
e.HasIndex(x => x.RequesterUserId);
|
||||
e.HasIndex(x => x.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public class OtRequestConfiguration : IEntityTypeConfiguration<OtRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<OtRequest> e)
|
||||
{
|
||||
e.ToTable("OtRequests");
|
||||
e.Property(x => x.MaDonTu).HasMaxLength(50);
|
||||
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Reason).HasMaxLength(1000).IsRequired();
|
||||
e.Property(x => x.Hours).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
|
||||
e.HasIndex(x => x.RequesterUserId);
|
||||
e.HasIndex(x => x.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public class TravelRequestConfiguration : IEntityTypeConfiguration<TravelRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TravelRequest> e)
|
||||
{
|
||||
e.ToTable("TravelRequests");
|
||||
e.Property(x => x.MaDonTu).HasMaxLength(50);
|
||||
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Destination).HasMaxLength(300).IsRequired();
|
||||
e.Property(x => x.Purpose).HasMaxLength(1000).IsRequired();
|
||||
e.Property(x => x.EstimatedCost).HasColumnType("decimal(18,2)");
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
|
||||
e.HasIndex(x => x.RequesterUserId);
|
||||
e.HasIndex(x => x.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public class VehicleBookingConfiguration : IEntityTypeConfiguration<VehicleBooking>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VehicleBooking> e)
|
||||
{
|
||||
e.ToTable("VehicleBookings");
|
||||
e.Property(x => x.MaDonTu).HasMaxLength(50);
|
||||
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.VehicleLicense).HasMaxLength(20).IsRequired();
|
||||
e.Property(x => x.VehicleName).HasMaxLength(200);
|
||||
e.Property(x => x.Destination).HasMaxLength(300).IsRequired();
|
||||
e.Property(x => x.Purpose).HasMaxLength(1000).IsRequired();
|
||||
e.Property(x => x.DriverName).HasMaxLength(200);
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
e.HasIndex(x => x.MaDonTu).IsUnique().HasFilter("[MaDonTu] IS NOT NULL");
|
||||
e.HasIndex(x => x.RequesterUserId);
|
||||
e.HasIndex(x => x.Status);
|
||||
e.HasIndex(x => new { x.VehicleLicense, x.StartAt }); // overlap check defer Phase 11
|
||||
}
|
||||
}
|
||||
|
||||
public class ItTicketConfiguration : IEntityTypeConfiguration<ItTicket>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ItTicket> e)
|
||||
{
|
||||
e.ToTable("ItTickets");
|
||||
e.Property(x => x.MaTicket).HasMaxLength(50);
|
||||
e.Property(x => x.RequesterFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.Title).HasMaxLength(300).IsRequired();
|
||||
e.Property(x => x.Description).HasMaxLength(5000).IsRequired();
|
||||
e.Property(x => x.AssignedToFullName).HasMaxLength(200);
|
||||
e.Property(x => x.Resolution).HasMaxLength(5000);
|
||||
e.Property(x => x.Category).HasConversion<int>();
|
||||
e.Property(x => x.Priority).HasConversion<int>();
|
||||
e.Property(x => x.Status).HasConversion<int>();
|
||||
e.HasIndex(x => x.MaTicket).IsUnique().HasFilter("[MaTicket] IS NOT NULL");
|
||||
e.HasIndex(x => x.RequesterUserId);
|
||||
e.HasIndex(x => x.AssignedToUserId);
|
||||
e.HasIndex(x => x.Status);
|
||||
e.HasIndex(x => x.Category);
|
||||
}
|
||||
}
|
||||
|
||||
// Mig 40 G-P1 — Attendance
|
||||
public class AttendanceConfiguration : IEntityTypeConfiguration<Attendance>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Attendance> e)
|
||||
{
|
||||
e.ToTable("Attendances");
|
||||
e.Property(x => x.UserFullName).HasMaxLength(200).IsRequired();
|
||||
e.Property(x => x.CheckInLatitude).HasColumnType("decimal(10,7)");
|
||||
e.Property(x => x.CheckInLongitude).HasColumnType("decimal(10,7)");
|
||||
e.Property(x => x.CheckInAccuracy).HasColumnType("decimal(8,2)");
|
||||
e.Property(x => x.CheckOutLatitude).HasColumnType("decimal(10,7)");
|
||||
e.Property(x => x.CheckOutLongitude).HasColumnType("decimal(10,7)");
|
||||
e.Property(x => x.CheckOutAccuracy).HasColumnType("decimal(8,2)");
|
||||
e.Property(x => x.IpAddressIn).HasMaxLength(50);
|
||||
e.Property(x => x.IpAddressOut).HasMaxLength(50);
|
||||
e.Property(x => x.Note).HasMaxLength(500);
|
||||
e.Property(x => x.WorkHours).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.OtHours).HasColumnType("decimal(5,2)");
|
||||
e.Property(x => x.SourceIn).HasConversion<int>();
|
||||
e.Property(x => x.SourceOut).HasConversion<int>();
|
||||
|
||||
// UNIQUE composite (UserId, AttendanceDate)
|
||||
e.HasIndex(x => new { x.UserId, x.AttendanceDate }).IsUnique();
|
||||
}
|
||||
}
|
||||
@ -1567,6 +1567,16 @@ public static class DbInitializer
|
||||
(MenuKeys.OffDeXuatList, "Danh sách", MenuKeys.OffDeXuat, 1, "List"),
|
||||
(MenuKeys.OffDeXuatCreate, "Tạo mới", MenuKeys.OffDeXuat, 2, "Plus"),
|
||||
(MenuKeys.OffDeXuatInbox, "Inbox duyệt", MenuKeys.OffDeXuat, 3, "Inbox"),
|
||||
// Phase 10.3 G-O4+G-O5+G-O6+G-P1 (Mig 39+40 — S38 2026-05-28). Skeleton Workflow Apps.
|
||||
(MenuKeys.OffDonTu, "Đơn từ", MenuKeys.Off, 4, "FileText"),
|
||||
(MenuKeys.OffDonTuLeave, "Nghỉ phép", MenuKeys.OffDonTu, 1, "CalendarOff"),
|
||||
(MenuKeys.OffDonTuOt, "Đăng ký OT", MenuKeys.OffDonTu, 2, "Clock"),
|
||||
(MenuKeys.OffDonTuTravel, "Công tác", MenuKeys.OffDonTu, 3, "Plane"),
|
||||
(MenuKeys.OffDatXe, "Đặt xe công", MenuKeys.Off, 5, "Car"),
|
||||
(MenuKeys.OffItTicket, "Ticket CNTT", MenuKeys.Off, 6, "Ticket"),
|
||||
(MenuKeys.OffChamCong, "Chấm công", MenuKeys.Off, 7, "Fingerprint"),
|
||||
// Phase 10.4 G-H3 — Dashboard NS dưới root Hrm.
|
||||
(MenuKeys.HrmDashboard, "Dashboard NS", MenuKeys.Hrm, 3, "BarChart3"),
|
||||
};
|
||||
|
||||
// Per-type sub-menu under Contracts: 1 group + 3 leaves each
|
||||
|
||||
5945
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528090830_AddWorkflowApps.Designer.cs
generated
Normal file
5945
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528090830_AddWorkflowApps.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,329 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorkflowApps : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Attendances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
UserFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
AttendanceDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CheckInAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CheckOutAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CheckInLatitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
|
||||
CheckInLongitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
|
||||
CheckInAccuracy = table.Column<decimal>(type: "decimal(8,2)", nullable: true),
|
||||
CheckOutLatitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
|
||||
CheckOutLongitude = table.Column<decimal>(type: "decimal(10,7)", nullable: true),
|
||||
CheckOutAccuracy = table.Column<decimal>(type: "decimal(8,2)", nullable: true),
|
||||
SourceIn = table.Column<int>(type: "int", nullable: false),
|
||||
SourceOut = table.Column<int>(type: "int", nullable: false),
|
||||
IpAddressIn = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
IpAddressOut = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
WorkHours = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
OtHours = table.Column<decimal>(type: "decimal(5,2)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Attendances", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ItTickets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MaTicket = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: false),
|
||||
Category = table.Column<int>(type: "int", nullable: false),
|
||||
Priority = table.Column<int>(type: "int", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
AssignedToUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
AssignedToFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
ResolvedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Resolution = table.Column<string>(type: "nvarchar(max)", maxLength: 5000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ItTickets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LeaveRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
LeaveTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
NumDays = table.Column<decimal>(type: "decimal(5,2)", nullable: false),
|
||||
Reason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LeaveRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OtRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
OtDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
StartTime = table.Column<TimeSpan>(type: "time", nullable: false),
|
||||
EndTime = table.Column<TimeSpan>(type: "time", nullable: false),
|
||||
Hours = table.Column<decimal>(type: "decimal(5,2)", nullable: false),
|
||||
Reason = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
OtPolicyId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OtRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TravelRequests",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
Destination = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
NumDays = table.Column<int>(type: "int", nullable: false),
|
||||
Purpose = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
EstimatedCost = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TravelRequests", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VehicleBookings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
MaDonTu = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
RequesterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
RequesterFullName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
|
||||
VehicleLicense = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
|
||||
VehicleName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
StartAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Destination = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
Purpose = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
DriverName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
ApprovalWorkflowId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
CurrentApprovalLevelOrder = table.Column<int>(type: "int", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VehicleBookings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Attendances_UserId_AttendanceDate",
|
||||
table: "Attendances",
|
||||
columns: new[] { "UserId", "AttendanceDate" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItTickets_AssignedToUserId",
|
||||
table: "ItTickets",
|
||||
column: "AssignedToUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItTickets_Category",
|
||||
table: "ItTickets",
|
||||
column: "Category");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItTickets_MaTicket",
|
||||
table: "ItTickets",
|
||||
column: "MaTicket",
|
||||
unique: true,
|
||||
filter: "[MaTicket] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItTickets_RequesterUserId",
|
||||
table: "ItTickets",
|
||||
column: "RequesterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ItTickets_Status",
|
||||
table: "ItTickets",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeaveRequests_MaDonTu",
|
||||
table: "LeaveRequests",
|
||||
column: "MaDonTu",
|
||||
unique: true,
|
||||
filter: "[MaDonTu] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeaveRequests_RequesterUserId",
|
||||
table: "LeaveRequests",
|
||||
column: "RequesterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LeaveRequests_Status",
|
||||
table: "LeaveRequests",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtRequests_MaDonTu",
|
||||
table: "OtRequests",
|
||||
column: "MaDonTu",
|
||||
unique: true,
|
||||
filter: "[MaDonTu] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtRequests_RequesterUserId",
|
||||
table: "OtRequests",
|
||||
column: "RequesterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OtRequests_Status",
|
||||
table: "OtRequests",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TravelRequests_MaDonTu",
|
||||
table: "TravelRequests",
|
||||
column: "MaDonTu",
|
||||
unique: true,
|
||||
filter: "[MaDonTu] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TravelRequests_RequesterUserId",
|
||||
table: "TravelRequests",
|
||||
column: "RequesterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TravelRequests_Status",
|
||||
table: "TravelRequests",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookings_MaDonTu",
|
||||
table: "VehicleBookings",
|
||||
column: "MaDonTu",
|
||||
unique: true,
|
||||
filter: "[MaDonTu] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookings_RequesterUserId",
|
||||
table: "VehicleBookings",
|
||||
column: "RequesterUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookings_Status",
|
||||
table: "VehicleBookings",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VehicleBookings_VehicleLicense_StartAt",
|
||||
table: "VehicleBookings",
|
||||
columns: new[] { "VehicleLicense", "StartAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Attendances");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ItTickets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LeaveRequests");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OtRequests");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "TravelRequests");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VehicleBookings");
|
||||
}
|
||||
}
|
||||
}
|
||||
5945
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528090839_AddAttendances.Designer.cs
generated
Normal file
5945
src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260528090839_AddAttendances.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAttendances : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3448,6 +3448,267 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("Notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.Attendance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("AttendanceDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("CheckInAccuracy")
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<DateTime?>("CheckInAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("CheckInLatitude")
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("CheckInLongitude")
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("CheckOutAccuracy")
|
||||
.HasColumnType("decimal(8,2)");
|
||||
|
||||
b.Property<DateTime?>("CheckOutAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("CheckOutLatitude")
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<decimal?>("CheckOutLongitude")
|
||||
.HasColumnType("decimal(10,7)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("IpAddressIn")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("IpAddressOut")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<decimal?>("OtHours")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<int>("SourceIn")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SourceOut")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("UserFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<decimal?>("WorkHours")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "AttendanceDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Attendances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.ItTicket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AssignedToFullName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid?>("AssignedToUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(5000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("MaTicket")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RequesterFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("RequesterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Resolution")
|
||||
.HasMaxLength(5000)
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignedToUserId");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.HasIndex("MaTicket")
|
||||
.IsUnique()
|
||||
.HasFilter("[MaTicket] IS NOT NULL");
|
||||
|
||||
b.HasIndex("RequesterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("ItTickets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.LeaveRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<Guid>("LeaveTypeId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("MaDonTu")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<decimal>("NumDays")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("RequesterFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("RequesterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaDonTu")
|
||||
.IsUnique()
|
||||
.HasFilter("[MaDonTu] IS NOT NULL");
|
||||
|
||||
b.HasIndex("RequesterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("LeaveRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.MeetingBooking", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -3623,6 +3884,87 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("MeetingRooms", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.OtRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<TimeSpan>("EndTime")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<decimal>("Hours")
|
||||
.HasColumnType("decimal(5,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("MaDonTu")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("OtDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("OtPolicyId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("RequesterFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("RequesterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<TimeSpan>("StartTime")
|
||||
.HasColumnType("time");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaDonTu")
|
||||
.IsUnique()
|
||||
.HasFilter("[MaDonTu] IS NOT NULL");
|
||||
|
||||
b.HasIndex("RequesterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("OtRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.Proposal", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@ -3844,6 +4186,181 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("ProposalLevelOpinions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.TravelRequest", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Destination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<DateTime>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal?>("EstimatedCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("MaDonTu")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<int>("NumDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("RequesterFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("RequesterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("StartDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaDonTu")
|
||||
.IsUnique()
|
||||
.HasFilter("[MaDonTu] IS NOT NULL");
|
||||
|
||||
b.HasIndex("RequesterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("TravelRequests", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.Office.VehicleBooking", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("ApprovalWorkflowId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("CurrentApprovalLevelOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("DeletedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Destination")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<string>("DriverName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<DateTime>("EndAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("MaDonTu")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Purpose")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("RequesterFullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<Guid>("RequesterUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("StartAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("UpdatedBy")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("VehicleLicense")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("VehicleName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MaDonTu")
|
||||
.IsUnique()
|
||||
.HasFilter("[MaDonTu] IS NOT NULL");
|
||||
|
||||
b.HasIndex("RequesterUserId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("VehicleLicense", "StartAt");
|
||||
|
||||
b.ToTable("VehicleBookings", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SolutionErp.Domain.PurchaseEvaluations.PurchaseEvaluation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
Reference in New Issue
Block a user