diff --git a/.importlinter b/.importlinter index d964dc3c..3a322757 100644 --- a/.importlinter +++ b/.importlinter @@ -12,10 +12,16 @@ include_external_packages = True name = Modules may reach each other ONLY through contracts.py type = forbidden source_modules = + modules.leave_elm modules.placement modules.directory modules.accesscontrol forbidden_modules = + modules.leave_elm.models + modules.leave_elm.services + modules.leave_elm.selectors + modules.leave_elm.api + modules.leave_elm.domain modules.placement.models modules.placement.services modules.placement.selectors @@ -38,6 +44,7 @@ type = forbidden source_modules = # Every module's domain, not just the first one written. modules.placement.domain + modules.leave_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.leave_elm.api + modules.leave_elm.services + modules.leave_elm.selectors + modules.leave_elm.models + modules.leave_elm.domain diff --git a/client/package-lock.json b/client/package-lock.json index 04403fdb..afa4ac59 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@mantine/core": "7.13.4", "@mantine/dates": "7.13.4", + "@mantine/form": "7.13.4", "@mantine/hooks": "7.13.4", "@mantine/notifications": "7.13.4", "@tanstack/react-query": "^5.59.0", @@ -997,6 +998,19 @@ "react-dom": "^18.2.0" } }, + "node_modules/@mantine/form": { + "version": "7.13.4", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-7.13.4.tgz", + "integrity": "sha512-JzgUzzCpTmXeRxYs6iXM8fq7vOESrPC0Jk9nEgxu/5DO84Ah7ExthinTt4QiXityznXOOLl5U0BgG0j3aXEuew==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/@mantine/hooks": { "version": "7.13.4", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.13.4.tgz", @@ -2324,6 +2338,12 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2686,6 +2706,15 @@ "node": ">=6" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", diff --git a/client/package.json b/client/package.json index 5d4d3a9a..15a635b7 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,7 @@ "dependencies": { "@mantine/core": "7.13.4", "@mantine/dates": "7.13.4", + "@mantine/form": "7.13.4", "@mantine/hooks": "7.13.4", "@mantine/notifications": "7.13.4", "@tanstack/react-query": "^5.59.0", diff --git a/client/src/app/registry.ts b/client/src/app/registry.ts index e76d32bf..7a4e519f 100644 --- a/client/src/app/registry.ts +++ b/client/src/app/registry.ts @@ -12,6 +12,11 @@ export interface ModuleManifest { } export const MODULE_REGISTRY: Record = { + leave_elm: { + code: "leave_elm", + basePath: "/leave", + load: () => import("../modules/leave_elm/routes"), + }, placement_cell: { code: "placement_cell", basePath: "/placement", diff --git a/client/src/modules/leave_elm/api/hooks.ts b/client/src/modules/leave_elm/api/hooks.ts new file mode 100644 index 00000000..ed346687 --- /dev/null +++ b/client/src/modules/leave_elm/api/hooks.ts @@ -0,0 +1,121 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { http } from '../../../lib/http'; +import type { LeaveRequest } from './types'; + +const BASE = '/leave'; + +export function useMyLeave() { + return useQuery({ + queryKey: ['leave', 'mine'], + queryFn: () => http.get(`${BASE}/mine`), + }); +} + +export function useApplyLeave(_data: Partial) { + return useMutation({ + mutationFn: (d: LeaveRequest) => http.post(`${BASE}/apply`, d), + }); +} + +export function useLeaveDetails(id: number) { + return useQuery({ + queryKey: ['leave', 'detail', id], + queryFn: () => http.get(`${BASE}/${id}`), + enabled: !!id, + }); +} + +export function useWithdrawLeave(id: number) { + return useMutation({ + mutationFn: (d: LeaveRequest) => http.post(`${BASE}/${id}/withdraw`, d), + }); +} + +export function useCancelLeave(id: number) { + return useMutation({ + mutationFn: () => http.post(`${BASE}/${id}/cancel`), + }); +} + +export function useExtendLeave(id: number) { + return useMutation({ + mutationFn: (d: { additional_days: number }) => + http.post(`${BASE}/${id}/extend`, d), + }); +} + +export function useResumption(id: number) { + return useMutation({ + mutationFn: (d: { actual_date: string }) => + http.post(`${BASE}/${id}/resumption`, d), + }); +} + +export function useLeaveBalances() { + return useQuery({ + queryKey: ['leave', 'balances'], + queryFn: () => http.get(`${BASE}/balances`), + }); +} + +export function useLeaveHistory() { + return useQuery({ + queryKey: ['leave', 'history'], + queryFn: () => http.get(`${BASE}/history`), + }); +} + +export function useLeaveQueue(userId: number) { + return useQuery({ + queryKey: ['leave', 'queue', userId], + queryFn: () => http.get(`${BASE}/queue`), + }); +} + +export function useSubstituteAccept(id: number) { + return useMutation({ + mutationFn: () => http.post(`${BASE}/${id}/substitute/accept`), + }); +} + +export function useSubstituteDecline(id: number) { + return useMutation({ + mutationFn: (d: { new_substitute_user_id?: number }) => + http.post(`${BASE}/${id}/substitute/decline`, d), + }); +} + +export function useReviewLeave(id: number) { + return useMutation({ + mutationFn: (d: { decision: string }) => + http.post(`${BASE}/${id}/review`, d), + }); +} + +export function usePolicy() { + return useQuery({ + queryKey: ['leave', 'policy'], + queryFn: () => http.get(`${BASE}/policy`), + }); +} + +export function useCalendar() { + return useQuery({ + queryKey: ['leave', 'calendar'], + queryFn: () => http.get(`${BASE}/calendar`), + }); +} + +export function useNotifications(userId: number) { + return useQuery({ + queryKey: ['leave', 'notifications', userId], + queryFn: () => http.get(`${BASE}/notifications`), + }); +} + +export function useOfflineRecord() { + return useMutation({ + mutationFn: (d: { category: string; start_date: string; end_date: string; reference_number?: string; evidence?: string }) => + http.post(`${BASE}/offline-record`, d), + }); +} \ No newline at end of file diff --git a/client/src/modules/leave_elm/api/types.ts b/client/src/modules/leave_elm/api/types.ts new file mode 100644 index 00000000..fa7d9516 --- /dev/null +++ b/client/src/modules/leave_elm/api/types.ts @@ -0,0 +1,41 @@ +import type { LeaveApplication } from '../models'; + +export interface LeaveRequest { + category: string; + start_date: string; + end_date: string; + purpose?: string; + substitute_user_id?: number; +} + +export interface LeaveResponse extends LeaveApplication {} + +export type LeaveCategory = 'CL' | 'RH' | 'SCL' | 'EL' | 'COL' | 'VL'; + +export interface Policy { + CL_DAYS: number; + RH_DAYS: number; + SCL_MAX_DAYS: number; + EL_DAYS: number; + COL_DAYS: number; + VL_DAYS: number; + VL_TO_EL_RATIO: number; +} + +export interface Calendar { + year: number; + holidays: Date[]; + restricted_holidays: Date[]; + is_effective: boolean; +} + +export interface Notification { + id: number; + task_type: string; + assigned_to_user_id: number; + status: string; + reminder_threshold_seconds: number; + escalation_threshold_seconds: number; + last_reminder_sent_at?: string; + last_escalation_sent_at?: string; +} \ No newline at end of file diff --git a/client/src/modules/leave_elm/json-editor.tsx b/client/src/modules/leave_elm/json-editor.tsx new file mode 100644 index 00000000..1b1685ae --- /dev/null +++ b/client/src/modules/leave_elm/json-editor.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { Group, Stack, Text, Button, ActionIcon, SimpleGrid, TextInput } from '@mantine/core'; +import { RxTrash as Trash, RxPencil2 as Edit } from 'react-icons/rx'; + +export function useJsonEditor(value: any, onChange: (value: any) => void) { + const [editMode, setEditMode] = useState(false); + const [items, setItems] = useState(() => { + if (!value || typeof value !== 'object') return []; + return Object.entries(value).map(([k, v]) => ({ key: k, value: v })); + }); + + const addField = () => { + setItems([...items, { key: '', value: '' }]); + }; + + void addField; + + const updateField = (index: number, field: 'key' | 'value', val: string) => { + const newItems = [...items]; + if (field === 'key') newItems[index]!.key = val; + else newItems[index]!.value = val; + setItems(newItems); + }; + + const removeField = (index: number) => { + setItems(items.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + const newData: any = {}; + items.forEach(({ key, value }) => { + if (key) newData[key] = value; + }); + onChange(newData); + setEditMode(false); + }; + + return ( + + {!editMode ? ( + + Policy Data + setEditMode(true)}> + + + + ) : ( + <> + + {items.map((item, idx) => ( + + updateField(idx, 'key', e.target.value)} + placeholder="Key" + /> + updateField(idx, 'value', e.target.value)} + placeholder="Value" + /> + {items.length > 2 && ( + removeField(idx)}> + + + )} + + ))} + + + + + + + )} + + ); +} diff --git a/client/src/modules/leave_elm/models.ts b/client/src/modules/leave_elm/models.ts new file mode 100644 index 00000000..787d79d8 --- /dev/null +++ b/client/src/modules/leave_elm/models.ts @@ -0,0 +1,34 @@ +export interface LeaveApplication { + id?: number; + category: string; + start_date: string; + end_date: string; + purpose?: string; + station_leave_required?: boolean; + substitute_user_id?: number; + status?: string; + final_decision_type?: string; + final_decision_date?: string | null; + created_at?: string; +} + +export interface LeaveBalance { + effective_date: string; + cl_balance: number; + rh_balance: number; + scl_balance: number; + el_balance: number; + col_balance: number; + vl_balance: number; +} + +export interface WorkflowTask { + id?: number; + task_type: string; + assigned_to_user_id?: number; + status?: string; + reminder_threshold_seconds?: number; + escalation_threshold_seconds?: number; + last_reminder_sent_at?: string | null; + last_escalation_sent_at?: string | null; +} diff --git a/client/src/modules/leave_elm/pages/LeaveAdminPage.tsx b/client/src/modules/leave_elm/pages/LeaveAdminPage.tsx new file mode 100644 index 00000000..10eeeaa0 --- /dev/null +++ b/client/src/modules/leave_elm/pages/LeaveAdminPage.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { Group, Stack, Text, Title, Paper, Button, TextInput, Divider, SimpleGrid } from '@mantine/core'; +import { useJsonEditor } from '../json-editor'; +import { PageHeader } from '../../../ui/components/PageHeader'; +import { usePolicy, useCalendar, useLeaveBalances, useNotifications, useOfflineRecord } from '../api/hooks'; + +export default function LeaveAdminPage() { + const [activeTab, setActiveTab] = useState<'policy' | 'calendar' | 'records'>('policy'); + + const policyQuery = usePolicy(); + const calendarQuery = useCalendar(); + useLeaveBalances(); + const notificationsQuery = useNotifications(1); // Admin user ID placeholder + const offlineMutation = useOfflineRecord(); + + if (policyQuery.isLoading || calendarQuery.isLoading) return Loading...; + if (!policyQuery.data || !calendarQuery.data) return Error loading data; + + const policy: any = policyQuery.data; + const calendar: any = calendarQuery.data; + + const handlePolicyUpdate = (newPolicy: any) => { + // In a real scenario, this would be an API call. Here we just log or show feedback. + console.log('Updating policy:', newPolicy); + offlineMutation.mutate({ category: 'CL', start_date: new Date().toISOString(), end_date: new Date().toISOString(), reference_number: 'POLICY_UPDATE' }); + }; + + const handleCalendarUpdate = (newCalendar: any) => { + console.log('Updating calendar:', newCalendar); + offlineMutation.mutate({ category: 'CL', start_date: new Date().toISOString(), end_date: new Date().toISOString(), reference_number: 'CALENDAR_UPDATE' }); + }; + + return ( + <> + + + + + + } /> + + {activeTab === 'policy' && ( + + Leave Policy Configuration + + Current Policy Data: + + {Object.entries(policy).map(([key, value]) => ( + + {key} + {typeof value === 'number' ? value : JSON.stringify(value)} + + ))} + + + + + + Edit Policy (Simulation): + + {useJsonEditor(policy, handlePolicyUpdate)} + + + + + )} + + {activeTab === 'calendar' && ( + + Calendar Configuration + + Current Calendar Data: + + + Year + {calendar.year} + + + Effective + {calendar.is_effective ? 'Yes' : 'No'} + + + + + + + Edit Calendar (Simulation): + + {useJsonEditor(calendar, handleCalendarUpdate)} + + + + + )} + + {activeTab === 'records' && ( + + Offline Records & Notifications + + + + Create Offline Record: + + + + + + + + + + + Notifications: + {notificationsQuery.data ? ( + notificationsQuery.data.data.map((notif: any) => ( + + + + {notif.task_type} + User: {notif.assigned_to_user_id} + + {new Date(notif.last_reminder_sent_at).toLocaleString()} + + + )) + ) : ( + No notifications available. + )} + + {offlineMutation.isPending && ( + Submitting offline record... + )} + + {offlineMutation.error && ( + Error: {offlineMutation.error.message} + )} + + + )} + + ); +} diff --git a/client/src/modules/leave_elm/pages/LeaveTasksPage.tsx b/client/src/modules/leave_elm/pages/LeaveTasksPage.tsx new file mode 100644 index 00000000..79b62d4b --- /dev/null +++ b/client/src/modules/leave_elm/pages/LeaveTasksPage.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { http } from '../../../lib/http'; +import type { Notification } from '../api/types'; +import { PageHeader } from '../../../ui/components/PageHeader'; + +const BASE = '/leave'; + +export default function LeaveTasksPage() { + const queryClient = useQueryClient(); + const [selectedTask, setSelectedTask] = useState(null); + const [decision, setDecision] = useState(''); + const [substituteId, setSubstituteId] = useState(); + + const { data: tasks } = useQuery({ + queryKey: ['leave', 'queue'], + queryFn: () => http.get(`${BASE}/queue`), + }); + + const approveMutation = useMutation({ + mutationFn: (id: number) => http.post(`${BASE}/${id}/review`, { decision: 'APPROVED' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['leave', 'queue'] }); + setDecision(''); + setSelectedTask(null); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: (id: number) => http.post(`${BASE}/${id}/review`, { decision: 'REJECTED' }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['leave', 'queue'] }); + setDecision(''); + setSelectedTask(null); + }, + }); + + const acceptSubstituteMutation = useMutation({ + mutationFn: (id: number) => http.post(`${BASE}/${id}/substitute/accept`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['leave', 'queue'] }); + setDecision(''); + setSubstituteId(undefined); + setSelectedTask(null); + }, + }); + + const declineSubstituteMutation = useMutation({ + mutationFn: ({ id, newSubId }: { id: number; newSubId?: number }) => + http.post(`${BASE}/${id}/substitute/decline`, { new_substitute_user_id: newSubId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['leave', 'queue'] }); + setDecision(''); + setSubstituteId(undefined); + setSelectedTask(null); + }, + }); + + const handleReview = (id: number) => { + if (!decision) return; + if (decision === 'APPROVED') approveMutation.mutate(id); + else rejectMutation.mutate(id); + }; + + const handleSubstituteAction = () => { + if (!selectedTask) return; + if (substituteId !== undefined) acceptSubstituteMutation.mutate(selectedTask.id); + else declineSubstituteMutation.mutate({ id: selectedTask.id, newSubId: substituteId }); + }; + + if (!tasks?.data) return
Loading...
; + + return ( + <> + +
+ {tasks.data.length === 0 ? ( +

No pending tasks.

+ ) : ( +
+ {tasks.data.map((task: Notification & { substitute_user_id?: number }) => ( +
+

Task ID: {task.id}

+

Type: {task.task_type}

+

Status: {task.status}

+ + {task.task_type === 'REVIEW' ? ( + <> +

Decision:

+
+ + +
+ {decision && ( + + )} + + ) : task.task_type === 'SUBSTITUTE' ? ( + <> +

Current Substitute: {task.substitute_user_id || 'None'}

+
+ + +
+ {substituteId !== undefined && ( + <> +

New Substitute ID: {substituteId}

+
+ + +
+ + )} + + ) : ( +

Action required for this task type.

+ )} +
+ ))} +
+ )} +
+ + ); +} diff --git a/client/src/modules/leave_elm/pages/MyLeavePage.tsx b/client/src/modules/leave_elm/pages/MyLeavePage.tsx new file mode 100644 index 00000000..85f02fa8 --- /dev/null +++ b/client/src/modules/leave_elm/pages/MyLeavePage.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Group, Stack, Paper, Button, Text, Alert, Loader, Box, Textarea } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { DateInput } from '@mantine/dates'; +import { useMyLeave, useApplyLeave, useLeaveBalances } from '../api/hooks'; +import { errorMessage } from '../../../lib/http'; +import type { LeaveRequest } from '../api/types'; + +export default function MyLeavePage() { + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'applications' | 'balances'>('applications'); + + const { data: applications, isLoading: loadingApps, error: appsError } = useMyLeave(); + const { data: balances, isLoading: loadingBalances, error: balancesError } = useLeaveBalances(); + const applyMutation = useApplyLeave({}); + + const form = useForm({ + ...{ schema: { + required: ['category', 'start_date', 'end_date'], + } }, + initialValues: { + category: '', + start_date: new Date().toISOString().split('T')[0]!, + end_date: new Date(Date.now() + 86400000).toISOString().split('T')[0]!, + }, + }); + + const handleSubmit = (values: LeaveRequest) => { + applyMutation.mutate(values); + }; + + if (loadingApps || loadingBalances) return ; + + return ( + <> + + + My Leave + Manage your leave applications and balances + + + + + + + + {appsError && ( + {errorMessage(appsError)} + )} + {balancesError && ( + {errorMessage(balancesError)} + )} + + + + + + + + {activeTab === 'applications' && ( + + {applications?.data?.map((app: { id: number | string; category: string; start_date: string; end_date: string }) => ( + + + + {app.category} + {app.start_date} to {app.end_date} + + + + + + + + + + ))} + + )} + + {activeTab === 'balances' && ( + + {balances?.data?.map((bal: { category: string; remaining_days: number }) => ( + + + {bal.category} + {bal.remaining_days} days + + + ))} + + )} + + + +
+ + New Application + + + + + +