initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View 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 }
}

View 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,
}
}

View 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
}

View 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 }
}

View 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])
}

View 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 }
}