diff --git a/client/src/app/registry.ts b/client/src/app/registry.ts index e76d32bf..1fcdac8f 100644 --- a/client/src/app/registry.ts +++ b/client/src/app/registry.ts @@ -17,4 +17,9 @@ export const MODULE_REGISTRY: Record = { basePath: "/placement", load: () => import("../modules/placement/routes"), }, + leave_management: { + code: "leave_management", + basePath: "/leave", + load: () => import("../modules/leave/routes"), + }, }; diff --git a/client/src/modules/leave/api.ts b/client/src/modules/leave/api.ts new file mode 100644 index 00000000..d7c0e78f --- /dev/null +++ b/client/src/modules/leave/api.ts @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { http } from "../../lib/http"; + +type Page = { results: T[] }; +export type LeaveCategory = { code: string; label: string; description: string }; +export type LeaveRequest = { + id: number; category_code: string; request_type: string; status: string; + start_date: string; end_date: string; duration_days: string; reason: string; +}; +export type LeaveBalance = { + id: number; category_code: string; year: number; entitlement: string; + carried_over: string; available: string; +}; + +const key = ["leave"] as const; +export function useMyLeave() { + const categories = useQuery({ queryKey: [...key, "categories"], queryFn: async () => + (await http.get>("/leave/categories")).data }); + const requests = useQuery({ queryKey: [...key, "requests"], queryFn: async () => + (await http.get>("/leave/requests")).data }); + const balances = useQuery({ queryKey: [...key, "balances"], queryFn: async () => + (await http.get>("/leave/balances")).data }); + return { categories, requests, balances }; +} + +export function useEmployeeSearch(query: string) { + return useQuery({ queryKey: [...key, "employees", query], queryFn: async () => + (await http.get<{ user_id: number; label: string; kind: string }[]>("/leave/employees", { params: { q: query } })).data, + enabled: query.trim().length >= 2, + staleTime: 30_000, + }); +} + +export function useApplyForLeave() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async (body: Record) => { + const draft = (await http.post("/leave/requests", body)).data; + return (await http.post(`/leave/requests/${draft.id}/submit`, {})).data; + }, + onSuccess: () => client.invalidateQueries({ queryKey: key }), + }); +} + +export function useWithdrawLeave() { + const client = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => + (await http.post(`/leave/requests/${id}/withdraw`, {})).data, + onSuccess: () => client.invalidateQueries({ queryKey: key }), + }); +} + +export type LeaveTask = { id: number; request_id: number; employee_id: number; category_code: string; sequence: number; role: string; state: string }; +export function useTasks() { + const client = useQueryClient(); + const query = useQuery({ queryKey: [...key, "tasks"], queryFn: async () => + (await http.get>("/leave/tasks")).data }); + const decide = useMutation({ + mutationFn: async ({ id, decision }: { id: number; decision: string }) => + (await http.post(`/leave/tasks/${id}/decision`, { decision })).data, + onSuccess: () => client.invalidateQueries({ queryKey: key }), + }); + return { ...query, decide }; +} + +export function useSubstitutions() { + const client = useQueryClient(); + const query = useQuery({ queryKey: [...key, "substitutions"], queryFn: async () => + (await http.get>("/leave/substitutions")).data }); + const respond = useMutation({ + mutationFn: async ({ id, decision }: { id: number; decision: "accept" | "decline" }) => + (await http.post(`/leave/requests/${id}/substitute-response`, { decision })).data, + onSuccess: () => client.invalidateQueries({ queryKey: key }), + }); + return { ...query, respond }; +} + +export type LeaveCalendar = { id: number; year: number; revision: number; holidays: { date: string; description?: string; type?: string; restricted_holiday?: boolean }[] }; +export function useLeaveCalendars() { + return useQuery({ queryKey: [...key, "calendars"], queryFn: async () => + (await http.get>("/leave/calendars")).data }); +} diff --git a/client/src/modules/leave/pages/CalendarPage.tsx b/client/src/modules/leave/pages/CalendarPage.tsx new file mode 100644 index 00000000..574a2008 --- /dev/null +++ b/client/src/modules/leave/pages/CalendarPage.tsx @@ -0,0 +1,26 @@ +import { Alert, Card, Container, Stack, Table, Text } from "@mantine/core"; +import { ErrorState } from "../../../ui/components/ErrorState"; +import { PageHeader } from "../../../ui/components/PageHeader"; +import { useLeaveCalendars } from "../api"; + +export default function CalendarPage() { + const calendars = useLeaveCalendars(); + if (calendars.error) return ; + const published = calendars.data?.results ?? []; + return + + + {published.length === 0 && No published holiday calendar is available.} + {published.map((calendar) => + {calendar.year} · revision {calendar.revision} + {calendar.holidays.length === 0 ? No holiday dates listed. : + DateDescriptionType + {calendar.holidays.map((holiday, index) => + {holiday.date}{holiday.description ?? "—"} + {holiday.restricted_holiday || holiday.type === "RH" ? "Restricted holiday" : holiday.type ?? "Holiday"} + )} +
} +
)} +
+
; +} diff --git a/client/src/modules/leave/pages/MyLeavePage.tsx b/client/src/modules/leave/pages/MyLeavePage.tsx new file mode 100644 index 00000000..e787a9a9 --- /dev/null +++ b/client/src/modules/leave/pages/MyLeavePage.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { Alert, Button, Card, Checkbox, Container, Grid, Group, Select, Stack, Table, Text, TextInput, Textarea } from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { errorMessage } from "../../../lib/http"; +import { ErrorState } from "../../../ui/components/ErrorState"; +import { PageHeader } from "../../../ui/components/PageHeader"; +import { useApplyForLeave, useEmployeeSearch, useMyLeave, useWithdrawLeave } from "../api"; + +export default function MyLeavePage() { + const { categories, requests, balances } = useMyLeave(); + const apply = useApplyForLeave(); + const withdraw = useWithdrawLeave(); + const [category, setCategory] = useState(null); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + const [reason, setReason] = useState(""); + const [portion, setPortion] = useState("full"); + const [stationLeave, setStationLeave] = useState(false); + const [destination, setDestination] = useState(""); + const [departure, setDeparture] = useState(""); + const [returnTime, setReturnTime] = useState(""); + const [substituteSearch, setSubstituteSearch] = useState(""); + const [substituteUserId, setSubstituteUserId] = useState(null); + const employees = useEmployeeSearch(substituteSearch); + if (requests.error) return ; + + function submit() { + if (!category || !start || !end || !reason.trim()) return; + apply.mutate({ category_code: category, start_date: start, end_date: end, + reason, cl_portion: category === "CL" ? portion : "full", station_leave: stationLeave, + station_destination: destination, + station_departure_at: departure ? new Date(departure).toISOString() : null, + station_return_at: returnTime ? new Date(returnTime).toISOString() : null, + substitute_user_id: substituteUserId ? Number(substituteUserId) : null }, { + onSuccess: () => { setReason(""); setStationLeave(false); setDestination(""); setDeparture(""); setReturnTime(""); setSubstituteUserId(null); notifications.show({ color: "green", title: "Leave request submitted", message: "You can follow its progress in your request history." }); }, + onError: (error) => notifications.show({ color: "red", title: "Could not submit request", message: errorMessage(error) }), + }); + } + + return + + + {(balances.data?.results ?? []).map((balance) => + {balance.category_code} · {balance.year} + Available{balance.available} days + Entitlement {balance.entitlement}; carried forward {balance.carried_over} + + )} + + + New leave application + + setPortion(v ?? "full")} + data={[{ value: "full", label: "Full day" }, { value: "first_half", label: "First half" }, { value: "second_half", label: "Second half" }]} />} + {(category === "CL" || category === "RH") && setStationLeave(e.currentTarget.checked)} />} + {stationLeave && <> + setDestination(e.currentTarget.value)} /> + setDeparture(e.currentTarget.value)} /> + setReturnTime(e.currentTarget.value)} /> + } +