initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
src/admin/hooks/useApiCall.ts
Normal file
45
src/admin/hooks/useApiCall.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import apiFetch from '../utils/api'
|
||||
|
||||
interface ApiCallResult<T> {
|
||||
data: T | null
|
||||
ok: boolean
|
||||
response: Response | null
|
||||
}
|
||||
|
||||
export default function useApiCall() {
|
||||
const alert = useAlert()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const call = useCallback(async <T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
errorMsg = 'Chyba při načítání dat'
|
||||
): Promise<ApiCallResult<T>> => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
try {
|
||||
const response = await apiFetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok || !data.success) {
|
||||
alert.error(data.error || errorMsg)
|
||||
return { data: null, ok: false, response }
|
||||
}
|
||||
return { data: data.data as T, ok: true, response }
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { data: null, ok: false, response: null }
|
||||
}
|
||||
alert.error(errorMsg)
|
||||
return { data: null, ok: false, response: null }
|
||||
}
|
||||
}, [alert])
|
||||
|
||||
return { call }
|
||||
}
|
||||
766
src/admin/hooks/useAttendanceAdmin.ts
Normal file
766
src/admin/hooks/useAttendanceAdmin.ts
Normal file
@@ -0,0 +1,766 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import apiFetch from '../utils/api'
|
||||
import {
|
||||
calcProjectMinutesTotal,
|
||||
calcFormWorkMinutes,
|
||||
calculateWorkMinutes,
|
||||
getDatePart,
|
||||
getTimePart,
|
||||
} from '../utils/attendanceHelpers'
|
||||
import type { ShiftFormData, ProjectLog, Project, User } from '../components/ShiftFormModal'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AlertContext {
|
||||
alert: { success: (msg: string) => void; error: (msg: string) => void }
|
||||
}
|
||||
|
||||
interface AttendanceRecord {
|
||||
id: number
|
||||
user_id: number
|
||||
shift_date: string
|
||||
leave_type?: string
|
||||
leave_hours?: number
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
break_start?: string | null
|
||||
break_end?: string | null
|
||||
notes?: string
|
||||
project_id?: number | null
|
||||
project_name?: string
|
||||
project_logs?: Array<{
|
||||
id?: number
|
||||
project_id: number
|
||||
project_name?: string
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
hours?: string | number | null
|
||||
minutes?: string | number | null
|
||||
}>
|
||||
user_name?: string
|
||||
users?: {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiUser {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
}
|
||||
|
||||
interface UserTotal {
|
||||
name: string
|
||||
minutes: number
|
||||
working: boolean
|
||||
vacation_hours: number
|
||||
sick_hours: number
|
||||
holiday_hours: number
|
||||
unpaid_hours: number
|
||||
overtime: number
|
||||
missing: number
|
||||
fund: number | null
|
||||
business_days: number
|
||||
worked_hours: number
|
||||
covered: number
|
||||
}
|
||||
|
||||
interface LeaveBalance {
|
||||
vacation_total: number
|
||||
vacation_remaining: number
|
||||
}
|
||||
|
||||
interface BulkForm {
|
||||
month: string
|
||||
user_ids: string[]
|
||||
arrival_time: string
|
||||
departure_time: string
|
||||
break_start_time: string
|
||||
break_end_time: string
|
||||
}
|
||||
|
||||
interface AttendanceData {
|
||||
records: AttendanceRecord[]
|
||||
users: User[]
|
||||
user_totals: Record<string, UserTotal>
|
||||
leave_balances: Record<string, LeaveBalance>
|
||||
}
|
||||
|
||||
interface DeleteConfirmState {
|
||||
show: boolean
|
||||
record: AttendanceRecord | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const combineDatetime = (date: string, time: string): string | null =>
|
||||
date && time ? `${date}T${time}:00` : null
|
||||
|
||||
/**
|
||||
* Compute per-user totals from raw attendance records.
|
||||
* This replaces the server-side `user_totals` that the PHP backend returned.
|
||||
*/
|
||||
function computeUserTotals(
|
||||
records: AttendanceRecord[],
|
||||
userMap: Map<number, string>,
|
||||
month: string,
|
||||
): Record<string, UserTotal> {
|
||||
const totals: Record<string, UserTotal> = {}
|
||||
|
||||
for (const rec of records) {
|
||||
const uid = String(rec.user_id)
|
||||
if (!totals[uid]) {
|
||||
const name =
|
||||
userMap.get(rec.user_id) ??
|
||||
(rec.users
|
||||
? `${rec.users.first_name} ${rec.users.last_name}`.trim() || rec.users.username
|
||||
: `User #${rec.user_id}`)
|
||||
totals[uid] = {
|
||||
name,
|
||||
minutes: 0,
|
||||
working: false,
|
||||
vacation_hours: 0,
|
||||
sick_hours: 0,
|
||||
holiday_hours: 0,
|
||||
unpaid_hours: 0,
|
||||
overtime: 0,
|
||||
missing: 0,
|
||||
fund: null,
|
||||
business_days: 0,
|
||||
worked_hours: 0,
|
||||
covered: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const t = totals[uid]
|
||||
const leaveType = rec.leave_type || 'work'
|
||||
|
||||
if (leaveType === 'work') {
|
||||
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
|
||||
t.minutes += calculateWorkMinutes(rec)
|
||||
} else {
|
||||
const leaveHours = Number(rec.leave_hours) || 8
|
||||
switch (leaveType) {
|
||||
case 'vacation': t.vacation_hours += leaveHours; break
|
||||
case 'sick': t.sick_hours += leaveHours; break
|
||||
case 'holiday': t.holiday_hours += leaveHours; break
|
||||
case 'unpaid': t.unpaid_hours += leaveHours; break
|
||||
}
|
||||
}
|
||||
|
||||
// Track if user is currently working (has arrival but no departure)
|
||||
if (rec.arrival_time && !rec.departure_time) {
|
||||
t.working = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add fund data per user (matching PHP addFundDataToUserTotals)
|
||||
const [yearStr, monthStr] = month.split('-')
|
||||
const yr = parseInt(yearStr, 10)
|
||||
const mo = parseInt(monthStr, 10) - 1
|
||||
|
||||
// Count business days in month (Mon-Fri)
|
||||
let rawBizDays = 0
|
||||
const cur = new Date(yr, mo, 1)
|
||||
while (cur.getMonth() === mo) {
|
||||
const dow = cur.getDay()
|
||||
if (dow !== 0 && dow !== 6) rawBizDays++
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
for (const uid of Object.keys(totals)) {
|
||||
const t = totals[uid]
|
||||
// Subtract holiday days from business days for this user
|
||||
const holidayDays = Math.round(t.holiday_hours / 8)
|
||||
const bizDays = Math.max(0, rawBizDays - holidayDays)
|
||||
const fund = bizDays * 8
|
||||
const workedHours = Math.round((t.minutes / 60) * 10) / 10
|
||||
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
|
||||
const leaveHours = t.vacation_hours + t.sick_hours
|
||||
const covered = Math.round((workedHours + leaveHours) * 10) / 10
|
||||
|
||||
t.fund = fund
|
||||
t.business_days = bizDays
|
||||
t.worked_hours = workedHours
|
||||
t.covered = covered
|
||||
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10)
|
||||
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10)
|
||||
}
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useAttendanceAdmin({ alert }: AlertContext) {
|
||||
// ---- Core state ----
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [month, setMonth] = useState(() => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
})
|
||||
const [filterUserId, setFilterUserId] = useState('')
|
||||
const [data, setData] = useState<AttendanceData>({
|
||||
records: [],
|
||||
users: [],
|
||||
user_totals: {},
|
||||
leave_balances: {},
|
||||
})
|
||||
|
||||
// ---- Bulk modal ----
|
||||
const [showBulkModal, setShowBulkModal] = useState(false)
|
||||
const [bulkSubmitting, setBulkSubmitting] = useState(false)
|
||||
const [bulkForm, setBulkForm] = useState<BulkForm>({
|
||||
month: '',
|
||||
user_ids: [],
|
||||
arrival_time: '08:00',
|
||||
departure_time: '16:30',
|
||||
break_start_time: '12:00',
|
||||
break_end_time: '12:30',
|
||||
})
|
||||
|
||||
// ---- Create modal ----
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const [createForm, setCreateForm] = useState<ShiftFormData>({
|
||||
user_id: '',
|
||||
shift_date: today,
|
||||
leave_type: 'work',
|
||||
leave_hours: 8,
|
||||
arrival_date: today,
|
||||
arrival_time: '',
|
||||
break_start_date: today,
|
||||
break_start_time: '',
|
||||
break_end_date: today,
|
||||
break_end_time: '',
|
||||
departure_date: today,
|
||||
departure_time: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
// ---- Edit modal ----
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editingRecord, setEditingRecord] = useState<AttendanceRecord | null>(null)
|
||||
const [editForm, setEditForm] = useState<ShiftFormData>({
|
||||
user_id: '',
|
||||
shift_date: '',
|
||||
leave_type: 'work',
|
||||
leave_hours: 8,
|
||||
arrival_date: '',
|
||||
arrival_time: '',
|
||||
break_start_date: '',
|
||||
break_start_time: '',
|
||||
break_end_date: '',
|
||||
break_end_time: '',
|
||||
departure_date: '',
|
||||
departure_time: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
// ---- Delete ----
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState>({
|
||||
show: false,
|
||||
record: null,
|
||||
})
|
||||
|
||||
// ---- Projects ----
|
||||
const [projectList, setProjectList] = useState<Project[]>([])
|
||||
const [createProjectLogs, setCreateProjectLogs] = useState<ProjectLog[]>([])
|
||||
const [editProjectLogs, setEditProjectLogs] = useState<ProjectLog[]>([])
|
||||
|
||||
// ---- Print ref (kept for API compat) ----
|
||||
const printRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// ---- Ref to hold full user list for user_totals computation ----
|
||||
const usersRef = useRef<Map<number, string>>(new Map())
|
||||
|
||||
// =========================================================================
|
||||
// Load projects once
|
||||
// =========================================================================
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance?action=projects`)
|
||||
const result = await response.json()
|
||||
if (result.success) setProjectList(result.data?.projects ?? result.data ?? [])
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
loadProjects()
|
||||
}, [])
|
||||
|
||||
// =========================================================================
|
||||
// Load users once
|
||||
// =========================================================================
|
||||
useEffect(() => {
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/users?limit=1000`)
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
const apiUsers: ApiUser[] = result.data
|
||||
const mapped: User[] = apiUsers.map((u) => ({
|
||||
id: u.id,
|
||||
name: `${u.first_name} ${u.last_name}`.trim() || u.username,
|
||||
}))
|
||||
const nameMap = new Map<number, string>()
|
||||
for (const u of mapped) nameMap.set(u.id as number, u.name)
|
||||
usersRef.current = nameMap
|
||||
|
||||
setData((prev) => ({ ...prev, users: mapped }))
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
// =========================================================================
|
||||
// Fetch attendance records + leave balances
|
||||
// =========================================================================
|
||||
const fetchData = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (showLoading) setLoading(true)
|
||||
try {
|
||||
const [yearStr, monthStr] = month.split('-')
|
||||
|
||||
// Build records URL
|
||||
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`
|
||||
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`
|
||||
|
||||
// Fetch records and balances in parallel
|
||||
const [recordsResponse, balancesResponse] = await Promise.all([
|
||||
apiFetch(recordsUrl),
|
||||
apiFetch(`${API_BASE}/attendance?action=balances&year=${yearStr}`),
|
||||
])
|
||||
|
||||
if (recordsResponse.status === 401) return
|
||||
|
||||
const recordsResult = await recordsResponse.json()
|
||||
const balancesResult = await balancesResponse.json()
|
||||
|
||||
const records: AttendanceRecord[] = recordsResult.success
|
||||
? (Array.isArray(recordsResult.data) ? recordsResult.data : [])
|
||||
: []
|
||||
// balancesResult.data is { users: [...], balances: { uid: {...} } }
|
||||
const balancesObj = balancesResult.success ? balancesResult.data : {}
|
||||
const leaveBalances: Record<string, LeaveBalance> = balancesObj?.balances ?? balancesObj ?? {}
|
||||
|
||||
// Compute user_totals client-side
|
||||
const userTotals = computeUserTotals(records, usersRef.current, month)
|
||||
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
records,
|
||||
user_totals: userTotals,
|
||||
leave_balances: leaveBalances,
|
||||
}))
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
} finally {
|
||||
if (showLoading) setLoading(false)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[month, filterUserId],
|
||||
)
|
||||
|
||||
// Initial load with skeleton, filter changes without skeleton
|
||||
const initialLoadDone = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) {
|
||||
initialLoadDone.current = true
|
||||
fetchData(true)
|
||||
} else {
|
||||
fetchData(false)
|
||||
}
|
||||
}, [fetchData])
|
||||
|
||||
// =========================================================================
|
||||
// Validation helper
|
||||
// =========================================================================
|
||||
const validateProjectLogs = (logs: ProjectLog[], formData: ShiftFormData): boolean => {
|
||||
const totalWork = calcFormWorkMinutes(formData)
|
||||
const totalProject = calcProjectMinutesTotal(logs)
|
||||
if (totalWork > 0 && totalProject !== totalWork) {
|
||||
const wH = Math.floor(totalWork / 60)
|
||||
const wM = totalWork % 60
|
||||
const pH = Math.floor(totalProject / 60)
|
||||
const pM = totalProject % 60
|
||||
alert.error(
|
||||
`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Create modal
|
||||
// =========================================================================
|
||||
const openCreateModal = () => {
|
||||
const todayDate = new Date().toISOString().split('T')[0]
|
||||
setCreateForm({
|
||||
user_id: '',
|
||||
shift_date: todayDate,
|
||||
leave_type: 'work',
|
||||
leave_hours: 8,
|
||||
arrival_date: todayDate,
|
||||
arrival_time: '',
|
||||
break_start_date: todayDate,
|
||||
break_start_time: '',
|
||||
break_end_date: todayDate,
|
||||
break_end_time: '',
|
||||
departure_date: todayDate,
|
||||
departure_time: '',
|
||||
notes: '',
|
||||
})
|
||||
setCreateProjectLogs([])
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
||||
const handleCreateShiftDateChange = (newDate: string) => {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
shift_date: newDate,
|
||||
arrival_date: newDate,
|
||||
break_start_date: newDate,
|
||||
break_end_date: newDate,
|
||||
departure_date: newDate,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCreateSubmit = async () => {
|
||||
if (!createForm.user_id || !createForm.shift_date) {
|
||||
alert.error('Vyplňte zaměstnance a datum směny')
|
||||
return
|
||||
}
|
||||
|
||||
const filteredCreateLogs = createProjectLogs.filter((l) => l.project_id)
|
||||
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
||||
if (!validateProjectLogs(filteredCreateLogs, createForm)) return
|
||||
}
|
||||
|
||||
try {
|
||||
const isLeave = createForm.leave_type !== 'work'
|
||||
const payload: Record<string, unknown> = {
|
||||
user_id: Number(createForm.user_id),
|
||||
shift_date: createForm.shift_date,
|
||||
leave_type: createForm.leave_type,
|
||||
notes: createForm.notes || null,
|
||||
}
|
||||
|
||||
if (isLeave) {
|
||||
payload.leave_hours = createForm.leave_hours || 8
|
||||
payload.arrival_time = null
|
||||
payload.departure_time = null
|
||||
payload.break_start = null
|
||||
payload.break_end = null
|
||||
} else {
|
||||
payload.arrival_time = combineDatetime(createForm.arrival_date, createForm.arrival_time)
|
||||
payload.departure_time = combineDatetime(createForm.departure_date, createForm.departure_time)
|
||||
payload.break_start = combineDatetime(createForm.break_start_date, createForm.break_start_time)
|
||||
payload.break_end = combineDatetime(createForm.break_end_date, createForm.break_end_time)
|
||||
}
|
||||
|
||||
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
|
||||
payload.project_logs = filteredCreateLogs
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
alert.success(result.message || result.data?.message || 'Záznam vytvořen')
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se vytvořit záznam')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Bulk modal
|
||||
// =========================================================================
|
||||
const openBulkModal = () => {
|
||||
setBulkForm({
|
||||
month,
|
||||
user_ids: data.users.map((u) => String(u.id)),
|
||||
arrival_time: '08:00',
|
||||
departure_time: '16:30',
|
||||
break_start_time: '12:00',
|
||||
break_end_time: '12:30',
|
||||
})
|
||||
setShowBulkModal(true)
|
||||
}
|
||||
|
||||
const toggleBulkUser = (userId: number | string) => {
|
||||
const uid = String(userId)
|
||||
setBulkForm((prev) => ({
|
||||
...prev,
|
||||
user_ids: prev.user_ids.includes(uid)
|
||||
? prev.user_ids.filter((u) => u !== uid)
|
||||
: [...prev.user_ids, uid],
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleAllBulkUsers = () => {
|
||||
const allIds = data.users.map((u) => String(u.id))
|
||||
setBulkForm((prev) => ({
|
||||
...prev,
|
||||
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleBulkSubmit = async () => {
|
||||
if (!bulkForm.month) {
|
||||
alert.error('Vyberte měsíc')
|
||||
return
|
||||
}
|
||||
if (bulkForm.user_ids.length === 0) {
|
||||
alert.error('Vyberte alespoň jednoho zaměstnance')
|
||||
return
|
||||
}
|
||||
|
||||
setBulkSubmitting(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance?action=bulk_attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(bulkForm),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setShowBulkModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
alert.success(result.message || result.data?.message || 'Záznamy vytvořeny')
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se vytvořit záznamy')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setBulkSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Edit modal
|
||||
// =========================================================================
|
||||
const openEditModal = (record: AttendanceRecord) => {
|
||||
// Enrich record with user_name for the modal subtitle
|
||||
const userName = record.users
|
||||
? `${record.users.first_name} ${record.users.last_name}`.trim() || record.users.username
|
||||
: (record as Record<string, unknown>).user_name as string || `User #${record.user_id}`
|
||||
const enriched = { ...record, user_name: userName }
|
||||
setEditingRecord(enriched)
|
||||
|
||||
const shiftDate = getDatePart(record.shift_date) || record.shift_date
|
||||
setEditForm({
|
||||
user_id: String(record.user_id),
|
||||
shift_date: shiftDate,
|
||||
leave_type: record.leave_type || 'work',
|
||||
leave_hours: Number(record.leave_hours) || 8,
|
||||
arrival_date: getDatePart(record.arrival_time) || shiftDate,
|
||||
arrival_time: getTimePart(record.arrival_time),
|
||||
break_start_date: getDatePart(record.break_start) || shiftDate,
|
||||
break_start_time: getTimePart(record.break_start),
|
||||
break_end_date: getDatePart(record.break_end) || shiftDate,
|
||||
break_end_time: getTimePart(record.break_end),
|
||||
departure_date: getDatePart(record.departure_time) || shiftDate,
|
||||
departure_time: getTimePart(record.departure_time),
|
||||
notes: record.notes || '',
|
||||
})
|
||||
|
||||
const logs: ProjectLog[] = (record.project_logs || []).map((l) => {
|
||||
if (l.hours !== null && l.hours !== undefined) {
|
||||
return {
|
||||
project_id: String(l.project_id),
|
||||
hours: String(l.hours),
|
||||
minutes: String(l.minutes || 0),
|
||||
}
|
||||
}
|
||||
if (l.started_at && l.ended_at) {
|
||||
const mins = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(l.ended_at).getTime() - new Date(l.started_at).getTime()) / 60000,
|
||||
),
|
||||
)
|
||||
return {
|
||||
project_id: String(l.project_id),
|
||||
hours: String(Math.floor(mins / 60)),
|
||||
minutes: String(mins % 60),
|
||||
}
|
||||
}
|
||||
return { project_id: String(l.project_id), hours: '', minutes: '' }
|
||||
})
|
||||
setEditProjectLogs(logs)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editingRecord) return
|
||||
|
||||
const isWork = (editForm.leave_type || 'work') === 'work'
|
||||
const filteredEditLogs = isWork ? editProjectLogs.filter((l) => l.project_id) : []
|
||||
if (filteredEditLogs.length > 0) {
|
||||
if (!validateProjectLogs(filteredEditLogs, editForm)) return
|
||||
}
|
||||
|
||||
try {
|
||||
const isLeave = editForm.leave_type !== 'work'
|
||||
const payload: Record<string, unknown> = {
|
||||
leave_type: editForm.leave_type,
|
||||
notes: editForm.notes || null,
|
||||
}
|
||||
|
||||
if (isLeave) {
|
||||
payload.leave_hours = editForm.leave_hours || 8
|
||||
payload.arrival_time = null
|
||||
payload.departure_time = null
|
||||
payload.break_start = null
|
||||
payload.break_end = null
|
||||
} else {
|
||||
payload.arrival_time = combineDatetime(editForm.arrival_date, editForm.arrival_time)
|
||||
payload.departure_time = combineDatetime(editForm.departure_date, editForm.departure_time)
|
||||
payload.break_start = combineDatetime(editForm.break_start_date, editForm.break_start_time)
|
||||
payload.break_end = combineDatetime(editForm.break_end_date, editForm.break_end_time)
|
||||
}
|
||||
|
||||
if (filteredEditLogs.length > 0) {
|
||||
payload.project_logs = filteredEditLogs
|
||||
}
|
||||
|
||||
const response = await apiFetch(`${API_BASE}/attendance/${editingRecord.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false)
|
||||
await fetchData(false)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
alert.success(result.message || result.data?.message || 'Záznam aktualizován')
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se uložit')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Delete
|
||||
// =========================================================================
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm.record) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`${API_BASE}/attendance/${deleteConfirm.record.id}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setDeleteConfirm({ show: false, record: null })
|
||||
await fetchData(false)
|
||||
alert.success(result.message || result.data?.message || 'Záznam smazán')
|
||||
} else {
|
||||
alert.error(result.error || 'Nepodařilo se smazat')
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Print (stub)
|
||||
// =========================================================================
|
||||
const handlePrint = async () => {
|
||||
// TODO: implement print functionality
|
||||
alert.success('Funkce tisku bude brzy dostupná')
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Derived
|
||||
// =========================================================================
|
||||
const hasData = Object.keys(data.user_totals).length > 0
|
||||
|
||||
// =========================================================================
|
||||
// Public API
|
||||
// =========================================================================
|
||||
return {
|
||||
loading,
|
||||
month,
|
||||
setMonth,
|
||||
filterUserId,
|
||||
setFilterUserId,
|
||||
data,
|
||||
hasData,
|
||||
showBulkModal,
|
||||
setShowBulkModal,
|
||||
bulkSubmitting,
|
||||
bulkForm,
|
||||
setBulkForm,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
createForm,
|
||||
setCreateForm,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
editingRecord,
|
||||
editForm,
|
||||
setEditForm,
|
||||
deleteConfirm,
|
||||
setDeleteConfirm,
|
||||
projectList,
|
||||
createProjectLogs,
|
||||
setCreateProjectLogs,
|
||||
editProjectLogs,
|
||||
setEditProjectLogs,
|
||||
printRef,
|
||||
openCreateModal,
|
||||
handleCreateShiftDateChange,
|
||||
handleCreateSubmit,
|
||||
openBulkModal,
|
||||
toggleBulkUser,
|
||||
toggleAllBulkUsers,
|
||||
handleBulkSubmit,
|
||||
openEditModal,
|
||||
handleEditSubmit,
|
||||
handleDelete,
|
||||
handlePrint,
|
||||
}
|
||||
}
|
||||
14
src/admin/hooks/useDebounce.ts
Normal file
14
src/admin/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
return () => clearTimeout(handler)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
91
src/admin/hooks/useListData.ts
Normal file
91
src/admin/hooks/useListData.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import apiFetch from '../utils/api'
|
||||
import useDebounce from './useDebounce'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
interface PaginationData {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface UseListDataOptions {
|
||||
dataKey?: string
|
||||
search?: string
|
||||
sort?: string
|
||||
order?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
extraParams?: Record<string, string>
|
||||
errorMsg?: string
|
||||
}
|
||||
|
||||
export default function useListData<T = unknown>(
|
||||
endpoint: string,
|
||||
options: UseListDataOptions = {}
|
||||
) {
|
||||
const { dataKey, search = '', sort, order, page = 1, perPage = 25, extraParams = {}, errorMsg = 'Nepodařilo se načíst data' } = options
|
||||
const alert = useAlert()
|
||||
const [items, setItems] = useState<T[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initialLoad, setInitialLoad] = useState(true)
|
||||
const [pagination, setPagination] = useState<PaginationData | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
})
|
||||
if (debouncedSearch) params.set('search', debouncedSearch)
|
||||
if (sort) params.set('sort', sort)
|
||||
if (order) params.set('order', order)
|
||||
Object.entries(extraParams).forEach(([k, v]) => {
|
||||
if (v) params.set(k, v)
|
||||
})
|
||||
|
||||
const url = endpoint.startsWith('/') ? `${endpoint}?${params}` : `${API_BASE}/${endpoint}?${params}`
|
||||
const response = await apiFetch(url, { signal: controller.signal })
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
const data = dataKey ? result.data[dataKey] : (Array.isArray(result.data) ? result.data : result.data?.items || [])
|
||||
setItems(data || [])
|
||||
const pag = result.pagination || (!Array.isArray(result.data) && result.data?.pagination) || null
|
||||
setPagination(pag || {
|
||||
total: data?.length ?? 0,
|
||||
page,
|
||||
per_page: perPage,
|
||||
total_pages: 1,
|
||||
})
|
||||
} else {
|
||||
alert.error(result.error || errorMsg)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
alert.error(errorMsg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setInitialLoad(false)
|
||||
}
|
||||
}, [endpoint, debouncedSearch, sort, order, page, perPage, dataKey, JSON.stringify(extraParams)]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
return () => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
}
|
||||
}, [fetchData])
|
||||
|
||||
return { items, setItems, loading, initialLoad, pagination, refetch: fetchData }
|
||||
}
|
||||
14
src/admin/hooks/useModalLock.ts
Normal file
14
src/admin/hooks/useModalLock.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
}
|
||||
19
src/admin/hooks/useTableSort.ts
Normal file
19
src/admin/hooks/useTableSort.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export default function useTableSort(defaultSort = 'id') {
|
||||
const [sort, setSort] = useState(defaultSort)
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const handleSort = useCallback((column: string) => {
|
||||
setSort(prev => {
|
||||
if (prev === column) {
|
||||
setOrder(o => (o === 'asc' ? 'desc' : 'asc'))
|
||||
return column
|
||||
}
|
||||
setOrder('desc')
|
||||
return column
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { sort, order, handleSort, activeSort: sort }
|
||||
}
|
||||
Reference in New Issue
Block a user