Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/src/app/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@ export const MODULE_REGISTRY: Record<string, ModuleManifest> = {
basePath: "/placement",
load: () => import("../modules/placement/routes"),
},
leave_management: {
code: "leave_management",
basePath: "/leave",
load: () => import("../modules/leave/routes"),
},
};
83 changes: 83 additions & 0 deletions client/src/modules/leave/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { http } from "../../lib/http";

type Page<T> = { 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<Page<LeaveCategory>>("/leave/categories")).data });
const requests = useQuery({ queryKey: [...key, "requests"], queryFn: async () =>
(await http.get<Page<LeaveRequest>>("/leave/requests")).data });
const balances = useQuery({ queryKey: [...key, "balances"], queryFn: async () =>
(await http.get<Page<LeaveBalance>>("/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<string, unknown>) => {
const draft = (await http.post<LeaveRequest>("/leave/requests", body)).data;
return (await http.post<LeaveRequest>(`/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<LeaveRequest>(`/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<Page<LeaveTask>>("/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<Page<LeaveRequest>>("/leave/substitutions")).data });
const respond = useMutation({
mutationFn: async ({ id, decision }: { id: number; decision: "accept" | "decline" }) =>
(await http.post<LeaveRequest>(`/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<Page<LeaveCalendar>>("/leave/calendars")).data });
}
26 changes: 26 additions & 0 deletions client/src/modules/leave/pages/CalendarPage.tsx
Original file line number Diff line number Diff line change
@@ -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 <Container size="md"><ErrorState error={calendars.error} /></Container>;
const published = calendars.data?.results ?? [];
return <Container size="md">
<PageHeader title="Holiday and RH calendar" subtitle="Dates from the currently published institute calendar." />
<Stack>
{published.length === 0 && <Alert color="gray">No published holiday calendar is available.</Alert>}
{published.map((calendar) => <Card key={calendar.id} withBorder>
<Text fw={600} mb="sm">{calendar.year} · revision {calendar.revision}</Text>
{calendar.holidays.length === 0 ? <Text size="sm" c="dimmed">No holiday dates listed.</Text> :
<Table striped><Table.Thead><Table.Tr><Table.Th>Date</Table.Th><Table.Th>Description</Table.Th><Table.Th>Type</Table.Th></Table.Tr></Table.Thead>
<Table.Tbody>{calendar.holidays.map((holiday, index) => <Table.Tr key={`${holiday.date}-${index}`}>
<Table.Td>{holiday.date}</Table.Td><Table.Td>{holiday.description ?? "—"}</Table.Td>
<Table.Td>{holiday.restricted_holiday || holiday.type === "RH" ? "Restricted holiday" : holiday.type ?? "Holiday"}</Table.Td>
</Table.Tr>)}</Table.Tbody>
</Table>}
</Card>)}
</Stack>
</Container>;
}
91 changes: 91 additions & 0 deletions client/src/modules/leave/pages/MyLeavePage.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<string | null>(null);
const employees = useEmployeeSearch(substituteSearch);
if (requests.error) return <Container size="lg"><ErrorState error={requests.error} /></Container>;

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 <Container size="lg">
<PageHeader title="My Leave" subtitle="Apply for leave and review your balances and requests." />
<Grid gutter="md" mb="lg">
{(balances.data?.results ?? []).map((balance) => <Grid.Col key={balance.id} span={{ base: 12, sm: 6, md: 4 }}>
<Card withBorder h="100"><Text fw={600}>{balance.category_code} · {balance.year}</Text>
<Text size="sm" c="dimmed">Available</Text><Text size="xl" fw={700}>{balance.available} days</Text>
<Text size="xs" c="dimmed">Entitlement {balance.entitlement}; carried forward {balance.carried_over}</Text>
</Card>
</Grid.Col>)}
</Grid>
<Card withBorder mb="lg">
<Text fw={600} mb="md">New leave application</Text>
<Stack gap="sm">
<Select label="Leave category" placeholder="Select a category" value={category}
onChange={setCategory} data={(categories.data?.results ?? []).map((item) => ({ value: item.code, label: `${item.code} · ${item.label}` }))} />
<Group grow>
<TextInput type="date" label="From" value={start} onChange={(e) => setStart(e.currentTarget.value)} />
<TextInput type="date" label="Through" value={end} onChange={(e) => setEnd(e.currentTarget.value)} />
</Group>
{category === "CL" && <Select label="CL portion" value={portion} onChange={(v) => 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") && <Checkbox label="I will be outside Jabalpur (Station Leave)" checked={stationLeave} onChange={(e) => setStationLeave(e.currentTarget.checked)} />}
{stationLeave && <>
<TextInput label="Destination" value={destination} onChange={(e) => setDestination(e.currentTarget.value)} />
<Group grow><TextInput type="datetime-local" label="Departure" value={departure} onChange={(e) => setDeparture(e.currentTarget.value)} />
<TextInput type="datetime-local" label="Return" value={returnTime} onChange={(e) => setReturnTime(e.currentTarget.value)} /></Group>
</>}
<Select label="Substitute (when responsibility coverage is required)" placeholder="Search employees"
searchable clearable searchValue={substituteSearch} onSearchChange={setSubstituteSearch}
value={substituteUserId} onChange={setSubstituteUserId} nothingFoundMessage={substituteSearch.length < 2 ? "Type at least two characters" : "No eligible employee found"}
data={(employees.data ?? []).map((person) => ({ value: String(person.user_id), label: `${person.label} · ${person.kind}` }))} />
<Textarea label="Purpose / reason" value={reason} onChange={(e) => setReason(e.currentTarget.value)} minRows={2} />
<Button onClick={submit} loading={apply.isPending} disabled={!category || !start || !end || !reason.trim() || (stationLeave && (!destination || !departure || !returnTime))}>Submit application</Button>
</Stack>
</Card>
<Card withBorder>
<Text fw={600} mb="sm">Request history</Text>
{(requests.data?.results?.length ?? 0) === 0 ? <Alert color="gray">No leave applications yet.</Alert> :
<Table.ScrollContainer minWidth={640}><Table striped highlightOnHover>
<Table.Thead><Table.Tr><Table.Th>Category</Table.Th><Table.Th>Period</Table.Th><Table.Th>Days</Table.Th><Table.Th>Status</Table.Th><Table.Th /></Table.Tr></Table.Thead>
<Table.Tbody>{requests.data?.results.map((item) => <Table.Tr key={item.id}>
<Table.Td>{item.category_code}</Table.Td><Table.Td>{item.start_date} – {item.end_date}</Table.Td>
<Table.Td>{item.duration_days}</Table.Td><Table.Td>{item.status.replaceAll("_", " ")}</Table.Td>
<Table.Td>{["awaiting_substitute", "applicant_action", "pending_unit_head", "pending_establishment", "pending_authority"].includes(item.status) &&
<Button size="xs" variant="light" color="red" loading={withdraw.isPending} onClick={() => withdraw.mutate(item.id, {
onError: (error) => notifications.show({ color: "red", title: "Could not withdraw request", message: errorMessage(error) }),
})}>Withdraw</Button>}</Table.Td>
</Table.Tr>)}</Table.Tbody>
</Table></Table.ScrollContainer>}
</Card>
</Container>;
}
27 changes: 27 additions & 0 deletions client/src/modules/leave/pages/SubstitutionsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Alert, Button, Card, Container, Group, Stack, Text } 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 { useSubstitutions } from "../api";

export default function SubstitutionsPage() {
const inbox = useSubstitutions();
if (inbox.error) return <Container size="md"><ErrorState error={inbox.error} /></Container>;
return <Container size="md">
<PageHeader title="Substitute requests" subtitle="Accept or decline responsibility coverage assigned to you." />
<Stack>
{(inbox.data?.results ?? []).length === 0 && <Alert color="gray">There are no pending substitute requests.</Alert>}
{(inbox.data?.results ?? []).map((request) => <Card key={request.id} withBorder>
<Group justify="space-between">
<div><Text fw={600}>{request.category_code} · {request.start_date} to {request.end_date}</Text>
<Text size="sm" c="dimmed">Applicant #{request.employee_id} · {request.reason}</Text></div>
<Group>
<Button size="xs" color="green" onClick={() => inbox.respond.mutate({ id: request.id, decision: "accept" }, { onError: (e) => notifications.show({ color: "red", title: "Could not accept request", message: errorMessage(e) }) })}>Accept</Button>
<Button size="xs" color="red" variant="light" onClick={() => inbox.respond.mutate({ id: request.id, decision: "decline" }, { onError: (e) => notifications.show({ color: "red", title: "Could not decline request", message: errorMessage(e) }) })}>Decline</Button>
</Group>
</Group>
</Card>)}
</Stack>
</Container>;
}
30 changes: 30 additions & 0 deletions client/src/modules/leave/pages/TasksPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Alert, Button, Card, Container, Group, Stack, Text } 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 { useTasks } from "../api";

export default function TasksPage() {
const tasks = useTasks();
if (tasks.error) return <Container size="md"><ErrorState error={tasks.error} /></Container>;
return <Container size="md">
<PageHeader title="Leave tasks" subtitle="Review and route leave applications assigned to your role." />
<Stack>
{(tasks.data?.results ?? []).length === 0 && <Alert color="gray">There are no pending tasks assigned to you.</Alert>}
{(tasks.data?.results ?? []).map((task) => <Card withBorder key={task.id}>
<Group justify="space-between" align="flex-start"><div>
<Text fw={600}>Request #{task.request_id} · {task.category_code}</Text>
<Text size="sm" c="dimmed">{task.role.replaceAll("_", " ")} · step {task.sequence}</Text>
</div>
<Group>{task.role === "unit_head" && !["CL", "RH"].includes(task.category_code) ? <>
<Button size="xs" variant="light" onClick={() => tasks.decide.mutate({ id: task.id, decision: "recommended" }, { onError: (e) => notifications.show({ color: "red", title: "Could not record review", message: errorMessage(e) }) })}>Recommend</Button>
<Button size="xs" color="red" variant="light" onClick={() => tasks.decide.mutate({ id: task.id, decision: "not_recommended" }, { onError: (e) => notifications.show({ color: "red", title: "Could not record review", message: errorMessage(e) }) })}>Not recommended</Button>
</> : <>
<Button size="xs" color="green" onClick={() => tasks.decide.mutate({ id: task.id, decision: task.role === "establishment" ? "forwarded" : "approved" }, { onError: (e) => notifications.show({ color: "red", title: "Could not decide request", message: errorMessage(e) }) })}>{task.role === "establishment" ? "Forward" : "Approve"}</Button>
{task.role !== "establishment" && <Button size="xs" color="red" variant="light" onClick={() => tasks.decide.mutate({ id: task.id, decision: "rejected" }, { onError: (e) => notifications.show({ color: "red", title: "Could not decide request", message: errorMessage(e) }) })}>Reject</Button>}
</>}</Group></Group>
</Card>)}
</Stack>
</Container>;
}
15 changes: 15 additions & 0 deletions client/src/modules/leave/routes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { lazy } from "react";
import type { RouteObject } from "react-router-dom";

const MyLeavePage = lazy(() => import("./pages/MyLeavePage"));
const TasksPage = lazy(() => import("./pages/TasksPage"));
const SubstitutionsPage = lazy(() => import("./pages/SubstitutionsPage"));
const CalendarPage = lazy(() => import("./pages/CalendarPage"));

export const routes: RouteObject[] = [
{ index: true, element: <MyLeavePage /> },
{ path: "tasks", element: <TasksPage /> },
{ path: "substitutions", element: <SubstitutionsPage /> },
{ path: "balances", element: <MyLeavePage /> },
{ path: "calendar", element: <CalendarPage /> },
];
6 changes: 4 additions & 2 deletions config/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from modules.placement.schedule import (
TASK_ROUTES as PLACEMENT_ROUTES,
)
from modules.leave.schedule import BEAT_SCHEDULE as LEAVE_SCHEDULE
from modules.leave.schedule import TASK_ROUTES as LEAVE_ROUTES

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
app = Celery("fusion_integrated")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

# Each module owns its timers; this only merges them, so modules stay removable.
app.conf.beat_schedule = {**PLACEMENT_SCHEDULE}
app.conf.task_routes = {**PLACEMENT_ROUTES}
app.conf.beat_schedule = {**PLACEMENT_SCHEDULE, **LEAVE_SCHEDULE}
app.conf.task_routes = {**PLACEMENT_ROUTES, **LEAVE_ROUTES}
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def env_list(name, default=None):
# See docs/03-platform/module-authoring-guide.md to add one.
DOMAIN_MODULES = [
"modules.placement",
"modules.leave.apps.LeaveConfig",
]

INSTALLED_APPS = (DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
path("api/v1/", include("fusion_auth.urls")),
path("api/v1/directory/", include("modules.directory.api.urls")),
path("api/v1/placement/", include("modules.placement.api.urls")),
path("api/v1/leave/", include("modules.leave.api.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="docs"),
]
3 changes: 3 additions & 0 deletions docs/00-overview/current-state-assessment.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ WebOPAC with BeautifulSoup) · `iwdModuleV2` (22) · `estate_module` (5) · `sch
`research_procedures` (7) · `filetracking` (2) · `notifications_extension` (0) · `department` (3) ·
`counselling_cell` (9) · `recruitment` (24, near-empty views) · `feeds` (11) · `otheracademic` (7).

This describes the legacy estate. The new ELM module is a fresh design against the synchronized
ELM requirements bundle; it does not port the deprecated `leave` or `establishment` models.

**14 of these have no DRF `api/` layer at all** and are still pure Django templates:
`establishment`, `estate_module`, `finance_accounts`, `income_expenditure`, `hostel_management`,
`iwdModuleV2`, `library`, `office_module`, `otheracademic`, `recruitment`, `scholarships`,
Expand Down
Loading