From ca911bf96c789226eb15f061c3d241aaa43b9a56 Mon Sep 17 00:00:00 2001 From: BOHA Date: Fri, 5 Jun 2026 12:54:50 +0200 Subject: [PATCH] feat(plan): react query options and page state hook --- src/admin/hooks/usePlanWork.ts | 174 +++++++++++++++++++++++++++++++++ src/admin/lib/queries/plan.ts | 63 ++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 src/admin/hooks/usePlanWork.ts create mode 100644 src/admin/lib/queries/plan.ts diff --git a/src/admin/hooks/usePlanWork.ts b/src/admin/hooks/usePlanWork.ts new file mode 100644 index 0000000..55294cd --- /dev/null +++ b/src/admin/hooks/usePlanWork.ts @@ -0,0 +1,174 @@ +import { useState, useCallback, useMemo } from "react"; +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; +import apiFetch from "../utils/api"; +import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan"; + +export type ViewMode = "week" | "month"; +export type ModalMode = + | { kind: "closed" } + | { kind: "create"; userId: number; date: string } + | { kind: "edit-range"; entryId: number; userId: number; date: string } + | { kind: "edit-override"; overrideId: number; userId: number; date: string } + | { kind: "day-in-range"; userId: number; date: string; entryId: number } + | { kind: "view"; userId: number; date: string }; + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function startOfWeek(d: Date): Date { + const day = d.getUTCDay(); // 0 = Sun + const diff = day === 0 ? -6 : 1 - day; // Monday-start + const r = new Date(d); + r.setUTCDate(d.getUTCDate() + diff); + return r; +} + +function addDays(d: Date, n: number): Date { + const r = new Date(d); + r.setUTCDate(d.getUTCDate() + n); + return r; +} + +export interface UsePlanWorkArgs { + initialDate?: Date; + canEdit: boolean; +} + +export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) { + const qc = useQueryClient(); + const [view, setView] = useState("week"); + const [anchor, setAnchor] = useState(initialDate ?? new Date()); + const [filterActive, setFilterActive] = useState(true); + const [modal, setModal] = useState({ kind: "closed" }); + + const range = useMemo(() => { + if (view === "week") { + const from = startOfWeek(anchor); + const to = addDays(from, 6); + return { from, to }; + } + const from = new Date( + Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1), + ); + const to = new Date( + Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0), + ); + return { from, to }; + }, [view, anchor]); + + const gridQuery = useQuery({ + queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view), + queryFn: () => + apiFetch( + `/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`, + ).then((r) => r.json().then((b: any) => b.data as GridData)), + }); + + const invalidate = useCallback(() => { + qc.invalidateQueries({ queryKey: planKeys.all }); + }, [qc]); + + // --- Mutations --- + const createEntry = useMutation({ + mutationFn: (body: any) => + apiFetch("/api/admin/plan/entries", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: invalidate, + }); + + const updateEntry = useMutation({ + mutationFn: ({ + id, + body, + force, + }: { + id: number; + body: any; + force?: boolean; + }) => + apiFetch(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: invalidate, + }); + + const deleteEntry = useMutation({ + mutationFn: ({ id, force }: { id: number; force?: boolean }) => + apiFetch(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, { + method: "DELETE", + }), + onSuccess: invalidate, + }); + + const createOverride = useMutation({ + mutationFn: (body: any) => + apiFetch("/api/admin/plan/overrides", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: invalidate, + }); + + const updateOverride = useMutation({ + mutationFn: ({ + id, + body, + force, + }: { + id: number; + body: any; + force?: boolean; + }) => + apiFetch(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + onSuccess: invalidate, + }); + + const deleteOverride = useMutation({ + mutationFn: ({ id, force }: { id: number; force?: boolean }) => + apiFetch(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, { + method: "DELETE", + }), + onSuccess: invalidate, + }); + + return { + view, + setView, + anchor, + setAnchor, + filterActive, + setFilterActive, + range, + grid: gridQuery.data, + gridLoading: gridQuery.isLoading, + modal, + setModal, + canEdit, + createEntry, + updateEntry, + deleteEntry, + createOverride, + updateOverride, + deleteOverride, + invalidate, + }; +} + +export function getCell( + grid: GridData | undefined, + userId: number, + date: string, +): ResolvedCell | null { + return grid?.cells?.[userId]?.[date] ?? null; +} diff --git a/src/admin/lib/queries/plan.ts b/src/admin/lib/queries/plan.ts new file mode 100644 index 0000000..18e8ad0 --- /dev/null +++ b/src/admin/lib/queries/plan.ts @@ -0,0 +1,63 @@ +import { queryOptions } from "@tanstack/react-query"; +import apiFetch from "../../utils/api"; +import { jsonQuery } from "../apiAdapter"; + +export interface PlanUser { + id: number; + full_name: string; + username: string; + role_name: string | null; + is_active: boolean; + has_attendance_record: boolean; +} + +export interface ResolvedCell { + source: "entry" | "override"; + entryId: number | null; + overrideId: number | null; + user_id: number; + shift_date: string; + project_id: number | null; + category: string; + note: string; + rangeFrom: string | null; + rangeTo: string | null; +} + +export interface GridData { + view: "week" | "month"; + date_from: string; + date_to: string; + users: PlanUser[]; + cells: Record>; +} + +export const planKeys = { + all: ["plan"] as const, + grid: (dateFrom: string, dateTo: string, view: "week" | "month") => + ["plan", "grid", { dateFrom, dateTo, view }] as const, + entries: (userId: number | null, dateFrom: string, dateTo: string) => + ["plan", "entries", { userId, dateFrom, dateTo }] as const, + overrides: (userId: number | null, dateFrom: string, dateTo: string) => + ["plan", "overrides", { userId, dateFrom, dateTo }] as const, + users: () => ["plan", "users"] as const, +}; + +export const gridQuery = ( + dateFrom: string, + dateTo: string, + view: "week" | "month", +) => + queryOptions({ + queryKey: planKeys.grid(dateFrom, dateTo, view), + queryFn: () => + jsonQuery( + `/api/admin/plan/grid?date_from=${dateFrom}&date_to=${dateTo}&view=${view}`, + ), + }); + +export const usersQuery = () => + queryOptions({ + queryKey: planKeys.users(), + queryFn: () => jsonQuery("/api/admin/plan/users"), + });