Compare commits
5 Commits
04e25527e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e5555c658 | |||
| 1ac9d22e2d | |||
| 7b0e6923a9 | |||
| d36d925e38 | |||
| 2d3412f79a |
@@ -6,7 +6,7 @@ DB_NAME=work_allocation
|
||||
DB_PORT=3307
|
||||
|
||||
# JWT Configuration - CHANGE IN PRODUCTION!
|
||||
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024
|
||||
JWT_SECRET=VwoPOwDth7aqCSlrbf/qYYY0upLu0kRuTcuM71ionJI=
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
|
||||
@@ -9,11 +9,27 @@
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.3",
|
||||
"mysql2": "npm:mysql2@^3.11.0",
|
||||
"bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
|
||||
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts"
|
||||
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts",
|
||||
"react": "https://esm.sh/react@18",
|
||||
"react-dom": "https://esm.sh/react-dom@18"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["recommended"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 100,
|
||||
"indentWidth": 2,
|
||||
"semiColons": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,12 +36,20 @@ router.get(
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
COALESCE(oc.name, c.name) as contractor_name,
|
||||
es.id as active_swap_id,
|
||||
es.original_contractor_id,
|
||||
es.target_contractor_id,
|
||||
oc.name as original_contractor_name,
|
||||
tc.name as target_contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
LEFT JOIN employee_swaps es ON es.employee_id = e.id AND es.status = 'Active' AND es.swap_date <= a.work_date
|
||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
@@ -97,12 +105,18 @@ router.get("/:id", authenticateToken, async (ctx) => {
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
COALESCE(oc.name, c.name) as contractor_name,
|
||||
es.id as active_swap_id,
|
||||
oc.name as original_contractor_name,
|
||||
tc.name as target_contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
LEFT JOIN employee_swaps es ON es.employee_id = e.id AND es.status = 'Active' AND es.swap_date <= a.work_date
|
||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||
WHERE a.id = ?`,
|
||||
[attendanceId],
|
||||
);
|
||||
@@ -157,27 +171,43 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's an active check-in (not yet checked out) for today
|
||||
const activeCheckIn = await db.query<Attendance[]>(
|
||||
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = 'CheckedIn'",
|
||||
// Check if there's any existing attendance record for today
|
||||
const existingRecord = await db.query<Attendance[]>(
|
||||
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ?",
|
||||
[employeeId, workDate],
|
||||
);
|
||||
|
||||
if (activeCheckIn.length > 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "User has an active check-in. Please check out first before checking in again." };
|
||||
return;
|
||||
}
|
||||
|
||||
const checkInTime = new Date().toISOString().slice(0, 19).replace(
|
||||
"T",
|
||||
" ",
|
||||
);
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"],
|
||||
);
|
||||
let recordId: number;
|
||||
|
||||
if (existingRecord.length > 0) {
|
||||
const record = existingRecord[0];
|
||||
|
||||
// If already checked in, don't allow another check-in
|
||||
if (record.status === "CheckedIn") {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "User has an active check-in. Please check out first before checking in again." };
|
||||
return;
|
||||
}
|
||||
|
||||
// If checked out or other status, update the existing record to check in again
|
||||
await db.execute(
|
||||
"UPDATE attendance SET check_in_time = ?, check_out_time = NULL, status = ?, supervisor_id = ? WHERE id = ?",
|
||||
[checkInTime, "CheckedIn", currentUser.id, record.id],
|
||||
);
|
||||
recordId = record.id;
|
||||
} else {
|
||||
// No existing record, create new one
|
||||
const result = await db.execute(
|
||||
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"],
|
||||
);
|
||||
recordId = result.insertId as number;
|
||||
}
|
||||
|
||||
const newRecord = await db.query<Attendance[]>(
|
||||
`SELECT a.*,
|
||||
@@ -191,10 +221,10 @@ router.post(
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
WHERE a.id = ?`,
|
||||
[result.insertId],
|
||||
[recordId],
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.status = existingRecord.length > 0 ? 200 : 201;
|
||||
ctx.response.body = newRecord[0];
|
||||
} catch (error) {
|
||||
console.error("Check in error:", error);
|
||||
|
||||
@@ -29,11 +29,14 @@ router.get(
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
e.phone_number as employee_phone,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
COALESCE(oc.name, c.name) as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name,
|
||||
d.id as department_id,
|
||||
sr.rate as standard_rate
|
||||
sr.rate as standard_rate,
|
||||
es.id as active_swap_id,
|
||||
oc.name as original_contractor_name,
|
||||
tc.name as target_contractor_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
@@ -42,6 +45,9 @@ router.get(
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN standard_rates sr ON wa.sub_department_id = sr.sub_department_id
|
||||
AND wa.activity = sr.activity
|
||||
LEFT JOIN employee_swaps es ON es.employee_id = e.id AND es.status = 'Active' AND es.swap_date <= wa.assigned_date
|
||||
LEFT JOIN users oc ON es.original_contractor_id = oc.id
|
||||
LEFT JOIN users tc ON es.target_contractor_id = tc.id
|
||||
WHERE wa.status = 'Completed'
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
@@ -63,9 +69,9 @@ router.get(
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
// Department filter (for SuperAdmin)
|
||||
if (departmentId && currentUser.role === "SuperAdmin") {
|
||||
query += " AND e.department_id = ?";
|
||||
// Department filter - filter by sub-department's department
|
||||
if (departmentId) {
|
||||
query += " AND sd.department_id = ?";
|
||||
queryParams.push(departmentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -304,6 +304,91 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
// Update work allocation units (Supervisor, Contractor, or SuperAdmin)
|
||||
router.put(
|
||||
"/:id/units",
|
||||
authenticateToken,
|
||||
authorize("Supervisor", "Contractor", "SuperAdmin"),
|
||||
async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const allocationId = ctx.params.id;
|
||||
const body = await ctx.request.body.json() as {
|
||||
units?: number;
|
||||
completedUnits?: number;
|
||||
markComplete?: boolean;
|
||||
};
|
||||
const { units, completedUnits, markComplete } = body;
|
||||
|
||||
if (units === undefined && completedUnits === undefined) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Units or completedUnits required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify allocation exists and user has access
|
||||
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
||||
const params: unknown[] = [allocationId];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND supervisor_id = ?";
|
||||
params.push(currentUser.id);
|
||||
} else if (currentUser.role === "Contractor") {
|
||||
query += " AND contractor_id = ?";
|
||||
params.push(currentUser.id);
|
||||
}
|
||||
|
||||
const allocations = await db.query<WorkAllocation[]>(query, params);
|
||||
|
||||
if (allocations.length === 0) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = {
|
||||
error: "Work allocation not found or access denied",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const allocation = allocations[0];
|
||||
const newUnits = units !== undefined ? units : allocation.units;
|
||||
const newCompletedUnits = completedUnits !== undefined ? completedUnits : (allocation as any).completed_units || 0;
|
||||
const rate = allocation.rate || 0;
|
||||
const newTotalAmount = newCompletedUnits * rate;
|
||||
|
||||
// Determine status: mark as Completed if markComplete is true
|
||||
const newStatus = markComplete ? "Completed" : allocation.status;
|
||||
const completionDate = markComplete ? new Date().toISOString().split("T")[0] : allocation.completion_date;
|
||||
|
||||
await db.execute(
|
||||
"UPDATE work_allocations SET units = ?, completed_units = ?, total_amount = ?, status = ?, completion_date = ? WHERE id = ?",
|
||||
[newUnits, newCompletedUnits, newTotalAmount, newStatus, completionDate, allocationId],
|
||||
);
|
||||
|
||||
const updatedAllocation = await db.query<WorkAllocation[]>(
|
||||
`SELECT wa.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
JOIN users c ON wa.contractor_id = c.id
|
||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE wa.id = ?`,
|
||||
[allocationId],
|
||||
);
|
||||
|
||||
ctx.response.body = updatedAllocation[0];
|
||||
} catch (error) {
|
||||
console.error("Update work allocation units error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete work allocation (Supervisor or SuperAdmin)
|
||||
router.delete(
|
||||
"/:id",
|
||||
|
||||
15
backend-deno/scripts/add_completed_units.sql
Normal file
15
backend-deno/scripts/add_completed_units.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Add completed_units column to work_allocations table
|
||||
-- Run this migration to support tracking completed units separately from total units
|
||||
-- Note: MySQL 8.0 does not support IF NOT EXISTS for ADD COLUMN
|
||||
|
||||
-- Check if column exists before running:
|
||||
-- SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'work_allocations' AND COLUMN_NAME = 'completed_units';
|
||||
|
||||
-- Add the column (will error if already exists)
|
||||
ALTER TABLE work_allocations
|
||||
ADD COLUMN completed_units DECIMAL(10,2) DEFAULT 0 AFTER units;
|
||||
|
||||
-- Update existing records to set completed_units equal to units for completed allocations
|
||||
UPDATE work_allocations
|
||||
SET completed_units = units
|
||||
WHERE status = 'Completed' AND units IS NOT NULL;
|
||||
@@ -70,6 +70,7 @@ export interface WorkAllocation {
|
||||
status: AllocationStatus;
|
||||
rate: number | null;
|
||||
units: number | null;
|
||||
completed_units: number | null;
|
||||
total_amount: number | null;
|
||||
created_at: Date;
|
||||
employee_name?: string;
|
||||
|
||||
@@ -82,6 +82,27 @@ export const useWorkAllocations = (
|
||||
}
|
||||
};
|
||||
|
||||
const updateUnits = async (
|
||||
id: number,
|
||||
units?: number,
|
||||
completedUnits?: number,
|
||||
markComplete?: boolean,
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkAllocationUnits(id, units, completedUnits, markComplete);
|
||||
await fetchAllocations(); // Refresh list
|
||||
return updated;
|
||||
} catch (err: never) {
|
||||
setError(err.message || "Failed to update work allocation units");
|
||||
console.error("Failed to update work allocation units:", err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
allocations,
|
||||
loading,
|
||||
@@ -90,5 +111,6 @@ export const useWorkAllocations = (
|
||||
createAllocation,
|
||||
updateAllocation,
|
||||
deleteAllocation,
|
||||
updateUnits,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const AllRatesPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params: any = {};
|
||||
const params: { departmentId?: number; startDate?: string; endDate?: string } = {};
|
||||
if (filters.departmentId) {
|
||||
params.departmentId = parseInt(filters.departmentId);
|
||||
}
|
||||
@@ -55,8 +55,8 @@ export const AllRatesPage: React.FC = () => {
|
||||
const data = await api.getAllRates(params);
|
||||
setAllRates(data.allRates);
|
||||
setSummary(data.summary);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch rates");
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch rates");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -47,20 +47,19 @@ export const ContractorPaymentPage: React.FC = () => {
|
||||
|
||||
// Fetch report data
|
||||
const fetchReport = async () => {
|
||||
if (!selectedDepartment) {
|
||||
alert("Please select a department");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch completed allocations for the date range
|
||||
const params: Record<string, any> = {
|
||||
startDate,
|
||||
endDate,
|
||||
departmentId: parseInt(selectedDepartment),
|
||||
};
|
||||
|
||||
// Only add departmentId if a specific department is selected
|
||||
if (selectedDepartment) {
|
||||
params.departmentId = parseInt(selectedDepartment);
|
||||
}
|
||||
|
||||
const data = await api.getCompletedAllocationsReport(params);
|
||||
|
||||
// Process data to group by sub-department and contractor
|
||||
@@ -113,14 +112,17 @@ export const ContractorPaymentPage: React.FC = () => {
|
||||
|
||||
const contractor = contractorMap.get(contractorId)!;
|
||||
const amount = parseFloat(alloc.total_amount) || parseFloat(alloc.rate) || 0;
|
||||
const departmentName = (alloc.department_name || "").toLowerCase();
|
||||
const subDeptNameLower = (alloc.sub_department_name || "").toLowerCase();
|
||||
const activity = (alloc.activity || "").toLowerCase();
|
||||
|
||||
// Categorize by activity type
|
||||
if (activity.includes("dana")) {
|
||||
// Categorize by department/sub-department name
|
||||
if (departmentName.includes("dana") || subDeptNameLower.includes("dana")) {
|
||||
contractor.dana += amount;
|
||||
} else if (activity.includes("tukdi")) {
|
||||
} else if (departmentName.includes("tudki") || departmentName.includes("tukdi") ||
|
||||
subDeptNameLower.includes("tudki") || subDeptNameLower.includes("tukdi")) {
|
||||
contractor.tukdi += amount;
|
||||
} else if (activity.includes("groundnut")) {
|
||||
} else if (departmentName.includes("groundnut") || subDeptNameLower.includes("groundnut")) {
|
||||
contractor.groundnut += amount;
|
||||
} else if (activity.includes("commission") || activity.includes("salary")) {
|
||||
contractor.commission_salary += amount;
|
||||
@@ -383,7 +385,7 @@ export const ContractorPaymentPage: React.FC = () => {
|
||||
onChange={(e) => setSelectedDepartment(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select Department</option>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((dept) => (
|
||||
<option key={dept.id} value={dept.id}>{dept.name}</option>
|
||||
))}
|
||||
|
||||
@@ -76,6 +76,13 @@ export const DashboardPage: React.FC = () => {
|
||||
const [contractorRates, setContractorRates] = useState<
|
||||
Record<number, number>
|
||||
>({});
|
||||
const [activeSwaps, setActiveSwaps] = useState<{
|
||||
employee_id: number;
|
||||
original_department_id: number;
|
||||
target_department_id: number;
|
||||
original_contractor_id?: number;
|
||||
target_contractor_id?: number;
|
||||
}[]>([]);
|
||||
|
||||
const refreshAllData = () => {
|
||||
refreshEmployees();
|
||||
@@ -84,6 +91,12 @@ export const DashboardPage: React.FC = () => {
|
||||
api.getAttendance({ startDate: today, endDate: today })
|
||||
.then(setAttendance)
|
||||
.catch(console.error);
|
||||
// Refresh active swaps for SuperAdmin
|
||||
if (isSuperAdmin) {
|
||||
api.getEmployeeSwaps({ status: "Active" })
|
||||
.then(setActiveSwaps)
|
||||
.catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const isSuperAdmin = user?.role === "SuperAdmin";
|
||||
@@ -102,6 +115,15 @@ export const DashboardPage: React.FC = () => {
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Fetch active swaps for SuperAdmin
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
api.getEmployeeSwaps({ status: "Active" })
|
||||
.then(setActiveSwaps)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
// Fetch contractor rates for supervisor view
|
||||
useEffect(() => {
|
||||
if (isSupervisor) {
|
||||
@@ -213,6 +235,16 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
const supervisors = employees.filter((e) => e.role === "Supervisor");
|
||||
|
||||
// Helper function to get the effective contractor ID for an employee
|
||||
// If employee has an active swap, return the original contractor ID
|
||||
const getEffectiveContractorId = (employeeId: number, currentContractorId?: number) => {
|
||||
const swap = activeSwaps.find((s) => s.employee_id === employeeId);
|
||||
if (swap && swap.original_contractor_id) {
|
||||
return swap.original_contractor_id;
|
||||
}
|
||||
return currentContractorId;
|
||||
};
|
||||
|
||||
return supervisors.map((supervisor) => {
|
||||
const deptContractors = employees.filter(
|
||||
(e) =>
|
||||
@@ -220,67 +252,125 @@ export const DashboardPage: React.FC = () => {
|
||||
e.department_id === supervisor.department_id,
|
||||
);
|
||||
|
||||
// Get employees without a contractor but in this department (e.g., swapped employees)
|
||||
const unassignedEmployees = employees.filter(
|
||||
(e) =>
|
||||
e.role === "Employee" &&
|
||||
e.department_id === supervisor.department_id &&
|
||||
!e.contractor_id,
|
||||
);
|
||||
// Find employees swapped INTO this department (without a specific target contractor)
|
||||
const swappedIntoThisDept = activeSwaps
|
||||
.filter((s) => s.target_department_id === supervisor.department_id && !s.target_contractor_id)
|
||||
.map((swap) => {
|
||||
const emp = employees.find((e) => e.id === swap.employee_id);
|
||||
const originalContractor = employees.find((e) => e.id === swap.original_contractor_id);
|
||||
return emp ? { emp, originalContractor, swap } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
|
||||
const contractorNodes = deptContractors.map((contractor) => {
|
||||
// Include employees whose original contractor is this one (they belong here)
|
||||
const contractorEmployees = employees.filter(
|
||||
(e) => e.role === "Employee" && e.contractor_id === contractor.id,
|
||||
(e) => e.role === "Employee" && getEffectiveContractorId(e.id, e.contractor_id) === contractor.id,
|
||||
);
|
||||
|
||||
// Find employees swapped INTO this specific contractor
|
||||
const swappedInEmployees = activeSwaps
|
||||
.filter((s) => s.target_contractor_id === contractor.id)
|
||||
.map((swap) => employees.find((e) => e.id === swap.employee_id))
|
||||
.filter((e): e is NonNullable<typeof e> => e !== undefined);
|
||||
|
||||
// Build children: regular employees + swapped-in employees as nested
|
||||
const employeeNodes = contractorEmployees.map((emp) => {
|
||||
const empAttendance = attendance.find((a) =>
|
||||
a.employee_id === emp.id
|
||||
);
|
||||
const empAllocation = allocations.find((a) =>
|
||||
a.employee_id === emp.id && a.status !== "Completed"
|
||||
);
|
||||
const empSwap = activeSwaps.find((s) => s.employee_id === emp.id);
|
||||
|
||||
// If employee is swapped OUT, show swap info in activity
|
||||
let activityText = empAllocation?.description || empAllocation?.activity || "-";
|
||||
if (empSwap) {
|
||||
// Find target contractor/department name
|
||||
const targetContractor = employees.find((e) => e.id === empSwap.target_contractor_id);
|
||||
const targetDept = departments.find((d) => d.id === empSwap.target_department_id);
|
||||
activityText = `Swapped to ${targetContractor?.name || targetDept?.name || "another dept"}`;
|
||||
}
|
||||
|
||||
// Swapped-in employees are added at contractor level, not as children of individual employees
|
||||
const swappedInChildren: HierarchyNode[] = [];
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
role: "Employee",
|
||||
department: emp.department_name || "",
|
||||
subDepartment: empAllocation?.sub_department_name || "-",
|
||||
activity: activityText,
|
||||
status: empAttendance
|
||||
? (empAttendance.status === "CheckedIn" ||
|
||||
empAttendance.status === "CheckedOut"
|
||||
? "Present"
|
||||
: "Absent")
|
||||
: undefined,
|
||||
inTime: empAttendance?.check_in_time
|
||||
? new Date(empAttendance.check_in_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
outTime: empAttendance?.check_out_time
|
||||
? new Date(empAttendance.check_out_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
remark: empAttendance?.remark,
|
||||
children: swappedInChildren,
|
||||
};
|
||||
});
|
||||
|
||||
// Add swapped-in employees as a separate group under this contractor
|
||||
const swappedInNodes = swappedInEmployees.map((emp) => {
|
||||
const empAttendance = attendance.find((a) => a.employee_id === emp.id);
|
||||
const empAllocation = allocations.find((a) =>
|
||||
a.employee_id === emp.id && a.status !== "Completed"
|
||||
);
|
||||
const empSwap = activeSwaps.find((s) => s.employee_id === emp.id);
|
||||
const originalContractor = employees.find((e) => e.id === empSwap?.original_contractor_id);
|
||||
const originalDept = departments.find((d) => d.id === empSwap?.original_department_id);
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: `${emp.name} (from ${originalContractor?.name || originalDept?.name || "other"})`,
|
||||
role: "Employee",
|
||||
department: emp.department_name || "",
|
||||
subDepartment: empAllocation?.sub_department_name || "-",
|
||||
activity: empAllocation?.description || empAllocation?.activity || "-",
|
||||
status: empAttendance
|
||||
? (empAttendance.status === "CheckedIn" ||
|
||||
empAttendance.status === "CheckedOut"
|
||||
? "Present"
|
||||
: "Absent")
|
||||
: undefined,
|
||||
inTime: empAttendance?.check_in_time
|
||||
? new Date(empAttendance.check_in_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
outTime: empAttendance?.check_out_time
|
||||
? new Date(empAttendance.check_out_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
remark: empAttendance?.remark,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: contractor.id,
|
||||
name: contractor.name,
|
||||
role: "Contractor",
|
||||
department: contractor.department_name || "",
|
||||
children: contractorEmployees.map((emp) => {
|
||||
const empAttendance = attendance.find((a) =>
|
||||
a.employee_id === emp.id
|
||||
);
|
||||
const empAllocation = allocations.find((a) =>
|
||||
a.employee_id === emp.id && a.status !== "Completed"
|
||||
);
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
role: "Employee",
|
||||
department: emp.department_name || "",
|
||||
subDepartment: empAllocation?.sub_department_name || "-",
|
||||
activity: empAllocation?.description || empAllocation?.activity ||
|
||||
"-",
|
||||
status: empAttendance
|
||||
? (empAttendance.status === "CheckedIn" ||
|
||||
empAttendance.status === "CheckedOut"
|
||||
? "Present"
|
||||
: "Absent")
|
||||
: undefined,
|
||||
inTime: empAttendance?.check_in_time
|
||||
? new Date(empAttendance.check_in_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
outTime: empAttendance?.check_out_time
|
||||
? new Date(empAttendance.check_out_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: undefined,
|
||||
remark: empAttendance?.remark,
|
||||
children: [],
|
||||
};
|
||||
}),
|
||||
children: [...employeeNodes, ...swappedInNodes],
|
||||
};
|
||||
});
|
||||
|
||||
// Add unassigned employees node if there are any
|
||||
if (unassignedEmployees.length > 0) {
|
||||
// Add "Swapped In" node for employees swapped into this department without a specific contractor
|
||||
if (swappedIntoThisDept.length > 0) {
|
||||
contractorNodes.push({
|
||||
id: -supervisor.department_id!, // Negative ID to avoid conflicts
|
||||
name: "Unassigned (Swapped)",
|
||||
name: "Swapped In",
|
||||
role: "Contractor",
|
||||
department: supervisor.department_name || "",
|
||||
children: unassignedEmployees.map((emp) => {
|
||||
children: swappedIntoThisDept.map(({ emp, originalContractor }) => {
|
||||
const empAttendance = attendance.find((a) =>
|
||||
a.employee_id === emp.id
|
||||
);
|
||||
@@ -290,12 +380,11 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
name: `${emp.name} (from ${originalContractor?.name || "other"})`,
|
||||
role: "Employee",
|
||||
department: emp.department_name || "",
|
||||
subDepartment: empAllocation?.sub_department_name || "-",
|
||||
activity: empAllocation?.description || empAllocation?.activity ||
|
||||
"Swapped",
|
||||
activity: empAllocation?.description || empAllocation?.activity || "-",
|
||||
status: empAttendance
|
||||
? (empAttendance.status === "CheckedIn" ||
|
||||
empAttendance.status === "CheckedOut"
|
||||
@@ -325,7 +414,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
return supervisorNode;
|
||||
});
|
||||
}, [isSuperAdmin, employees, attendance, allocations]);
|
||||
}, [isSuperAdmin, employees, attendance, allocations, activeSwaps, departments]);
|
||||
|
||||
// Build hierarchy data for Supervisor view (department-specific)
|
||||
const supervisorHierarchyData = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CheckCircle, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { CheckCircle, Edit2, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||
import {
|
||||
Table,
|
||||
@@ -31,6 +31,7 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
createAllocation,
|
||||
updateAllocation,
|
||||
deleteAllocation,
|
||||
updateUnits,
|
||||
} = useWorkAllocations();
|
||||
const { departments } = useDepartments();
|
||||
const { employees } = useEmployees();
|
||||
@@ -39,6 +40,15 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
|
||||
// Check if user is supervisor (limited to their department)
|
||||
const isSupervisor = user?.role === "Supervisor";
|
||||
|
||||
// Check if user can edit units (supervisor, contractor, or super admin)
|
||||
const canEditUnits = user?.role === "Supervisor" || user?.role === "Contractor" || user?.role === "SuperAdmin";
|
||||
|
||||
// State for edit units modal
|
||||
const [editingAllocation, setEditingAllocation] = useState<any | null>(null);
|
||||
const [editUnitsValue, setEditUnitsValue] = useState<string>("");
|
||||
const [editCompletedUnitsValue, setEditCompletedUnitsValue] = useState<string>("");
|
||||
const [markCompleteChecked, setMarkCompleteChecked] = useState<boolean>(false);
|
||||
|
||||
// Get supervisor's department name
|
||||
const supervisorDeptName =
|
||||
@@ -213,6 +223,42 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditUnits = (allocation: any, fromCheckmark = false) => {
|
||||
setEditingAllocation(allocation);
|
||||
setEditUnitsValue(String(allocation.units || 0));
|
||||
setEditCompletedUnitsValue(String(allocation.completed_units || 0));
|
||||
// If opened from checkmark, default to marking complete
|
||||
setMarkCompleteChecked(fromCheckmark && allocation.status !== "Completed");
|
||||
};
|
||||
|
||||
const handleCloseEditUnits = () => {
|
||||
setEditingAllocation(null);
|
||||
setEditUnitsValue("");
|
||||
setEditCompletedUnitsValue("");
|
||||
setMarkCompleteChecked(false);
|
||||
};
|
||||
|
||||
const handleSaveUnits = async () => {
|
||||
if (!editingAllocation) return;
|
||||
try {
|
||||
const units = parseFloat(editUnitsValue) || 0;
|
||||
const completedUnits = parseFloat(editCompletedUnitsValue) || 0;
|
||||
await updateUnits(editingAllocation.id, units, completedUnits, markCompleteChecked);
|
||||
handleCloseEditUnits();
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Failed to update units");
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkCompleteWithUnits = async (allocation: any) => {
|
||||
const isPerUnit = allocation.units && allocation.units > 0;
|
||||
if (isPerUnit && canEditUnits) {
|
||||
handleOpenEditUnits(allocation, true);
|
||||
} else {
|
||||
await handleMarkComplete(allocation.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
const stats = {
|
||||
total: allocations.length,
|
||||
@@ -517,8 +563,10 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Employee</TableHead>
|
||||
<TableHead>Contractor</TableHead>
|
||||
<TableHead>Department</TableHead>
|
||||
<TableHead>Sub-Department</TableHead>
|
||||
<TableHead>Activity</TableHead>
|
||||
<TableHead>Units</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Rate Details</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -526,12 +574,12 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAllocations.map((allocation) => {
|
||||
const isPerUnit = allocation.activity === "Loading" ||
|
||||
allocation.activity === "Unloading";
|
||||
const isPerUnit = allocation.units && allocation.units > 0;
|
||||
const units = parseFloat(allocation.units) || 0;
|
||||
const completedUnits = parseFloat(allocation.completed_units) || 0;
|
||||
const rate = parseFloat(allocation.rate) || 0;
|
||||
const total = parseFloat(allocation.total_amount) ||
|
||||
(isPerUnit ? units * rate : rate);
|
||||
(isPerUnit ? completedUnits * rate : rate);
|
||||
|
||||
return (
|
||||
<TableRow key={allocation.id}>
|
||||
@@ -542,6 +590,9 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
<TableCell>
|
||||
{allocation.contractor_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{allocation.department_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{allocation.sub_department_name || "-"}
|
||||
</TableCell>
|
||||
@@ -550,8 +601,7 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
? (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
allocation.activity === "Loading" ||
|
||||
allocation.activity === "Unloading"
|
||||
isPerUnit
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
@@ -561,6 +611,41 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isPerUnit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm">
|
||||
<span className={`font-semibold ${completedUnits < units ? "text-orange-600" : "text-green-600"}`}>
|
||||
{completedUnits}
|
||||
</span>
|
||||
<span className="text-gray-500"> / {units}</span>
|
||||
{completedUnits < units && (
|
||||
<div className="text-xs text-orange-500">
|
||||
({units - completedUnits} remaining)
|
||||
</div>
|
||||
)}
|
||||
{completedUnits >= units && units > 0 && (
|
||||
<div className="text-xs text-green-500">
|
||||
✓ Complete
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditUnits && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenEditUnits(allocation)}
|
||||
className="text-blue-600 p-1"
|
||||
title="Edit Units"
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(allocation.assigned_date)
|
||||
.toLocaleDateString()}
|
||||
@@ -569,11 +654,11 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
{rate > 0
|
||||
? (
|
||||
<div className="text-sm">
|
||||
{isPerUnit && units > 0
|
||||
{isPerUnit && completedUnits > 0
|
||||
? (
|
||||
<div>
|
||||
<div className="text-gray-500">
|
||||
{units} × ₹{rate.toFixed(2)}
|
||||
{completedUnits} × ₹{rate.toFixed(2)}
|
||||
</div>
|
||||
<div className="font-semibold text-green-600">
|
||||
= ₹{total.toFixed(2)}
|
||||
@@ -611,9 +696,9 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleMarkComplete(allocation.id)}
|
||||
handleMarkCompleteWithUnits(allocation)}
|
||||
className="text-green-600"
|
||||
title="Mark Complete"
|
||||
title={isPerUnit ? "Complete & Set Units" : "Mark Complete"}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
</Button>
|
||||
@@ -691,6 +776,123 @@ export const WorkAllocationPage: React.FC = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Units Modal */}
|
||||
{editingAllocation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
Edit Units - {editingAllocation.activity}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseEditUnits}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Total Units Assigned
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editUnitsValue}
|
||||
onChange={(e) => setEditUnitsValue(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Completed Units
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editCompletedUnitsValue}
|
||||
onChange={(e) => setEditCompletedUnitsValue(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Units Difference Display */}
|
||||
{(() => {
|
||||
const totalUnits = parseFloat(editUnitsValue) || 0;
|
||||
const completed = parseFloat(editCompletedUnitsValue) || 0;
|
||||
const remaining = totalUnits - completed;
|
||||
return (
|
||||
<div className={`p-3 rounded-md ${remaining > 0 ? "bg-orange-50 border border-orange-200" : "bg-green-50 border border-green-200"}`}>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Total Units:</span>
|
||||
<span className="font-medium">{totalUnits}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Completed:</span>
|
||||
<span className="font-medium text-green-600">{completed}</span>
|
||||
</div>
|
||||
{remaining > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Remaining:</span>
|
||||
<span className="font-medium text-orange-600">{remaining}</span>
|
||||
</div>
|
||||
)}
|
||||
{remaining === 0 && totalUnits > 0 && (
|
||||
<div className="text-sm text-green-600 font-medium mt-1">
|
||||
✓ All units completed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Calculation Preview */}
|
||||
{parseFloat(editingAllocation.rate) > 0 && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="text-sm text-gray-600">
|
||||
Rate: ₹{parseFloat(editingAllocation.rate).toFixed(2)} per unit
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-green-600 mt-1">
|
||||
Total Payment: ₹{((parseFloat(editCompletedUnitsValue) || 0) * parseFloat(editingAllocation.rate)).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Complete Checkbox */}
|
||||
{editingAllocation.status !== "Completed" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="markComplete"
|
||||
checked={markCompleteChecked}
|
||||
onChange={(e) => setMarkCompleteChecked(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="markComplete" className="text-sm font-medium text-gray-700">
|
||||
Mark as Completed
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={handleCloseEditUnits}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSaveUnits}>
|
||||
{markCompleteChecked ? "Save & Complete" : "Save Units"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -174,6 +174,18 @@ class ApiService {
|
||||
});
|
||||
}
|
||||
|
||||
updateWorkAllocationUnits(
|
||||
id: number,
|
||||
units?: number,
|
||||
completedUnits?: number,
|
||||
markComplete?: boolean,
|
||||
) {
|
||||
return this.request<any>(`/work-allocations/${id}/units`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ units, completedUnits, markComplete }),
|
||||
});
|
||||
}
|
||||
|
||||
// Attendance
|
||||
getAttendance(
|
||||
params?: {
|
||||
|
||||
@@ -56,11 +56,15 @@ export interface WorkAllocation {
|
||||
supervisor_id: number;
|
||||
contractor_id: number;
|
||||
sub_department_id?: number;
|
||||
activity?: string;
|
||||
description?: string;
|
||||
assigned_date: string;
|
||||
status: "Pending" | "InProgress" | "Completed" | "Cancelled";
|
||||
completion_date?: string;
|
||||
rate?: number;
|
||||
units?: number;
|
||||
completed_units?: number;
|
||||
total_amount?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
employee_name?: string;
|
||||
|
||||
@@ -452,7 +452,7 @@ export const exportReportToXLSX = (
|
||||
}));
|
||||
|
||||
const ws3 = XLSX.utils.json_to_sheet(contractorData);
|
||||
ws3["!cols"] = [
|
||||
ws3["!cols"] = [.
|
||||
{ wch: 25 }, // Contractor
|
||||
{ wch: 12 }, // Employees
|
||||
{ wch: 16 }, // Total Completed
|
||||
@@ -466,7 +466,8 @@ export const exportReportToXLSX = (
|
||||
? `${dateRange.startDate}_to_${dateRange.endDate}`
|
||||
: new Date().toISOString().split("T")[0];
|
||||
const deptStr = departmentName.toLowerCase().replace(/\s+/g, "_");
|
||||
const filename = `work_report_${deptStr}_${dateStr}.xlsx`;
|
||||
const randomStr = Math.random().toString(36).substring(2, 11);
|
||||
const filename = `work_report${randomStr}_${deptStr}_${dateStr}.xlsx`;
|
||||
|
||||
// Write and download
|
||||
XLSX.writeFile(wb, filename);
|
||||
|
||||
Reference in New Issue
Block a user