v1.6.5: fix attendance unique constraint, schema sync, seed script

- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-17 16:21:34 +02:00
parent 1db5060c42
commit cd6d3daf43
75 changed files with 1748 additions and 1169 deletions

View File

@@ -1,49 +0,0 @@
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 { signal: _, ...restOptions } = options;
const response = await apiFetch(url, {
...restOptions,
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

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import {
calcProjectMinutesTotal,
@@ -461,6 +462,7 @@ function buildPrintHtml(
// ---------------------------------------------------------------------------
export default function useAttendanceAdmin({ alert }: AlertContext) {
const queryClient = useQueryClient();
// ---- Core state ----
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
@@ -771,6 +773,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -842,6 +845,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -976,6 +980,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1005,6 +1010,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -1,139 +0,0 @@
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 mountedRef = useRef(true);
const debouncedSearch = useDebounce(search, 300);
const extraParamsKey = Object.entries(extraParams)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([k, v]) => `${k}=${v}`)
.join("&");
const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
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) {
window.location.href = "/login";
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;
if (!mountedRef.current) return;
alert.error(errorMsg);
} finally {
if (!mountedRef.current) return;
setLoading(false);
setInitialLoad(false);
}
}, [
endpoint,
debouncedSearch,
sort,
order,
page,
perPage,
dataKey,
extraParamsKey,
]);
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
if (abortRef.current) abortRef.current.abort();
};
}, [fetchData]);
return {
items,
setItems,
loading,
initialLoad,
pagination,
refetch: fetchData,
};
}