diff --git a/.importlinter b/.importlinter index d964dc3c..ac2796ba 100644 --- a/.importlinter +++ b/.importlinter @@ -13,6 +13,7 @@ name = Modules may reach each other ONLY through contracts.py type = forbidden source_modules = modules.placement + modules.elm modules.directory modules.accesscontrol forbidden_modules = @@ -21,6 +22,11 @@ forbidden_modules = modules.placement.selectors modules.placement.api modules.placement.domain + modules.elm.models + modules.elm.services + modules.elm.selectors + modules.elm.api + modules.elm.domain modules.directory.models modules.directory.services modules.directory.api @@ -38,6 +44,7 @@ type = forbidden source_modules = # Every module's domain, not just the first one written. modules.placement.domain + modules.elm.domain forbidden_modules = django rest_framework @@ -66,3 +73,13 @@ layers = modules.placement.selectors modules.placement.models modules.placement.domain + +[importlinter:contract:elm-layers] +name = Layering inside elm +type = layers +layers = + modules.elm.api + modules.elm.services + modules.elm.selectors + modules.elm.models + modules.elm.domain diff --git a/client/src/app/registry.ts b/client/src/app/registry.ts index e76d32bf..62a76ddf 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"), }, + elm: { + code: "elm", + basePath: "/elm", + load: () => import("../modules/elm/routes"), + }, }; diff --git a/client/src/modules/elm/api/hooks.test.tsx b/client/src/modules/elm/api/hooks.test.tsx new file mode 100644 index 00000000..852f15f8 --- /dev/null +++ b/client/src/modules/elm/api/hooks.test.tsx @@ -0,0 +1,60 @@ +/** The write hooks post to the right endpoint and refresh the module's data. */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as download from "../../../lib/download"; +import { http } from "../../../lib/http"; +import { useDecide, useEmployeeSearch, usePrint, useWithdraw } from "./hooks"; + +function setup() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { wrapper, invalidate }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe("elm hooks", () => { + it("records a decision against the queue endpoint and refreshes", async () => { + const post = vi.spyOn(http, "post").mockResolvedValue({ data: { id: 7 } }); + const { wrapper, invalidate } = setup(); + const { result } = renderHook(() => useDecide(), { wrapper }); + result.current.mutate({ id: 7, event: "reject", note: "short staffed" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(post).toHaveBeenCalledWith("/elm/queue/7/decide", + { event: "reject", note: "short staffed" }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["elm"] }); + }); + + it("withdraws with a reason only", async () => { + const post = vi.spyOn(http, "post").mockResolvedValue({ data: {} }); + const { wrapper } = setup(); + const { result } = renderHook(() => useWithdraw(), { wrapper }); + result.current.mutate({ id: 3, reason: "plans changed" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(post).toHaveBeenCalledWith("/elm/requests/3/withdraw", { reason: "plans changed" }); + }); + + it("does not search the directory for a single letter", () => { + const get = vi.spyOn(http, "get"); + const { wrapper } = setup(); + renderHook(() => useEmployeeSearch("a"), { wrapper }); + expect(get).not.toHaveBeenCalled(); + }); + + it("saves the printable application instead of navigating to it", async () => { + vi.spyOn(http, "get").mockResolvedValue({ data: new Blob(["x"]), headers: {} }); + const save = vi.spyOn(download, "saveBlob").mockImplementation(() => undefined); + const { wrapper } = setup(); + const { result } = renderHook(() => usePrint(), { wrapper }); + result.current.mutate(12); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(save).toHaveBeenCalledWith(expect.any(Blob), "leave-12.txt"); + }); +}); diff --git a/client/src/modules/elm/api/hooks.ts b/client/src/modules/elm/api/hooks.ts new file mode 100644 index 00000000..baa68ddc --- /dev/null +++ b/client/src/modules/elm/api/hooks.ts @@ -0,0 +1,154 @@ +/** Data access for ELM. Every mutation invalidates the whole module key. */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { saveBlob } from "../../../lib/download"; +import { http } from "../../../lib/http"; +import type { + Balance, CalendarEntry, CalendarVersion, Employee, LeaveDetail, LeaveInput, + LeaveRequest, Notification, OfflineInput, OfflineRecord, Page, PolicyVersion, +} from "./types"; + +const KEY = "elm"; +export const keys = { + mine: () => [KEY, "mine"] as const, + detail: (id: number) => [KEY, "detail", id] as const, + balances: () => [KEY, "balances"] as const, + account: (uid: number) => [KEY, "account", uid] as const, + substitutions: () => [KEY, "substitutions"] as const, + queue: () => [KEY, "queue"] as const, + resumptions: () => [KEY, "resumptions"] as const, + policies: () => [KEY, "policies"] as const, + calendars: () => [KEY, "calendars"] as const, + offline: () => [KEY, "offline"] as const, + employees: (q: string) => [KEY, "employees", q] as const, + notifications: () => [KEY, "notifications"] as const, +}; + +function useInvalidate() { + const qc = useQueryClient(); + return () => qc.invalidateQueries({ queryKey: [KEY] }); +} + +function list(key: readonly unknown[], url: string) { + return { + queryKey: key, + queryFn: async () => (await http.get>(url)).data, + }; +} + +export const useMyRequests = () => useQuery(list(keys.mine(), "/elm/requests")); +export const useSubstitutions = () => + useQuery(list(keys.substitutions(), "/elm/substitutions")); +export const useQueue = () => useQuery(list(keys.queue(), "/elm/queue")); +export const useResumptions = () => + useQuery(list(keys.resumptions(), "/elm/resumptions")); +export const usePolicies = () => + useQuery(list(keys.policies(), "/elm/admin/policies")); +export const useCalendars = () => + useQuery(list(keys.calendars(), "/elm/admin/calendars")); +export const useOfflineRecords = () => + useQuery(list(keys.offline(), "/elm/admin/offline")); +export const useNotifications = () => + useQuery(list(keys.notifications(), "/elm/notifications")); + +export function useRequest(id: number | null) { + return useQuery({ + queryKey: keys.detail(id ?? 0), + queryFn: async () => (await http.get(`/elm/requests/${id}`)).data, + enabled: id != null, + }); +} + +export function useBalances() { + return useQuery({ + queryKey: keys.balances(), + queryFn: async () => (await http.get("/elm/balances")).data, + }); +} + +export function useAccount(userId: number | null) { + return useQuery({ + queryKey: keys.account(userId ?? 0), + queryFn: async () => + (await http.get("/elm/admin/accounts", { params: { user_id: userId } })).data, + enabled: userId != null, + retry: false, + }); +} + +export function useEmployeeSearch(q: string) { + return useQuery({ + queryKey: keys.employees(q), + queryFn: async () => + (await http.get<{ results: Employee[] }>("/elm/employees", { params: { q } })) + .data.results, + enabled: q.trim().length >= 2, + staleTime: 60_000, + }); +} + +function usePost(url: (v: V) => string, body: (v: V) => unknown = (v) => v) { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: async (v: V) => (await http.post(url(v), body(v))).data, + onSuccess: invalidate, + }); +} + +export const useApply = () => + usePost(() => "/elm/requests"); +export const useModify = () => + usePost<{ id: number; data: LeaveInput }>((v) => `/elm/requests/${v.id}/modify`, + (v) => v.data); +export const useWithdraw = () => + usePost<{ id: number; reason: string }>((v) => `/elm/requests/${v.id}/withdraw`, + (v) => ({ reason: v.reason })); +export const useCancel = () => + usePost<{ id: number; reason: string }>((v) => `/elm/requests/${v.id}/cancel`, + (v) => ({ reason: v.reason })); +export const useRenominate = () => + usePost<{ id: number; substitute_user_id: number; responsibility: string }>( + (v) => `/elm/requests/${v.id}/substitute`, + (v) => ({ substitute_user_id: v.substitute_user_id, responsibility: v.responsibility })); +export const useExtend = () => + usePost<{ id: number; end_date: string; purpose: string; + substitute_user_id: number | null }>( + (v) => `/elm/requests/${v.id}/extend`, + (v) => ({ end_date: v.end_date, purpose: v.purpose, + substitute_user_id: v.substitute_user_id })); +export const useResume = () => + usePost<{ id: number; actual_return_date: string; note: string }>( + (v) => `/elm/requests/${v.id}/resume`, + (v) => ({ actual_return_date: v.actual_return_date, note: v.note })); +export const useRespondSubstitution = () => + usePost<{ id: number; accept: boolean; note: string }>( + (v) => `/elm/substitutions/${v.id}/respond`, + (v) => ({ accept: v.accept, note: v.note })); +export const useDecide = () => + usePost<{ id: number; event: string; note: string }>( + (v) => `/elm/queue/${v.id}/decide`, (v) => ({ event: v.event, note: v.note })); +export const useVerify = () => + usePost<{ id: number; verified: boolean; note: string }>( + (v) => `/elm/resumptions/${v.id}/verify`, + (v) => ({ verified: v.verified, note: v.note })); +export const useCreatePolicy = () => + usePost>( + () => "/elm/admin/policies"); +export const usePublishPolicy = () => + usePost((id) => `/elm/admin/policies/${id}/publish`, () => ({})); +export const useCreateCalendar = () => + usePost<{ name: string; effective_from: string; entries: CalendarEntry[] }>( + () => "/elm/admin/calendars"); +export const usePublishCalendar = () => + usePost((id) => `/elm/admin/calendars/${id}/publish`, () => ({})); +export const useRecordOffline = () => + usePost(() => "/elm/admin/offline"); + +/** EL-UC-013: fetched, not navigated to, so a refusal stays in the app. */ +export function usePrint() { + return useMutation({ + mutationFn: async (id: number) => { + const res = await http.get(`/elm/requests/${id}/print`, { responseType: "blob" }); + saveBlob(res.data as Blob, `leave-${id}.txt`); + }, + }); +} diff --git a/client/src/modules/elm/api/types.ts b/client/src/modules/elm/api/types.ts new file mode 100644 index 00000000..f0b66a1b --- /dev/null +++ b/client/src/modules/elm/api/types.ts @@ -0,0 +1,175 @@ +/** Mirrors modules/elm/api/serializers.py. */ +export interface Page { + results: T[]; + next: string | null; + previous: string | null; +} + +export type Category = "CL" | "RH" | "SCL" | "EL" | "COL" | "VL"; + +export const CATEGORY_LABELS: Record = { + CL: "Casual Leave", + RH: "Restricted Holiday", + SCL: "Special Casual Leave", + EL: "Earned Leave", + COL: "Commuted Leave", + VL: "Vacation Leave", +}; + +export interface RouteStep { + step: "unit_head" | "establishment" | "sanction" | "director"; + role: string; +} + +export interface LeaveRequest { + id: number; + kind: "leave" | "extension" | "offline"; + parent_id: number | null; + user_id: number; + applicant_name: string; + applicant_kind: string; + applicant_unit: string; + category: Category; + category_label: string; + start_date: string; + end_date: string; + half: "" | "first" | "second"; + days: string; + purpose: string; + medical_evidence: string; + station_leave: boolean; + station_from: string | null; + station_to: string | null; + station_address: string; + substitute_user_id: number | null; + substitute_name: string; + substitute_status: string; + responsibility: string; + status: string; + route: RouteStep[]; + stage: number; + current_role: string; + recommendation: string; + decision_note: string; + decided_at: string | null; + actual_return_date: string | null; + resumption_note: string; + allowed_decisions: string[]; + created_at: string; + updated_at: string; +} + +export interface Transition { + from_status: string; + to_status: string; + event: string; + actor_role: string; + note: string; + at: string; +} + +export interface LeaveDetail extends LeaveRequest { + history: Transition[]; +} + +export interface LeaveInput { + category: Category; + start_date: string; + end_date: string; + half: "" | "first" | "second"; + purpose: string; + medical_evidence: string; + station_leave: boolean; + station_from: string | null; + station_to: string | null; + station_address: string; + substitute_user_id: number | null; + responsibility: string; +} + +export interface LedgerEntry { + id: number; + category: Category; + entry_type: string; + delta: string; + year: number; + request_id: number | null; + note: string; + at: string; +} + +export interface Balance { + user_id: number; + display_name?: string; + balances: Record; + pending: Record; + history: LedgerEntry[]; +} + +export interface Employee { + user_id: number; + display_name: string; + username: string; + kind: string; + department: string; +} + +export interface PolicyVersion { + id: number; + name: string; + params: Record; + hierarchy: Record; + effective_from: string; + status: "draft" | "published"; + published_at: string | null; + created_at: string; +} + +export interface CalendarEntry { + kind: "closed_holiday" | "restricted_holiday" | "vacation"; + name: string; + start_date: string; + end_date: string; +} + +export interface CalendarVersion { + id: number; + name: string; + effective_from: string; + status: "draft" | "published"; + published_at: string | null; + entries: CalendarEntry[]; + created_at: string; +} + +export interface OfflineInput { + user_id: number; + category: Category; + start_date: string; + end_date: string; + half: "" | "first" | "second"; + purpose: string; + sanction_reference: string; + sanctioned_by: string; + sanctioned_on: string; + evidence_note: string; +} + +export interface OfflineRecord { + id: number; + sanction_reference: string; + sanctioned_by: string; + sanctioned_on: string; + evidence_note: string; + recorded_by_user_id: number; + created_at: string; + leave: LeaveRequest; +} + +export interface Notification { + id: number; + message: string; + request_id: number | null; + created_at: string; + read_at: string | null; +} diff --git a/client/src/modules/elm/components/BalanceView.tsx b/client/src/modules/elm/components/BalanceView.tsx new file mode 100644 index 00000000..fa24a695 --- /dev/null +++ b/client/src/modules/elm/components/BalanceView.tsx @@ -0,0 +1,40 @@ +import { Card, SimpleGrid, Stack, Text } from "@mantine/core"; +import { DataTable } from "../../../ui/components/DataTable"; +import { CATEGORY_LABELS, type Balance, type Category } from "../api/types"; + +export function BalanceView({ data, loading }: { data?: Balance; loading: boolean }) { + const cats = Object.keys(CATEGORY_LABELS) as Category[]; + return ( + + + {cats.map((c) => ( + + {CATEGORY_LABELS[c]} + {data ? Number(data.balances[c]) : "—"} + {data && Number(data.pending[c]) > 0 && ( + {Number(data.pending[c])} pending + )} + + ))} + + + + Leave-account history + r.id} + empty={{ title: "No entries yet", + description: "Credits and debits appear here as they are posted." }} + columns={[ + { key: "at", header: "When", render: (r) => new Date(r.at).toLocaleDateString() }, + { key: "category", header: "Leave", render: (r) => r.category }, + { key: "type", header: "Entry", render: (r) => r.entry_type.replace(/_/g, " ") }, + { key: "delta", header: "Days", align: "right", + render: (r) => ( + {r.delta}) }, + { key: "note", header: "Note", render: (r) => r.note }, + ]} + /> + + + ); +} diff --git a/client/src/modules/elm/components/EmployeePicker.tsx b/client/src/modules/elm/components/EmployeePicker.tsx new file mode 100644 index 00000000..bb7840f4 --- /dev/null +++ b/client/src/modules/elm/components/EmployeePicker.tsx @@ -0,0 +1,29 @@ +import { Select } from "@mantine/core"; +import { useState } from "react"; +import { useEmployeeSearch } from "../api/hooks"; + +/** Faculty and staff only; the server never returns students or the caller. */ +export function EmployeePicker({ value, onChange, label, description }: { + value: number | null; + onChange: (id: number | null) => void; + label: string; + description?: string; +}) { + const [q, setQ] = useState(""); + const { data = [], isFetching } = useEmployeeSearch(q); + const options = data.map((e) => ({ + value: String(e.user_id), + label: `${e.display_name} (${e.username}${e.department ? `, ${e.department}` : ""})`, + })); + return ( + ({ value: k, label: l }))} + value={value.category} + onChange={(c) => onChange({ ...value, category: (c ?? "CL") as Category, + half: c === "CL" ? value.half : "" })} + /> + {value.category === "CL" && ( +