diff --git a/backend-deno/routes/activities.ts b/backend-deno/routes/activities.ts index 05bf9a1..c9b6889 100644 --- a/backend-deno/routes/activities.ts +++ b/backend-deno/routes/activities.ts @@ -1,6 +1,6 @@ import { Router } from "@oak/oak"; import { db } from "../config/database.ts"; -import { authenticateToken } from "../middleware/auth.ts"; +import { authenticateToken, getCurrentUser } from "../middleware/auth.ts"; const router = new Router(); @@ -86,9 +86,10 @@ router.get("/:id", authenticateToken, async (ctx) => { } }); -// Create activity (SuperAdmin only) +// Create activity (SuperAdmin or Supervisor for their own department) router.post("/", authenticateToken, async (ctx) => { try { + const user = getCurrentUser(ctx); const body = await ctx.request.body.json(); const { sub_department_id, name, unit_of_measurement } = body; @@ -98,6 +99,33 @@ router.post("/", authenticateToken, async (ctx) => { return; } + // Get the sub-department to check department ownership + const subDepts = await db.query<{ department_id: number }[]>( + "SELECT department_id FROM sub_departments WHERE id = ?", + [sub_department_id] + ); + + if (subDepts.length === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Sub-department not found" }; + return; + } + + const subDeptDepartmentId = subDepts[0].department_id; + + // Check authorization + if (user.role === 'Supervisor' && user.departmentId !== subDeptDepartmentId) { + ctx.response.status = 403; + ctx.response.body = { error: "You can only create activities for your own department" }; + return; + } + + if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') { + ctx.response.status = 403; + ctx.response.body = { error: "Unauthorized" }; + return; + } + const result = await db.execute( "INSERT INTO activities (sub_department_id, name, unit_of_measurement) VALUES (?, ?, ?)", [sub_department_id, name, unit_of_measurement || "Per Bag"] @@ -109,6 +137,12 @@ router.post("/", authenticateToken, async (ctx) => { message: "Activity created successfully" }; } catch (error) { + const err = error as { code?: string }; + if (err.code === "ER_DUP_ENTRY") { + ctx.response.status = 400; + ctx.response.body = { error: "Activity already exists in this sub-department" }; + return; + } console.error("Create activity error:", error); ctx.response.status = 500; ctx.response.body = { error: "Internal server error" }; @@ -135,11 +169,42 @@ router.put("/:id", authenticateToken, async (ctx) => { } }); -// Delete activity +// Delete activity (SuperAdmin or Supervisor for their own department) router.delete("/:id", authenticateToken, async (ctx) => { try { + const user = getCurrentUser(ctx); const activityId = ctx.params.id; + // Get the activity and its sub-department to check department ownership + const activities = await db.query( + `SELECT a.*, sd.department_id + FROM activities a + JOIN sub_departments sd ON a.sub_department_id = sd.id + WHERE a.id = ?`, + [activityId] + ); + + if (activities.length === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Activity not found" }; + return; + } + + const activity = activities[0] as Activity & { department_id: number }; + + // Check authorization + if (user.role === 'Supervisor' && user.departmentId !== activity.department_id) { + ctx.response.status = 403; + ctx.response.body = { error: "You can only delete activities from your own department" }; + return; + } + + if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') { + ctx.response.status = 403; + ctx.response.body = { error: "Unauthorized" }; + return; + } + await db.execute("DELETE FROM activities WHERE id = ?", [activityId]); ctx.response.body = { message: "Activity deleted successfully" }; diff --git a/backend-deno/routes/departments.ts b/backend-deno/routes/departments.ts index ac20dce..b1d7245 100644 --- a/backend-deno/routes/departments.ts +++ b/backend-deno/routes/departments.ts @@ -101,35 +101,140 @@ router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => { } }); -// Create sub-department (SuperAdmin only) -router.post("/:id/sub-departments", authenticateToken, authorize("SuperAdmin"), async (ctx) => { +// Create sub-department (SuperAdmin or Supervisor for their own department) +router.post("/sub-departments", authenticateToken, async (ctx) => { try { - const deptId = ctx.params.id; - const body = await ctx.request.body.json() as { name: string; primaryActivity: string }; - const { name, primaryActivity } = body; + const user = getCurrentUser(ctx); + const body = await ctx.request.body.json() as { department_id: number; name: string }; + const { department_id, name } = body; - if (!name || !primaryActivity) { + if (!name || !department_id) { ctx.response.status = 400; - ctx.response.body = { error: "Name and primary activity required" }; + ctx.response.body = { error: "Department ID and name are required" }; + return; + } + + // Check authorization + if (user.role === 'Supervisor' && user.departmentId !== department_id) { + ctx.response.status = 403; + ctx.response.body = { error: "You can only create sub-departments for your own department" }; + return; + } + + if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') { + ctx.response.status = 403; + ctx.response.body = { error: "Unauthorized" }; return; } const sanitizedName = sanitizeInput(name); - const sanitizedActivity = sanitizeInput(primaryActivity); const result = await db.execute( - "INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)", - [deptId, sanitizedName, sanitizedActivity] + "INSERT INTO sub_departments (department_id, name) VALUES (?, ?)", + [department_id, sanitizedName] ); const newSubDepartment = await db.query( "SELECT * FROM sub_departments WHERE id = ?", - [result.insertId] + [result.lastInsertId] ); ctx.response.status = 201; ctx.response.body = newSubDepartment[0]; } catch (error) { + const err = error as { code?: string }; + if (err.code === "ER_DUP_ENTRY") { + ctx.response.status = 400; + ctx.response.body = { error: "Sub-department already exists in this department" }; + return; + } + console.error("Create sub-department error:", error); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error" }; + } +}); + +// Delete sub-department (SuperAdmin or Supervisor for their own department) +router.delete("/sub-departments/:id", authenticateToken, async (ctx) => { + try { + const user = getCurrentUser(ctx); + const subDeptId = ctx.params.id; + + // Get the sub-department to check department ownership + const subDepts = await db.query( + "SELECT * FROM sub_departments WHERE id = ?", + [subDeptId] + ); + + if (subDepts.length === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Sub-department not found" }; + return; + } + + const subDept = subDepts[0]; + + // Check authorization + if (user.role === 'Supervisor' && user.departmentId !== subDept.department_id) { + ctx.response.status = 403; + ctx.response.body = { error: "You can only delete sub-departments from your own department" }; + return; + } + + if (user.role !== 'SuperAdmin' && user.role !== 'Supervisor') { + ctx.response.status = 403; + ctx.response.body = { error: "Unauthorized" }; + return; + } + + // Delete associated activities first (cascade should handle this, but being explicit) + await db.execute("DELETE FROM activities WHERE sub_department_id = ?", [subDeptId]); + + // Delete the sub-department + await db.execute("DELETE FROM sub_departments WHERE id = ?", [subDeptId]); + + ctx.response.body = { message: "Sub-department deleted successfully" }; + } catch (error) { + console.error("Delete sub-department error:", error); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error" }; + } +}); + +// Legacy route for creating sub-department under specific department (SuperAdmin only) +router.post("/:id/sub-departments", authenticateToken, authorize("SuperAdmin"), async (ctx) => { + try { + const deptId = ctx.params.id; + const body = await ctx.request.body.json() as { name: string }; + const { name } = body; + + if (!name) { + ctx.response.status = 400; + ctx.response.body = { error: "Name is required" }; + return; + } + + const sanitizedName = sanitizeInput(name); + + const result = await db.execute( + "INSERT INTO sub_departments (department_id, name) VALUES (?, ?)", + [deptId, sanitizedName] + ); + + const newSubDepartment = await db.query( + "SELECT * FROM sub_departments WHERE id = ?", + [result.lastInsertId] + ); + + ctx.response.status = 201; + ctx.response.body = newSubDepartment[0]; + } catch (error) { + const err = error as { code?: string }; + if (err.code === "ER_DUP_ENTRY") { + ctx.response.status = 400; + ctx.response.body = { error: "Sub-department already exists in this department" }; + return; + } console.error("Create sub-department error:", error); ctx.response.status = 500; ctx.response.body = { error: "Internal server error" }; diff --git a/src/App.tsx b/src/App.tsx index 96fec0d..44a873c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,8 +12,9 @@ import { LoginPage } from './pages/LoginPage'; import { ReportingPage } from './pages/ReportingPage'; import { StandardRatesPage } from './pages/StandardRatesPage'; import { AllRatesPage } from './pages/AllRatesPage'; +import { ActivitiesPage } from './pages/ActivitiesPage'; -type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates' | 'swaps' | 'reports' | 'standard-rates' | 'all-rates'; +type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates' | 'swaps' | 'reports' | 'standard-rates' | 'all-rates' | 'activities'; const AppContent: React.FC = () => { const [activePage, setActivePage] = useState('dashboard'); @@ -39,6 +40,8 @@ const AppContent: React.FC = () => { return ; case 'all-rates': return ; + case 'activities': + return ; default: return ; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4f5c5e9..6097d84 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList, ArrowRightLeft, FileSpreadsheet, Scale, Eye } from 'lucide-react'; +import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList, ArrowRightLeft, FileSpreadsheet, Scale, Eye, Layers } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; interface SidebarItemProps { @@ -142,6 +142,16 @@ export const Sidebar: React.FC = ({ activePage, onNavigate }) => { onClick={() => onNavigate('all-rates')} /> )} + + {/* Activities Management - SuperAdmin and Supervisor */} + {(isSuperAdmin || isSupervisor) && ( + onNavigate('activities')} + /> + )} {/* Role indicator at bottom */} diff --git a/src/hooks/useActivities.ts b/src/hooks/useActivities.ts new file mode 100644 index 0000000..a23bc06 --- /dev/null +++ b/src/hooks/useActivities.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../services/api'; +import { Activity } from '../types'; + +export const useActivities = (subDepartmentId?: string | number) => { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchActivities = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params: { subDepartmentId?: number; departmentId?: number } = {}; + if (subDepartmentId) { + params.subDepartmentId = Number(subDepartmentId); + } + const data = await api.getActivities(params); + setActivities(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch activities'); + setActivities([]); + } finally { + setLoading(false); + } + }, [subDepartmentId]); + + useEffect(() => { + fetchActivities(); + }, [fetchActivities]); + + return { activities, loading, error, refresh: fetchActivities }; +}; + +export const useActivitiesByDepartment = (departmentId?: string | number) => { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchActivities = useCallback(async () => { + if (!departmentId) { + setActivities([]); + return; + } + + setLoading(true); + setError(null); + try { + const data = await api.getActivities({ departmentId: Number(departmentId) }); + setActivities(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch activities'); + setActivities([]); + } finally { + setLoading(false); + } + }, [departmentId]); + + useEffect(() => { + fetchActivities(); + }, [fetchActivities]); + + return { activities, loading, error, refresh: fetchActivities }; +}; diff --git a/src/pages/ActivitiesPage.tsx b/src/pages/ActivitiesPage.tsx new file mode 100644 index 0000000..68b0c8b --- /dev/null +++ b/src/pages/ActivitiesPage.tsx @@ -0,0 +1,416 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, RefreshCw, Trash2, Layers, Activity as ActivityIcon } from 'lucide-react'; +import { Card, CardHeader, CardContent } from '../components/ui/Card'; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table'; +import { Button } from '../components/ui/Button'; +import { Input, Select } from '../components/ui/Input'; +import { useDepartments, useSubDepartments } from '../hooks/useDepartments'; +import { useActivitiesByDepartment } from '../hooks/useActivities'; +import { useAuth } from '../contexts/AuthContext'; +import { api } from '../services/api'; +import { SubDepartment, Activity } from '../types'; + +export const ActivitiesPage: React.FC = () => { + const [activeTab, setActiveTab] = useState<'subDepartments' | 'activities'>('subDepartments'); + const { user } = useAuth(); + const { departments } = useDepartments(); + + // Role-based access + const isSupervisor = user?.role === 'Supervisor'; + const isSuperAdmin = user?.role === 'SuperAdmin'; + const canAccess = isSupervisor || isSuperAdmin; + + // Department selection - supervisors are locked to their department + const [selectedDeptId, setSelectedDeptId] = useState(''); + + // Get sub-departments and activities for selected department + const { subDepartments, refresh: refreshSubDepts } = useSubDepartments(selectedDeptId); + const { activities, refresh: refreshActivities } = useActivitiesByDepartment(selectedDeptId); + + // Form states + const [subDeptForm, setSubDeptForm] = useState({ name: '' }); + const [activityForm, setActivityForm] = useState({ + subDepartmentId: '', + name: '', + unitOfMeasurement: 'Per Bag' as 'Per Bag' | 'Fixed Rate-Per Person' + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // Auto-select department for supervisors + useEffect(() => { + if (isSupervisor && user?.department_id) { + setSelectedDeptId(String(user.department_id)); + } + }, [isSupervisor, user?.department_id]); + + // Clear messages after 3 seconds + useEffect(() => { + if (success || error) { + const timer = setTimeout(() => { + setSuccess(''); + setError(''); + }, 3000); + return () => clearTimeout(timer); + } + }, [success, error]); + + const handleCreateSubDepartment = async () => { + if (!subDeptForm.name.trim()) { + setError('Sub-department name is required'); + return; + } + if (!selectedDeptId) { + setError('Please select a department first'); + return; + } + + setLoading(true); + setError(''); + try { + await api.createSubDepartment({ + department_id: parseInt(selectedDeptId), + name: subDeptForm.name.trim() + }); + setSuccess('Sub-department created successfully'); + setSubDeptForm({ name: '' }); + refreshSubDepts(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create sub-department'); + } finally { + setLoading(false); + } + }; + + const handleDeleteSubDepartment = async (id: number) => { + if (!confirm('Are you sure you want to delete this sub-department? This will also delete all associated activities.')) { + return; + } + + setLoading(true); + try { + await api.deleteSubDepartment(id); + setSuccess('Sub-department deleted successfully'); + refreshSubDepts(); + refreshActivities(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete sub-department'); + } finally { + setLoading(false); + } + }; + + const handleCreateActivity = async () => { + if (!activityForm.name.trim()) { + setError('Activity name is required'); + return; + } + if (!activityForm.subDepartmentId) { + setError('Please select a sub-department'); + return; + } + + setLoading(true); + setError(''); + try { + await api.createActivity({ + sub_department_id: parseInt(activityForm.subDepartmentId), + name: activityForm.name.trim(), + unit_of_measurement: activityForm.unitOfMeasurement + }); + setSuccess('Activity created successfully'); + setActivityForm({ subDepartmentId: '', name: '', unitOfMeasurement: 'Per Bag' }); + refreshActivities(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create activity'); + } finally { + setLoading(false); + } + }; + + const handleDeleteActivity = async (id: number) => { + if (!confirm('Are you sure you want to delete this activity?')) { + return; + } + + setLoading(true); + try { + await api.deleteActivity(id); + setSuccess('Activity deleted successfully'); + refreshActivities(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete activity'); + } finally { + setLoading(false); + } + }; + + if (!canAccess) { + return ( +
+ + +

You do not have permission to access this page.

+
+
+
+ ); + } + + const selectedDeptName = departments.find(d => d.id === parseInt(selectedDeptId))?.name || ''; + + return ( +
+ + +
+
+ +

Manage Activities & Sub-Departments

+
+ +
+
+ + + {/* Department Selection */} +
+ {isSupervisor ? ( +
+ + +

As a supervisor, you can only manage your department's activities.

+
+ ) : ( + setSubDeptForm({ name: e.target.value })} + placeholder="e.g., Loading/Unloading, Destoner, Tank" + /> +
+ +
+ + + {/* Sub-Departments List */} + + + + Sub-Department Name + Activities Count + Created At + Actions + + + + {subDepartments.length === 0 ? ( + + + No sub-departments found. Create one above. + + + ) : ( + subDepartments.map((subDept: SubDepartment) => { + const activityCount = activities.filter(a => a.sub_department_id === subDept.id).length; + return ( + + {subDept.name} + {activityCount} + {new Date(subDept.created_at).toLocaleDateString()} + + + + + ); + }) + )} + +
+ + )} + + {/* Activities Tab */} + {activeTab === 'activities' && ( +
+ {/* Create Activity Form */} +
+

Add New Activity

+
+ setActivityForm(prev => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Mufali Aavak Katai" + /> + ('/departments/sub-departments', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async deleteSubDepartment(id: number) { + return this.request<{ message: string }>(`/departments/sub-departments/${id}`, { + method: 'DELETE', + }); + } + // Work Allocations async getWorkAllocations(params?: { employeeId?: number; status?: string; departmentId?: number }) { const query = new URLSearchParams(params as any).toString();