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
17 changes: 17 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ source_modules =
modules.placement
modules.directory
modules.accesscontrol
modules.elm
forbidden_modules =
modules.elm.models
modules.elm.services
modules.elm.selectors
modules.elm.api
modules.elm.domain
modules.placement.models
modules.placement.services
modules.placement.selectors
Expand All @@ -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
Expand Down Expand Up @@ -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
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: {
code: "leave",
basePath: "/leave",
load: () => import("../modules/elm/routes"),
},
};
112 changes: 112 additions & 0 deletions client/src/modules/elm/LeaveApplicationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { useState } from "react";
import {
Alert, Button, Checkbox, Group, SegmentedControl, Select, SimpleGrid, Stack,
Textarea, TextInput,
} from "@mantine/core";

import { errorMessage } from "../../lib/http";
import type { HalfDay, LeaveApplication, LeaveCategory } from "./api/types";
import { CATEGORY_LABELS } from "./api/types";

const EMPTY: LeaveApplication = {
category: "CL", start_date: "", end_date: "", half: "", reason: "",
is_station_leave: false, station_address: "", station_contact: "",
};

/** The one leave form. Station Leave is a section of it, not a separate
* request (BR-EL-013). The server re-checks every rule; the client only
* stops obviously incomplete submissions. */
export function LeaveApplicationForm({ onSubmit, submitting, error }: {
onSubmit: (body: LeaveApplication, reset: () => void) => void;
submitting?: boolean;
error?: unknown;
}) {
const [form, setForm] = useState<LeaveApplication>(EMPTY);
const set = <K extends keyof LeaveApplication>(k: K, v: LeaveApplication[K]) =>
setForm((f) => ({ ...f, [k]: v }));

const halfDay = form.category === "CL" && form.half !== "";
const incomplete = !form.start_date || !(halfDay || form.end_date) ||
!form.reason.trim() ||
(form.is_station_leave &&
!(form.station_address.trim() && form.station_contact.trim()));

function submit(e: React.FormEvent) {
e.preventDefault();
const body = halfDay ? { ...form, end_date: form.start_date } : form;
onSubmit(body, () => setForm(EMPTY));
}

return (
<form onSubmit={submit}>
<Stack>
{error ? <Alert color="red">{errorMessage(error)}</Alert> : null}
<Select
label="Leave category" required allowDeselect={false}
data={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
value, label: `${label} (${value})`,
}))}
value={form.category}
onChange={(v) => setForm((f) => ({
...f, category: (v ?? "CL") as LeaveCategory,
half: v === "CL" ? f.half : "",
}))}
/>
{form.category === "CL" && (
<SegmentedControl
value={form.half}
onChange={(v) => set("half", v as HalfDay)}
data={[
{ value: "", label: "Full day(s)" },
{ value: "first", label: "First half" },
{ value: "second", label: "Second half" },
]}
/>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
type="date" label={halfDay ? "Date" : "From"} required
value={form.start_date}
onChange={(e) => set("start_date", e.currentTarget.value)}
/>
{!halfDay && (
<TextInput
type="date" label="To" required min={form.start_date || undefined}
value={form.end_date}
onChange={(e) => set("end_date", e.currentTarget.value)}
/>
)}
</SimpleGrid>
<Textarea
label="Reason" required autosize minRows={2} maxLength={2000}
value={form.reason}
onChange={(e) => set("reason", e.currentTarget.value)}
/>
<Checkbox
label="I will be away from the station (Station Leave)"
checked={form.is_station_leave}
onChange={(e) => set("is_station_leave", e.currentTarget.checked)}
/>
{form.is_station_leave && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label="Address while away" required maxLength={300}
value={form.station_address}
onChange={(e) => set("station_address", e.currentTarget.value)}
/>
<TextInput
label="Contact number while away" required maxLength={30}
value={form.station_contact}
onChange={(e) => set("station_contact", e.currentTarget.value)}
/>
</SimpleGrid>
)}
<Group justify="flex-end">
<Button type="submit" loading={submitting} disabled={incomplete}>
Submit application
</Button>
</Group>
</Stack>
</form>
);
}
58 changes: 58 additions & 0 deletions client/src/modules/elm/api/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Data access for ELM. Every mutation invalidates the whole module's cache:
* an approval changes both the queue and the applicant's balance. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

import { http } from "../../../lib/http";
import type {
LeaveApplication, LeaveBalance, LeaveRequest, LeaveStatus, Page,
} from "./types";

const KEY = "elm";

export function useMyLeave() {
return useQuery({
queryKey: [KEY, "mine"],
queryFn: async () =>
(await http.get<Page<LeaveRequest>>("/elm/requests", { params: { mine: 1 } })).data,
});
}

/** Requests still waiting on someone other than the applicant. */
export function useApprovalQueue() {
return useQuery({
queryKey: [KEY, "queue"],
queryFn: async () =>
(await http.get<Page<LeaveRequest>>("/elm/requests", {
params: { status: "pending,recommended,not_recommended" },
})).data,
});
}

export function useBalances(year?: number) {
return useQuery({
queryKey: [KEY, "balances", year],
queryFn: async () =>
(await http.get<LeaveBalance[]>("/elm/balances", { params: { year } })).data,
});
}

export function useApply() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: LeaveApplication) =>
(await http.post<LeaveRequest>("/elm/requests/apply", body)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [KEY] }),
});
}

export function useLeaveTransition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, to_status, remarks }: {
id: number; to_status: LeaveStatus; remarks?: string;
}) =>
(await http.post<LeaveRequest>(`/elm/requests/${id}/transition`,
{ to_status, remarks: remarks ?? "" })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [KEY] }),
});
}
65 changes: 65 additions & 0 deletions client/src/modules/elm/api/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type LeaveCategory = "CL" | "RH" | "SCL" | "EL" | "COL" | "VL";
export type HalfDay = "" | "first" | "second";
export type LeaveStatus =
| "pending" | "recommended" | "not_recommended"
| "approved" | "rejected" | "withdrawn";

export const CATEGORY_LABELS: Record<LeaveCategory, string> = {
CL: "Casual Leave",
RH: "Restricted Holiday",
SCL: "Special Casual Leave",
EL: "Earned Leave",
COL: "Commuted Leave",
VL: "Vacation Leave",
};

export interface LeaveHistory {
from_status: string;
to_status: string;
actor_user_id: number;
remarks: string;
at: string;
}

export interface LeaveRequest {
id: number;
applicant_user_id: number;
category: LeaveCategory;
start_date: string;
end_date: string;
half: HalfDay;
days: string;
reason: string;
status: LeaveStatus;
is_station_leave: boolean;
station_address: string;
station_contact: string;
created_at: string;
allowed_transitions: LeaveStatus[];
history: LeaveHistory[];
}

export interface LeaveApplication {
category: LeaveCategory;
start_date: string;
end_date: string;
half: HalfDay;
reason: string;
is_station_leave: boolean;
station_address: string;
station_contact: string;
}

export interface LeaveBalance {
category: LeaveCategory;
credited: string;
availed: string;
reserved: string;
available: string;
}

export interface Page<T> {
results: T[];
next: string | null;
previous: string | null;
}
68 changes: 68 additions & 0 deletions client/src/modules/elm/pages/ApplyLeavePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Card, Container, Grid, Stack, Table, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";

import { ErrorState } from "../../../ui/components/ErrorState";
import { PageHeader } from "../../../ui/components/PageHeader";
import { LeaveApplicationForm } from "../LeaveApplicationForm";
import { useApply, useBalances } from "../api/hooks";
import { CATEGORY_LABELS } from "../api/types";

export default function ApplyLeavePage() {
const apply = useApply();
const balances = useBalances();

return (
<Container size="xl">
<PageHeader
title="Apply for Leave"
subtitle="Your request goes to your Unit Head first"
/>
<Grid>
<Grid.Col span={{ base: 12, md: 8 }}>
<Card padding="lg">
<LeaveApplicationForm
submitting={apply.isPending}
error={apply.error}
onSubmit={(body, reset) => apply.mutate(body, {
onSuccess: (r) => {
reset();
notifications.show({
color: "green", title: "Submitted",
message: `${CATEGORY_LABELS[r.category]} for ${r.days} day(s) is pending.`,
});
},
})}
/>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Card padding="lg">
<Stack gap="xs">
<Text fw={600}>Balance this year</Text>
{balances.error ? <ErrorState error={balances.error} /> : (
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Category</Table.Th>
<Table.Th ta="right">Available</Table.Th>
<Table.Th ta="right">Pending</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(balances.data ?? []).map((b) => (
<Table.Tr key={b.category}>
<Table.Td>{b.category}</Table.Td>
<Table.Td ta="right">{b.available}</Table.Td>
<Table.Td ta="right">{b.reserved}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Card>
</Grid.Col>
</Grid>
</Container>
);
}
Loading