(Feat-Fix): Fixed Reports Page, overhauled the filtering system for reports.
This commit is contained in:
@@ -32,13 +32,16 @@ router.get(
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name,
|
||||
d.id as department_id
|
||||
d.id as department_id,
|
||||
sr.rate as standard_rate
|
||||
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
|
||||
LEFT JOIN standard_rates sr ON wa.sub_department_id = sr.sub_department_id
|
||||
AND wa.activity = sr.activity
|
||||
WHERE wa.status = 'Completed'
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>my-dashboard</title>
|
||||
<title>WorkAllocation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -485,7 +485,7 @@ export const RatesPage: React.FC = () => {
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setActiveTab("list");
|
||||
resetForm();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Building2,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||
import {
|
||||
@@ -16,87 +23,99 @@ import {
|
||||
TableRow,
|
||||
} from "../components/ui/Table.tsx";
|
||||
import { Button } from "../components/ui/Button.tsx";
|
||||
import { Input, Select } from "../components/ui/Input.tsx";
|
||||
import { api } from "../services/api.ts";
|
||||
import { useDepartments } from "../hooks/useDepartments.ts";
|
||||
import { useAuth } from "../contexts/authContext.ts";
|
||||
import {
|
||||
exportAllocationsToXLSX,
|
||||
exportWorkReportToXLSX,
|
||||
} from "../utils/excelExport.ts";
|
||||
import { exportReportToXLSX } from "../utils/excelExport.ts";
|
||||
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
interface SelectionCard {
|
||||
id: string | number;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export const ReportingPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { departments } = useDepartments();
|
||||
|
||||
// Wizard state
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(1);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
// Selections
|
||||
const [dateRange, setDateRange] = useState({ startDate: "", endDate: "" });
|
||||
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null);
|
||||
const [selectedContractor, setSelectedContractor] = useState<string | null>(null);
|
||||
const [selectedEmployee, setSelectedEmployee] = useState<string | null>(null);
|
||||
|
||||
// Data
|
||||
const [allocations, setAllocations] = useState<any[]>([]);
|
||||
const [summary, setSummary] = useState<
|
||||
{ totalAllocations: number; totalAmount: string; totalUnits: string } | null
|
||||
>(null);
|
||||
const [summary, setSummary] = useState<{
|
||||
totalAllocations: number;
|
||||
totalAmount: string;
|
||||
totalUnits: string;
|
||||
} | null>(null);
|
||||
const [contractors, setContractors] = useState<any[]>([]);
|
||||
const [employees, setEmployees] = useState<any[]>([]);
|
||||
const [subDepartments, setSubDepartments] = useState<any[]>([]);
|
||||
const [activities, setActivities] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Filters
|
||||
const [filters, setFilters] = useState({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departmentId: "",
|
||||
contractorId: "",
|
||||
employeeId: "",
|
||||
subDepartmentId: "",
|
||||
activity: "",
|
||||
});
|
||||
|
||||
const isSupervisor = user?.role === "Supervisor";
|
||||
const isContractor = user?.role === "Contractor";
|
||||
|
||||
// Fetch filter options
|
||||
// Fetch contractors and employees
|
||||
useEffect(() => {
|
||||
api.getUsers({ role: "Contractor" }).then(setContractors).catch(
|
||||
console.error,
|
||||
);
|
||||
api.getUsers({ role: "Contractor" }).then(setContractors).catch(console.error);
|
||||
api.getUsers({ role: "Employee" }).then(setEmployees).catch(console.error);
|
||||
api.getAllSubDepartments().then(setSubDepartments).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Fetch report data
|
||||
const fetchReport = async () => {
|
||||
// Filter contractors by department
|
||||
const filteredContractors = useMemo(() => {
|
||||
if (!selectedDepartment || selectedDepartment === "all") return contractors;
|
||||
return contractors.filter((c) => c.department_id === parseInt(selectedDepartment));
|
||||
}, [contractors, selectedDepartment]);
|
||||
|
||||
// Filter employees by contractor
|
||||
const filteredEmployees = useMemo(() => {
|
||||
if (!selectedContractor || selectedContractor === "all") return employees;
|
||||
return employees.filter((e) => e.contractor_id === parseInt(selectedContractor));
|
||||
}, [employees, selectedContractor]);
|
||||
|
||||
// Generate report
|
||||
const generateReport = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filters.startDate) params.startDate = filters.startDate;
|
||||
if (filters.endDate) params.endDate = filters.endDate;
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
// Department filter - use user's department if Supervisor, otherwise use filter
|
||||
const deptId = isSupervisor
|
||||
? user?.department_id
|
||||
: (filters.departmentId ? parseInt(filters.departmentId) : null);
|
||||
if (deptId) params.departmentId = deptId;
|
||||
if (dateRange.startDate) params.startDate = dateRange.startDate;
|
||||
if (dateRange.endDate) params.endDate = dateRange.endDate;
|
||||
|
||||
// Contractor filter - use user's id if Contractor, otherwise use filter
|
||||
const contractorIdValue = isContractor
|
||||
? user?.id
|
||||
: (filters.contractorId ? parseInt(filters.contractorId) : null);
|
||||
if (contractorIdValue) params.contractorId = contractorIdValue;
|
||||
// Use user's department if Supervisor
|
||||
if (isSupervisor && user?.department_id) {
|
||||
params.departmentId = user.department_id;
|
||||
} else if (selectedDepartment && selectedDepartment !== "all") {
|
||||
params.departmentId = parseInt(selectedDepartment);
|
||||
}
|
||||
|
||||
if (filters.employeeId) params.employeeId = parseInt(filters.employeeId);
|
||||
// Use user's id if Contractor
|
||||
if (isContractor && user?.id) {
|
||||
params.contractorId = user.id;
|
||||
} else if (selectedContractor && selectedContractor !== "all") {
|
||||
params.contractorId = parseInt(selectedContractor);
|
||||
}
|
||||
|
||||
if (selectedEmployee && selectedEmployee !== "all") {
|
||||
params.employeeId = parseInt(selectedEmployee);
|
||||
}
|
||||
|
||||
const data = await api.getCompletedAllocationsReport(params);
|
||||
setAllocations(data.allocations);
|
||||
setSummary(data.summary);
|
||||
|
||||
// Extract unique activities from allocations for the filter dropdown
|
||||
const uniqueActivities = [
|
||||
...new Set(
|
||||
data.allocations.map((a: any) => a.activity).filter(Boolean),
|
||||
),
|
||||
] as string[];
|
||||
setActivities(uniqueActivities);
|
||||
setShowResults(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch report");
|
||||
} finally {
|
||||
@@ -104,98 +123,268 @@ export const ReportingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport();
|
||||
}, []);
|
||||
|
||||
// Filter allocations based on search and dropdown filters
|
||||
// Filter allocations by search
|
||||
const filteredAllocations = useMemo(() => {
|
||||
let result = allocations;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
if (!searchQuery) return allocations;
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((a) =>
|
||||
return allocations.filter((a) =>
|
||||
a.employee_name?.toLowerCase().includes(query) ||
|
||||
a.contractor_name?.toLowerCase().includes(query) ||
|
||||
a.sub_department_name?.toLowerCase().includes(query) ||
|
||||
a.activity?.toLowerCase().includes(query) ||
|
||||
a.department_name?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
}, [allocations, searchQuery]);
|
||||
|
||||
// Apply sub-department filter (client-side)
|
||||
if (filters.subDepartmentId) {
|
||||
result = result.filter((a) =>
|
||||
a.sub_department_id === parseInt(filters.subDepartmentId)
|
||||
);
|
||||
}
|
||||
// Get selection names for summary
|
||||
const getSelectionSummary = () => {
|
||||
const deptName = selectedDepartment === "all"
|
||||
? "All Departments"
|
||||
: departments.find((d) => d.id === parseInt(selectedDepartment || ""))?.name || "All";
|
||||
const contractorName = selectedContractor === "all"
|
||||
? "All Contractors"
|
||||
: contractors.find((c) => c.id === parseInt(selectedContractor || ""))?.name || "All";
|
||||
const employeeName = selectedEmployee === "all"
|
||||
? "All Employees"
|
||||
: employees.find((e) => e.id === parseInt(selectedEmployee || ""))?.name || "All";
|
||||
|
||||
// Apply activity filter (client-side)
|
||||
if (filters.activity) {
|
||||
result = result.filter((a) => a.activity === filters.activity);
|
||||
}
|
||||
return { deptName, contractorName, employeeName };
|
||||
};
|
||||
|
||||
return result;
|
||||
}, [allocations, searchQuery, filters.subDepartmentId, filters.activity]);
|
||||
|
||||
// Get selected department name
|
||||
const selectedDeptName = filters.departmentId
|
||||
? departments.find((d) => d.id === parseInt(filters.departmentId))?.name ||
|
||||
"All Departments"
|
||||
: user?.role === "Supervisor"
|
||||
? departments.find((d) => d.id === user?.department_id)?.name ||
|
||||
"Department"
|
||||
: "All Departments";
|
||||
|
||||
// Export to Excel (XLSX format) - Formatted Report
|
||||
const exportFormattedReport = () => {
|
||||
// Export to Excel
|
||||
const handleExport = () => {
|
||||
if (filteredAllocations.length === 0) {
|
||||
alert("No data to export");
|
||||
return;
|
||||
}
|
||||
const { deptName } = getSelectionSummary();
|
||||
exportReportToXLSX(filteredAllocations, deptName, dateRange);
|
||||
};
|
||||
|
||||
exportWorkReportToXLSX(
|
||||
filteredAllocations,
|
||||
selectedDeptName,
|
||||
{ startDate: filters.startDate, endDate: filters.endDate },
|
||||
// Reset wizard
|
||||
const resetWizard = () => {
|
||||
setWizardStep(1);
|
||||
setShowResults(false);
|
||||
setDateRange({ startDate: "", endDate: "" });
|
||||
setSelectedDepartment(null);
|
||||
setSelectedContractor(null);
|
||||
setSelectedEmployee(null);
|
||||
setAllocations([]);
|
||||
setSummary(null);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Selection card component
|
||||
const SelectionCard = ({
|
||||
item,
|
||||
isSelected,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
item: SelectionCard;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`p-4 rounded-lg border-2 transition-all text-left w-full ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 ring-2 ring-blue-200"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isSelected ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium truncate ${isSelected ? "text-blue-700" : "text-gray-800"}`}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.subtitle && (
|
||||
<div className="text-sm text-gray-500 truncate">{item.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <CheckCircle2 className="text-blue-500 flex-shrink-0" size={20} />}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Export to Excel (XLSX format) - Simple List
|
||||
const exportSimpleList = () => {
|
||||
if (filteredAllocations.length === 0) {
|
||||
alert("No data to export");
|
||||
return;
|
||||
}
|
||||
// Step indicator
|
||||
const StepIndicator = () => (
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{[1, 2, 3, 4].map((step) => (
|
||||
<React.Fragment key={step}>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
step < wizardStep
|
||||
? "bg-green-500 text-white"
|
||||
: step === wizardStep
|
||||
? "bg-blue-600 text-white ring-4 ring-blue-200"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{step < wizardStep ? <Check size={18} /> : step}
|
||||
</div>
|
||||
{step < 4 && (
|
||||
<div className={`w-12 h-1 rounded ${step < wizardStep ? "bg-green-500" : "bg-gray-200"}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
exportAllocationsToXLSX(filteredAllocations);
|
||||
};
|
||||
|
||||
const handleFilterChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
fetchReport();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
departmentId: "",
|
||||
contractorId: "",
|
||||
employeeId: "",
|
||||
subDepartmentId: "",
|
||||
activity: "",
|
||||
// Render wizard steps
|
||||
const renderWizardContent = () => {
|
||||
switch (wizardStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Calendar className="mx-auto text-blue-600 mb-3" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-800">Select Date Range</h3>
|
||||
<p className="text-gray-500 mt-1">Choose the period for your report</p>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.startDate}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.endDate}
|
||||
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
setDateRange({
|
||||
startDate: firstDay.toISOString().split("T")[0],
|
||||
endDate: today.toISOString().split("T")[0],
|
||||
});
|
||||
setTimeout(fetchReport, 0);
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Use Current Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Building2 className="mx-auto text-blue-600 mb-3" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-800">Select Department</h3>
|
||||
<p className="text-gray-500 mt-1">Choose a department or select all</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto">
|
||||
<SelectionCard
|
||||
item={{ id: "all", name: "All Departments", subtitle: `${departments.length} departments` }}
|
||||
isSelected={selectedDepartment === "all"}
|
||||
onClick={() => setSelectedDepartment("all")}
|
||||
icon={<Building2 size={20} className={selectedDepartment === "all" ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
{departments.map((dept) => (
|
||||
<SelectionCard
|
||||
key={dept.id}
|
||||
item={{ id: dept.id, name: dept.name }}
|
||||
isSelected={selectedDepartment === String(dept.id)}
|
||||
onClick={() => setSelectedDepartment(String(dept.id))}
|
||||
icon={<Building2 size={20} className={selectedDepartment === String(dept.id) ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Users className="mx-auto text-blue-600 mb-3" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-800">Select Contractor</h3>
|
||||
<p className="text-gray-500 mt-1">Choose a contractor or select all</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto max-h-96 overflow-y-auto">
|
||||
<SelectionCard
|
||||
item={{ id: "all", name: "All Contractors", subtitle: `${filteredContractors.length} contractors` }}
|
||||
isSelected={selectedContractor === "all"}
|
||||
onClick={() => setSelectedContractor("all")}
|
||||
icon={<Users size={20} className={selectedContractor === "all" ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
{filteredContractors.map((contractor) => (
|
||||
<SelectionCard
|
||||
key={contractor.id}
|
||||
item={{ id: contractor.id, name: contractor.name, subtitle: contractor.department_name }}
|
||||
isSelected={selectedContractor === String(contractor.id)}
|
||||
onClick={() => setSelectedContractor(String(contractor.id))}
|
||||
icon={<Users size={20} className={selectedContractor === String(contractor.id) ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<User className="mx-auto text-blue-600 mb-3" size={48} />
|
||||
<h3 className="text-xl font-semibold text-gray-800">Select Employee</h3>
|
||||
<p className="text-gray-500 mt-1">Choose an employee or select all</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto max-h-96 overflow-y-auto">
|
||||
<SelectionCard
|
||||
item={{ id: "all", name: "All Employees", subtitle: `${filteredEmployees.length} employees` }}
|
||||
isSelected={selectedEmployee === "all"}
|
||||
onClick={() => setSelectedEmployee("all")}
|
||||
icon={<User size={20} className={selectedEmployee === "all" ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
{filteredEmployees.map((employee) => (
|
||||
<SelectionCard
|
||||
key={employee.id}
|
||||
item={{ id: employee.id, name: employee.name, subtitle: employee.contractor_name }}
|
||||
isSelected={selectedEmployee === String(employee.id)}
|
||||
onClick={() => setSelectedEmployee(String(employee.id))}
|
||||
icon={<User size={20} className={selectedEmployee === String(employee.id) ? "text-blue-600" : "text-gray-500"} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if can proceed to next step
|
||||
const canProceed = () => {
|
||||
switch (wizardStep) {
|
||||
case 1: return true; // Date range is optional
|
||||
case 2: return selectedDepartment !== null;
|
||||
case 3: return selectedContractor !== null;
|
||||
case 4: return selectedEmployee !== null;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Results view
|
||||
if (showResults) {
|
||||
const { deptName, contractorName, employeeName } = getSelectionSummary();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
@@ -203,164 +392,44 @@ export const ReportingPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="text-green-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
Work Allocation Reports
|
||||
</h2>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Work Allocation Report</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{dateRange.startDate && dateRange.endDate
|
||||
? `${new Date(dateRange.startDate).toLocaleDateString()} - ${new Date(dateRange.endDate).toLocaleDateString()}`
|
||||
: "All Time"} • {deptName} • {contractorName} • {employeeName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={exportFormattedReport}
|
||||
disabled={filteredAllocations.length === 0}
|
||||
>
|
||||
<Download size={16} className="mr-2" />
|
||||
Export Report (XLSX)
|
||||
<Button variant="secondary" onClick={resetWizard}>
|
||||
<ArrowLeft size={16} className="mr-2" />
|
||||
New Report
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={exportSimpleList}
|
||||
disabled={filteredAllocations.length === 0}
|
||||
>
|
||||
<Button onClick={handleExport} disabled={filteredAllocations.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Export List
|
||||
Export to Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
{/* Filters */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter size={18} className="text-gray-500" />
|
||||
<h3 className="font-medium text-gray-700">Filters</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
name="startDate"
|
||||
value={filters.startDate}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
name="endDate"
|
||||
value={filters.endDate}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
label="Department"
|
||||
name="departmentId"
|
||||
value={isSupervisor
|
||||
? String(user?.department_id || "")
|
||||
: filters.departmentId}
|
||||
onChange={handleFilterChange}
|
||||
disabled={isSupervisor}
|
||||
options={[
|
||||
{ value: "", label: "All Departments" },
|
||||
...departments.map((d) => ({
|
||||
value: String(d.id),
|
||||
label: d.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Contractor"
|
||||
name="contractorId"
|
||||
value={isContractor
|
||||
? String(user?.id || "")
|
||||
: filters.contractorId}
|
||||
onChange={handleFilterChange}
|
||||
disabled={isContractor}
|
||||
options={[
|
||||
{ value: "", label: "All Contractors" },
|
||||
...contractors.map((c) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
|
||||
<Select
|
||||
label="Employee"
|
||||
name="employeeId"
|
||||
value={filters.employeeId}
|
||||
onChange={handleFilterChange}
|
||||
options={[
|
||||
{ value: "", label: "All Employees" },
|
||||
...employees.map((e) => ({
|
||||
value: String(e.id),
|
||||
label: e.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Sub-Department"
|
||||
name="subDepartmentId"
|
||||
value={filters.subDepartmentId}
|
||||
onChange={handleFilterChange}
|
||||
options={[
|
||||
{ value: "", label: "All Sub-Departments" },
|
||||
...subDepartments.map((sd) => ({
|
||||
value: String(sd.id),
|
||||
label: sd.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Activity"
|
||||
name="activity"
|
||||
value={filters.activity}
|
||||
onChange={handleFilterChange}
|
||||
options={[
|
||||
{ value: "", label: "All Activities" },
|
||||
...activities.map((a) => ({ value: a, label: a })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button onClick={applyFilters} size="sm">
|
||||
Apply Filters
|
||||
</Button>
|
||||
<Button variant="outline" onClick={clearFilters} size="sm">
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm text-blue-600 font-medium">
|
||||
Total Completed
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-800">
|
||||
{summary.totalAllocations}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600 font-medium">Total Completed</div>
|
||||
<div className="text-2xl font-bold text-blue-800">{summary.totalAllocations}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-sm text-green-600 font-medium">
|
||||
Total Amount
|
||||
</div>
|
||||
<div className="text-sm text-green-600 font-medium">Total Amount</div>
|
||||
<div className="text-2xl font-bold text-green-800">
|
||||
₹{parseFloat(summary.totalAmount).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="text-sm text-purple-600 font-medium">
|
||||
Total Units
|
||||
</div>
|
||||
<div className="text-sm text-purple-600 font-medium">Total Units</div>
|
||||
<div className="text-2xl font-bold text-purple-800">
|
||||
{parseFloat(summary.totalUnits).toLocaleString()}
|
||||
</div>
|
||||
@@ -368,39 +437,32 @@ export const ReportingPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Refresh */}
|
||||
{/* Search */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
||||
size={18}
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by employee, contractor, department..."
|
||||
placeholder="Search results..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={fetchReport}>
|
||||
<Button variant="ghost" onClick={generateReport}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">
|
||||
Error: {error}
|
||||
</div>
|
||||
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">Error: {error}</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading
|
||||
? <div className="text-center py-8">Loading report data...</div>
|
||||
: filteredAllocations.length > 0
|
||||
? (
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading report data...</div>
|
||||
) : filteredAllocations.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -412,73 +474,113 @@ export const ReportingPage: React.FC = () => {
|
||||
<TableHead>Activity</TableHead>
|
||||
<TableHead>Assigned</TableHead>
|
||||
<TableHead>Completed</TableHead>
|
||||
<TableHead>Rate (₹)</TableHead>
|
||||
<TableHead>Actual Rate (₹)</TableHead>
|
||||
<TableHead>Standard Rate (₹)</TableHead>
|
||||
<TableHead>Difference (₹)</TableHead>
|
||||
<TableHead>Units</TableHead>
|
||||
<TableHead>Total (₹)</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAllocations.map((allocation) => {
|
||||
const rate = parseFloat(allocation.rate) || 0;
|
||||
const standardRate = parseFloat(allocation.standard_rate) || 0;
|
||||
const difference = rate - standardRate;
|
||||
const units = parseFloat(allocation.units) || 0;
|
||||
const total = parseFloat(allocation.total_amount) || rate;
|
||||
|
||||
return (
|
||||
<TableRow key={allocation.id}>
|
||||
<TableCell>{allocation.id}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{allocation.employee_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{allocation.employee_name || "-"}</TableCell>
|
||||
<TableCell>{allocation.contractor_name || "-"}</TableCell>
|
||||
<TableCell>{allocation.department_name || "-"}</TableCell>
|
||||
<TableCell>{allocation.sub_department_name || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{allocation.contractor_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{allocation.department_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{allocation.sub_department_name || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
allocation.activity === "Loading" ||
|
||||
allocation.activity === "Unloading"
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
allocation.activity === "Loading" || allocation.activity === "Unloading"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
}`}>
|
||||
{allocation.activity || "Standard"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(allocation.assigned_date)
|
||||
.toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{allocation.completion_date
|
||||
? new Date(allocation.completion_date)
|
||||
.toLocaleDateString()
|
||||
: "-"}
|
||||
{allocation.completion_date ? new Date(allocation.completion_date).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
<TableCell>₹{rate.toFixed(2)}</TableCell>
|
||||
<TableCell>{units > 0 ? units : "-"}</TableCell>
|
||||
<TableCell className="font-semibold text-green-600">
|
||||
₹{total.toFixed(2)}
|
||||
<TableCell>{standardRate > 0 ? `₹${standardRate.toFixed(2)}` : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{standardRate > 0 ? (
|
||||
<span className={`font-medium ${
|
||||
difference > 0 ? "text-red-600" : difference < 0 ? "text-green-600" : "text-gray-600"
|
||||
}`}>
|
||||
{difference > 0 ? "+" : ""}₹{difference.toFixed(2)}
|
||||
</span>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>{units > 0 ? units : "-"}</TableCell>
|
||||
<TableCell className="font-semibold text-green-600">₹{total.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No completed work allocations found. Adjust your filters or
|
||||
check back later.
|
||||
No completed work allocations found for the selected criteria.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Wizard view
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="text-green-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-800">Generate Work Allocation Report</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="py-8">
|
||||
<StepIndicator />
|
||||
|
||||
{renderWizardContent()}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8 max-w-3xl mx-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setWizardStep((prev) => Math.max(1, prev - 1) as WizardStep)}
|
||||
disabled={wizardStep === 1}
|
||||
>
|
||||
<ArrowLeft size={16} className="mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{wizardStep < 4 ? (
|
||||
<Button onClick={() => setWizardStep((prev) => (prev + 1) as WizardStep)} disabled={!canProceed()}>
|
||||
Next
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={generateReport} disabled={!canProceed() || loading}>
|
||||
{loading ? "Generating..." : "Generate Report"}
|
||||
<FileSpreadsheet size={16} className="ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -324,3 +324,150 @@ export const exportAllocationsToXLSX = (
|
||||
// Write and download
|
||||
XLSX.writeFile(wb, outputFilename);
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined export function with improved formatting
|
||||
* Includes standard rate and difference columns
|
||||
*/
|
||||
export const exportReportToXLSX = (
|
||||
allocations: AllocationData[],
|
||||
departmentName: string,
|
||||
dateRange: { startDate: string; endDate: string },
|
||||
) => {
|
||||
if (allocations.length === 0) {
|
||||
alert("No data to export");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create workbook
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// ===== Sheet 1: Detailed Report =====
|
||||
const detailedData = allocations.map((a, index) => {
|
||||
const rate = parseFloat(String(a.rate)) || 0;
|
||||
const standardRate = parseFloat(String((a as any).standard_rate)) || 0;
|
||||
const difference = standardRate > 0 ? rate - standardRate : 0;
|
||||
const units = parseFloat(String(a.units)) || 0;
|
||||
const total = parseFloat(String(a.total_amount)) || rate;
|
||||
|
||||
return {
|
||||
"S.No": index + 1,
|
||||
"Employee": a.employee_name || "",
|
||||
"Contractor": a.contractor_name || "",
|
||||
"Department": a.department_name || "",
|
||||
"Sub-Department": a.sub_department_name || "",
|
||||
"Activity": a.activity || "Standard",
|
||||
"Assigned Date": a.assigned_date
|
||||
? new Date(a.assigned_date).toLocaleDateString()
|
||||
: "",
|
||||
"Completed Date": a.completion_date
|
||||
? new Date(a.completion_date).toLocaleDateString()
|
||||
: "",
|
||||
"Actual Rate (₹)": rate,
|
||||
"Standard Rate (₹)": standardRate > 0 ? standardRate : "-",
|
||||
"Difference (₹)": standardRate > 0 ? difference : "-",
|
||||
"Units": units > 0 ? units : "-",
|
||||
"Total Amount (₹)": total,
|
||||
};
|
||||
});
|
||||
|
||||
const ws1 = XLSX.utils.json_to_sheet(detailedData);
|
||||
|
||||
// Set column widths for detailed sheet
|
||||
ws1["!cols"] = [
|
||||
{ wch: 6 }, // S.No
|
||||
{ wch: 22 }, // Employee
|
||||
{ wch: 20 }, // Contractor
|
||||
{ wch: 15 }, // Department
|
||||
{ wch: 18 }, // Sub-Department
|
||||
{ wch: 15 }, // Activity
|
||||
{ wch: 12 }, // Assigned Date
|
||||
{ wch: 14 }, // Completed Date
|
||||
{ wch: 14 }, // Actual Rate
|
||||
{ wch: 16 }, // Standard Rate
|
||||
{ wch: 14 }, // Difference
|
||||
{ wch: 8 }, // Units
|
||||
{ wch: 16 }, // Total Amount
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws1, "Detailed Report");
|
||||
|
||||
// ===== Sheet 2: Summary by Activity =====
|
||||
const activitySummary = new Map<string, { count: number; totalAmount: number; totalUnits: number }>();
|
||||
|
||||
allocations.forEach((a) => {
|
||||
const activity = a.activity || "Standard";
|
||||
const existing = activitySummary.get(activity) || { count: 0, totalAmount: 0, totalUnits: 0 };
|
||||
existing.count += 1;
|
||||
existing.totalAmount += parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0;
|
||||
existing.totalUnits += parseFloat(String(a.units)) || 0;
|
||||
activitySummary.set(activity, existing);
|
||||
});
|
||||
|
||||
const summaryData = Array.from(activitySummary.entries()).map(([activity, data]) => ({
|
||||
"Activity": activity,
|
||||
"Total Completed": data.count,
|
||||
"Total Units": data.totalUnits,
|
||||
"Total Amount (₹)": data.totalAmount.toFixed(2),
|
||||
}));
|
||||
|
||||
// Add grand total row
|
||||
const grandTotal = {
|
||||
"Activity": "GRAND TOTAL",
|
||||
"Total Completed": allocations.length,
|
||||
"Total Units": allocations.reduce((sum, a) => sum + (parseFloat(String(a.units)) || 0), 0),
|
||||
"Total Amount (₹)": allocations.reduce((sum, a) =>
|
||||
sum + (parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0), 0
|
||||
).toFixed(2),
|
||||
};
|
||||
summaryData.push(grandTotal);
|
||||
|
||||
const ws2 = XLSX.utils.json_to_sheet(summaryData);
|
||||
ws2["!cols"] = [
|
||||
{ wch: 25 }, // Activity
|
||||
{ wch: 16 }, // Total Completed
|
||||
{ wch: 12 }, // Total Units
|
||||
{ wch: 18 }, // Total Amount
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws2, "Summary by Activity");
|
||||
|
||||
// ===== Sheet 3: Summary by Contractor =====
|
||||
const contractorSummary = new Map<string, { count: number; totalAmount: number; employees: Set<string> }>();
|
||||
|
||||
allocations.forEach((a) => {
|
||||
const contractor = a.contractor_name || "Unknown";
|
||||
const existing = contractorSummary.get(contractor) || { count: 0, totalAmount: 0, employees: new Set() };
|
||||
existing.count += 1;
|
||||
existing.totalAmount += parseFloat(String(a.total_amount)) || parseFloat(String(a.rate)) || 0;
|
||||
if (a.employee_name) existing.employees.add(a.employee_name);
|
||||
contractorSummary.set(contractor, existing);
|
||||
});
|
||||
|
||||
const contractorData = Array.from(contractorSummary.entries()).map(([contractor, data]) => ({
|
||||
"Contractor": contractor,
|
||||
"Employees": data.employees.size,
|
||||
"Total Completed": data.count,
|
||||
"Total Amount (₹)": data.totalAmount.toFixed(2),
|
||||
}));
|
||||
|
||||
const ws3 = XLSX.utils.json_to_sheet(contractorData);
|
||||
ws3["!cols"] = [
|
||||
{ wch: 25 }, // Contractor
|
||||
{ wch: 12 }, // Employees
|
||||
{ wch: 16 }, // Total Completed
|
||||
{ wch: 18 }, // Total Amount
|
||||
];
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws3, "Summary by Contractor");
|
||||
|
||||
// Generate filename
|
||||
const dateStr = dateRange.startDate && dateRange.endDate
|
||||
? `${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`;
|
||||
|
||||
// Write and download
|
||||
XLSX.writeFile(wb, filename);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user