From 96331b79559c61f81941cf6f8c06e89e639b6950 Mon Sep 17 00:00:00 2001 From: aryanc-04 Date: Mon, 12 Oct 2026 23:41:50 +0530 Subject: [PATCH] create elm_bundle assignment 4a --- app.js | 20 +++++ elm_bundle.md | 42 ++++++++++ elm_leave_balances.js | 45 ++++++++++ elm_leave_categories.js | 21 +++++ elm_leave_controller.js | 59 ++++++++++++++ elm_leave_detail.js | 56 +++++++++++++ elm_leave_form.js | 56 +++++++++++++ elm_leave_list.js | 71 ++++++++++++++++ elm_leave_requests.js | 53 ++++++++++++ elm_leave_routes.js | 59 ++++++++++++++ elm_leave_service.js | 177 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 659 insertions(+) create mode 100644 app.js create mode 100644 elm_bundle.md create mode 100644 elm_leave_balances.js create mode 100644 elm_leave_categories.js create mode 100644 elm_leave_controller.js create mode 100644 elm_leave_detail.js create mode 100644 elm_leave_form.js create mode 100644 elm_leave_list.js create mode 100644 elm_leave_requests.js create mode 100644 elm_leave_routes.js create mode 100644 elm_leave_service.js diff --git a/app.js b/app.js new file mode 100644 index 00000000..387a2f26 --- /dev/null +++ b/app.js @@ -0,0 +1,20 @@ +// path/to/app.js +import express from 'express'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import { sequelize } from './db'; +import elmLeaveRoutes from './modules/elm/routes/elm_leave_routes'; + +const app = express(); +app.use(bodyParser.json()); +app.use(cors()); + +sequelize.sync({ force: false }).then(() => { + console.log('Database synced'); +}); + +app.use('/api', elmLeaveRoutes); + +app.listen(3000, () => { + console.log('Server is running on port 3000'); +}); diff --git a/elm_bundle.md b/elm_bundle.md new file mode 100644 index 00000000..37307ebe --- /dev/null +++ b/elm_bundle.md @@ -0,0 +1,42 @@ +# Employee Leave Management (ELM) Integration Specification + +## 1. Core Domain & Data Models +* **Leave Categories & Entitlements:** + * **CL (Casual Leave):** 8 days/year, no carry-forward. Supports half-days (0.5 deduction). Continuous-day counting does *not* apply[cite: 3, 4]. + * **RH (Restricted Holiday):** 2 days/year, no carry-forward. Must match effective published RH calendar[cite: 3, 4]. + * **SCL (Special Casual Leave):** Up to 15 days/year, no carry-forward. Admissibility decided by authority[cite: 3, 4]. + * **EL (Earned Leave):** 30 days/year for non-faculty. Unused EL carries forward. Continuous-day counting applies (weekends/holidays inside interval count as leave)[cite: 3, 4]. + * **VL (Vacation Leave):** 60 days for faculty based on Academic Calendar. No carry-forward; unused VL converts to EL at year-end (2 VL : 1 EL)[cite: 3, 4]. + * **COL (Commuted Leave):** 20 days/year. Requires medical evidence; carries forward. Continuous-day counting applies[cite: 3, 4]. +* **Station Leave:** Captured within the same leave application if the employee leaves Jabalpur; not a separate process[cite: 3, 4]. +* **Roles:** Employee, Substitute (Responsibility Holder), Unit Head, Establishment (Deputy Registrar), Dean, Registrar, Director, Leave Administrator[cite: 3]. + +## 2. Core State Machine & Workflows +All leaves follow variations of these strict state transitions[cite: 1]: +1. **Draft / Not Submitted:** Initial state[cite: 1]. +2. **Awaiting Substitute Response:** If substitute is required, they must Accept to release the routing gate. If Declined, state moves to **Applicant Action Required** for replacement nomination[cite: 1, 4]. +3. **Awaiting Unit Head Review:** + * For CL/RH: Unit Head gives final `Approve` or `Reject`[cite: 1, 3]. + * For SCL/EL/COL/VL: Unit Head records `Recommended` or `Not Recommended` (which is not a rejection) and routes forward[cite: 1, 3]. +4. **Awaiting Establishment Routing:** Optional configured step for higher-sanction verification[cite: 1, 3]. +5. **Awaiting Final Sanction Decision:** Final authority (Dean/Registrar/Director) records `Approve` or `Reject`[cite: 1, 3]. +* **Director Exception:** If the Director is the applicant, they use a self-sanction path bypassing Unit Head[cite: 1, 4]. + +## 3. Lifecycle Actions & APIs (Use Cases) +* **Withdraw (EL-UC-007):** A pending request can be withdrawn immediately at any time *before* final approval. Terminates all routing[cite: 2, 4]. +* **Cancel (EL-UC-008):** Approved leave can only be cancelled *before* the start date. Routes through original hierarchy. If approved, balance is restored; original leave is NOT edited in place[cite: 2, 4]. +* **Extend (EL-UC-009):** Only for ongoing EL/COL/VL. Approvals post *incremental* additional periods without modifying the original record[cite: 2, 4]. +* **Resume (EL-UC-010/011):** For EL/COL/VL. Employee submits actual return date. If early/late, Sanctioning Authority reviews, system calculates actual period, adjusts balance, and syncs attendance[cite: 2, 4]. +* **Offline Leave (EL-UC-016):** Admins can retrospectively sync authorized offline/paper leave without triggering a new approval workflow[cite: 2, 4]. +* **Year-End Processing (EL-UC-020):** Event-triggered. Lapses CL/RH/SCL, converts VL to EL, carries forward EL/COL[cite: 2, 4]. + +## 4. Key Business Rules (Validation) +* **Overlap Guard (BR-EL-012):** New requests cannot overlap with active pending/approved leave. Exception: First-half and second-half CL on the same date are allowed[cite: 4]. +* **Substitute Guard (BR-EL-014):** Nominee must be an employee, cannot be the applicant, and cannot have overlapping pending/approved leave[cite: 4]. +* **Immutable Pending State (BR-EL-034):** Submitted requests cannot be modified in place. The user must withdraw and submit a new application[cite: 4]. +* **Role-Based Auth (BR-EL-036):** All APIs must strictly validate role authorizations[cite: 4]. + +## 5. Fusion ERP Integration Directives +* **Auth & Roles:** Use existing Fusion authentication and role-management decorators/middleware. +* **UI/UX:** Inherit common Fusion layouts, navigation, and reusable UI components. Do not build a standalone portal. +* **Database:** Extend the existing Fusion database schema. Create normalized tables for `elm_leave_requests`, `elm_leave_balances`, `elm_substitute_logs`, and `elm_state_transitions`.``` \ No newline at end of file diff --git a/elm_leave_balances.js b/elm_leave_balances.js new file mode 100644 index 00000000..d7c9aa68 --- /dev/null +++ b/elm_leave_balances.js @@ -0,0 +1,45 @@ +// path/to/modules/elm/models/elm_leave_balances.js +import { DataTypes, Model } from 'sequelize'; + +class ElmLeaveBalance extends Model {} + +ElmLeaveBalance.init({ + employeeId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + categoryId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'elm_leave_categories', + key: 'id', + }, + onDelete: 'CASCADE', + }, + balance: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + sequelize: null, // This will be set by the Sequelize instance + tableName: 'elm_leave_balances', +}); + +export default ElmLeaveBalance; diff --git a/elm_leave_categories.js b/elm_leave_categories.js new file mode 100644 index 00000000..f790a64b --- /dev/null +++ b/elm_leave_categories.js @@ -0,0 +1,21 @@ +// path/to/modules/elm/models/elm_leave_categories.js +import { DataTypes, Model } from 'sequelize'; + +class ElmLeaveCategory extends Model {} + +ElmLeaveCategory.init({ + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, +}, { + sequelize: null, // This will be set by the Sequelize instance + tableName: 'elm_leave_categories', +}); + +export default ElmLeaveCategory; diff --git a/elm_leave_controller.js b/elm_leave_controller.js new file mode 100644 index 00000000..521dbf56 --- /dev/null +++ b/elm_leave_controller.js @@ -0,0 +1,59 @@ +// path/to/modules/elm/controllers/elm_leave_controller.js +import express from 'express'; +import { Op } from 'sequelize'; +import { ElmLeaveService } from '../services'; + +const router = express.Router(); +const elmLeaveService = new ElmLeaveService(); + +router.get('/leave', async (req, res) => { + const { employeeId } = req.query; + try { + const leaveRequests = await elmLeaveService.getLeaveRequests(employeeId); + res.status(200).json(leaveRequests); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.get('/leave/:id', async (req, res) => { + const { id } = req.params; + try { + const leaveRequest = await elmLeaveService.getLeaveRequest(id); + res.status(200).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.post('/leave', async (req, res) => { + const { employeeId, categoryId, startDate, endDate } = req.body; + try { + const leaveRequest = await elmLeaveService.applyForLeave(employeeId, categoryId, startDate, endDate); + res.status(201).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.put('/leave/:id', async (req, res) => { + const { id, categoryId, startDate, endDate } = req.body; + try { + const leaveRequest = await elmLeaveService.updateLeaveRequest(id, categoryId, startDate, endDate); + res.status(200).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.delete('/leave/:id', async (req, res) => { + const { id } = req.params; + try { + await elmLeaveService.cancelLeave(id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +export default router; diff --git a/elm_leave_detail.js b/elm_leave_detail.js new file mode 100644 index 00000000..dde71ed6 --- /dev/null +++ b/elm_leave_detail.js @@ -0,0 +1,56 @@ +// path/to/modules/elm/components/elm_leave_detail.js +import React from 'react'; +import { Form, Input, DatePicker } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { useElmLeaveService } from '../services'; + +const { RangePicker } = DatePicker; + +const ElmLeaveDetail = ({ leaveRequest }) => { + const [startDate, setStartDate] = useState(leaveRequest.startDate); + const [endDate, setEndDate] = useState(leaveRequest.endDate); + const [categoryId, setCategoryId] = useState(leaveRequest.categoryId); + const [categoryIdOptions, setCategoryIdOptions] = useState([]); + const navigate = useNavigate(); + const elmLeaveService = useElmLeaveService(); + + const fetchLeaveCategories = async () => { + const categories = await elmLeaveService.getLeaveCategories(); + setCategoryIdOptions(categories.map(category => ({ label: category.name, value: category.id }))); + }; + + const handleSubmit = async () => { + try { + const updatedRequest = await elmLeaveService.updateLeaveRequest( + leaveRequest.id, + categoryId, + startDate, + endDate + ); + navigate(`/leave/${updatedRequest.id}`); + } catch (error) { + alert(error.message); + } + }; + + return ( +
+ + + + + setStartDate(dates)} /> + + + setEndDate(dates)} /> + + + + +
+ ); +}; + +export default ElmLeaveForm; diff --git a/elm_leave_list.js b/elm_leave_list.js new file mode 100644 index 00000000..1cb07405 --- /dev/null +++ b/elm_leave_list.js @@ -0,0 +1,71 @@ +// path/to/modules/elm/components/elm_leave_list.js +import React from 'react'; +import { Table, Button } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import { useElmLeaveService } from '../services'; + +const columns = [ + { + title: 'Employee', + dataIndex: 'employeeName', + key: 'employeeName', + }, + { + title: 'Category', + dataIndex: 'categoryName', + key: 'categoryName', + }, + { + title: 'Start Date', + dataIndex: 'startDate', + key: 'startDate', + }, + { + title: 'End Date', + dataIndex: 'endDate', + key: 'endDate', + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + }, + { + title: 'Action', + dataIndex: 'action', + key: 'action', + render: (text, record) => ( + + ), + }, +]; + +const ElmLeaveList = () => { + const [leaveRequests, setLeaveRequests] = useState([]); + const navigate = useNavigate(); + const elmLeaveService = useElmLeaveService(); + + const fetchLeaveRequests = async () => { + const requests = await elmLeaveService.getLeaveRequests(localStorage.getItem('userId')); + setLeaveRequests(requests.map(request => ({ + id: request.id, + employeeName: request.employee.name, + categoryName: request.category.name, + startDate: request.startDate.toISOString().split('T')[0], + endDate: request.endDate.toISOString().split('T')[0], + status: request.status, + }))); + }; + + React.useEffect(() => { + fetchLeaveRequests(); + }, []); + + return ( + + ); +}; + +export default ElmLeaveList; diff --git a/elm_leave_requests.js b/elm_leave_requests.js new file mode 100644 index 00000000..28323d9e --- /dev/null +++ b/elm_leave_requests.js @@ -0,0 +1,53 @@ +// path/to/modules/elm/models/elm_leave_requests.js +import { DataTypes, Model } from 'sequelize'; + +class ElmLeaveRequest extends Model {} + +ElmLeaveRequest.init({ + employeeId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + onDelete: 'CASCADE', + }, + categoryId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'elm_leave_categories', + key: 'id', + }, + onDelete: 'CASCADE', + }, + startDate: { + type: DataTypes.DATE, + allowNull: false, + }, + endDate: { + type: DataTypes.DATE, + allowNull: false, + }, + status: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'draft', + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, +}, { + sequelize: null, // This will be set by the Sequelize instance + tableName: 'elm_leave_requests', +}); + +export default ElmLeaveRequest; diff --git a/elm_leave_routes.js b/elm_leave_routes.js new file mode 100644 index 00000000..22a0f21e --- /dev/null +++ b/elm_leave_routes.js @@ -0,0 +1,59 @@ +// path/to/modules/elm/routes/elm_leave_routes.js +import express from 'express'; +import elmLeaveController from './elm_leave_controller'; +import { useElmLeaveService } from '../services'; + +const router = express.Router(); +const elmLeaveService = new ElmLeaveService(); + +router.get('/leave', async (req, res) => { + const { employeeId } = req.query; + try { + const leaveRequests = await elmLeaveService.getLeaveRequests(employeeId); + res.status(200).json(leaveRequests); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.get('/leave/:id', async (req, res) => { + const { id } = req.params; + try { + const leaveRequest = await elmLeaveService.getLeaveRequest(id); + res.status(200).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.post('/leave', async (req, res) => { + const { employeeId, categoryId, startDate, endDate } = req.body; + try { + const leaveRequest = await elmLeaveService.applyForLeave(employeeId, categoryId, startDate, endDate); + res.status(201).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.put('/leave/:id', async (req, res) => { + const { id, categoryId, startDate, endDate } = req.body; + try { + const leaveRequest = await elmLeaveService.updateLeaveRequest(id, categoryId, startDate, endDate); + res.status(200).json(leaveRequest); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +router.delete('/leave/:id', async (req, res) => { + const { id } = req.params; + try { + await elmLeaveService.cancelLeave(id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +export default router; diff --git a/elm_leave_service.js b/elm_leave_service.js new file mode 100644 index 00000000..8a38c35e --- /dev/null +++ b/elm_leave_service.js @@ -0,0 +1,177 @@ +// path/to/modules/elm/services/elm_leave_service.js +import { Op } from 'sequelize'; +import { ElmLeaveCategory, ElmLeaveBalance, ElmLeaveRequest } from '../models'; + +class ElmLeaveService { + async applyForLeave(employeeId, categoryId, startDate, endDate) { + const leaveCategory = await ElmLeaveCategory.findByPk(categoryId); + if (!leaveCategory) { + throw new Error('Invalid leave category'); + } + + const balance = await ElmLeaveBalance.findOne({ + where: { + employeeId, + categoryId, + }, + }); + + if (!balance) { + throw new Error('Insufficient leave balance'); + } + + if (balance.balance < 1) { + throw new Error('Insufficient leave balance'); + } + + const leaveRequest = await ElmLeaveRequest.create({ + employeeId, + categoryId, + startDate, + endDate, + status: 'draft', + }); + + balance.balance -= 1; + await balance.save(); + + return leaveRequest; + } + + async respondToSubstitute(substituteId, leaveRequestId) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'awaiting_substitute_response') { + throw new Error('Invalid leave request status'); + } + + const substitute = await User.findByPk(substituteId); + if (!substitute) { + throw new Error('Invalid substitute'); + } + + leaveRequest.substituteId = substituteId; + leaveRequest.status = 'awaiting_unit_head_review'; + await leaveRequest.save(); + + return leaveRequest; + } + + async approveLeave(leaveRequestId) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'awaiting_unit_head_review') { + throw new Error('Invalid leave request status'); + } + + leaveRequest.status = 'awaiting_establishment_routing'; + await leaveRequest.save(); + + return leaveRequest; + } + + async cancelLeave(leaveRequestId) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'submitted') { + throw new Error('Invalid leave request status'); + } + + leaveRequest.status = 'cancelled'; + await leaveRequest.save(); + + return leaveRequest; + } + + async extendLeave(leaveRequestId, additionalDays) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'ongoing') { + throw new Error('Invalid leave request status'); + } + + leaveRequest.endDate = leaveRequest.endDate.addDays(additionalDays); + await leaveRequest.save(); + + return leaveRequest; + } + + async resumeLeave(leaveRequestId, actualReturnDate) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'ongoing') { + throw new Error('Invalid leave request status'); + } + + leaveRequest.endDate = actualReturnDate; + await leaveRequest.save(); + + return leaveRequest; + } + + async withdrawLeave(leaveRequestId) { + const leaveRequest = await ElmLeaveRequest.findByPk(leaveRequestId); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + if (leaveRequest.status !== 'draft') { + throw new Error('Invalid leave request status'); + } + + leaveRequest.status = 'withdrawn'; + await leaveRequest.save(); + + return leaveRequest; + } + + async getLeaveRequests(employeeId) { + const leaveRequests = await ElmLeaveRequest.findAll({ + where: { + employeeId, + }, + }); + + return leaveRequests; + } + + async getLeaveRequest(id) { + const leaveRequest = await ElmLeaveRequest.findByPk(id); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + return leaveRequest; + } + + async updateLeaveRequest(id, categoryId, startDate, endDate) { + const leaveRequest = await ElmLeaveRequest.findByPk(id); + if (!leaveRequest) { + throw new Error('Invalid leave request'); + } + + leaveRequest.categoryId = categoryId; + leaveRequest.startDate = startDate; + leaveRequest.endDate = endDate; + await leaveRequest.save(); + + return leaveRequest; + } +} + +export default ElmLeaveService;