(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,
|
c.name as contractor_name,
|
||||||
sd.name as sub_department_name,
|
sd.name as sub_department_name,
|
||||||
d.name as 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
|
FROM work_allocations wa
|
||||||
JOIN users e ON wa.employee_id = e.id
|
JOIN users e ON wa.employee_id = e.id
|
||||||
JOIN users s ON wa.supervisor_id = s.id
|
JOIN users s ON wa.supervisor_id = s.id
|
||||||
JOIN users c ON wa.contractor_id = c.id
|
JOIN users c ON wa.contractor_id = c.id
|
||||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.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 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'
|
WHERE wa.status = 'Completed'
|
||||||
`;
|
`;
|
||||||
const queryParams: unknown[] = [];
|
const queryParams: unknown[] = [];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>my-dashboard</title>
|
<title>WorkAllocation</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export const RatesPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
<div className="flex justify-end gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab("list");
|
setActiveTab("list");
|
||||||
resetForm();
|
resetForm();
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
CheckCircle2,
|
||||||
Download,
|
Download,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Filter,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "../components/ui/Card.tsx";
|
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -16,87 +23,99 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "../components/ui/Table.tsx";
|
} from "../components/ui/Table.tsx";
|
||||||
import { Button } from "../components/ui/Button.tsx";
|
import { Button } from "../components/ui/Button.tsx";
|
||||||
import { Input, Select } from "../components/ui/Input.tsx";
|
|
||||||
import { api } from "../services/api.ts";
|
import { api } from "../services/api.ts";
|
||||||
import { useDepartments } from "../hooks/useDepartments.ts";
|
import { useDepartments } from "../hooks/useDepartments.ts";
|
||||||
import { useAuth } from "../contexts/authContext.ts";
|
import { useAuth } from "../contexts/authContext.ts";
|
||||||
import {
|
import { exportReportToXLSX } from "../utils/excelExport.ts";
|
||||||
exportAllocationsToXLSX,
|
|
||||||
exportWorkReportToXLSX,
|
type WizardStep = 1 | 2 | 3 | 4;
|
||||||
} from "../utils/excelExport.ts";
|
|
||||||
|
interface SelectionCard {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ReportingPage: React.FC = () => {
|
export const ReportingPage: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { departments } = useDepartments();
|
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 [allocations, setAllocations] = useState<any[]>([]);
|
||||||
const [summary, setSummary] = useState<
|
const [summary, setSummary] = useState<{
|
||||||
{ totalAllocations: number; totalAmount: string; totalUnits: string } | null
|
totalAllocations: number;
|
||||||
>(null);
|
totalAmount: string;
|
||||||
|
totalUnits: string;
|
||||||
|
} | null>(null);
|
||||||
const [contractors, setContractors] = useState<any[]>([]);
|
const [contractors, setContractors] = useState<any[]>([]);
|
||||||
const [employees, setEmployees] = useState<any[]>([]);
|
const [employees, setEmployees] = useState<any[]>([]);
|
||||||
const [subDepartments, setSubDepartments] = useState<any[]>([]);
|
|
||||||
const [activities, setActivities] = useState<string[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
startDate: "",
|
|
||||||
endDate: "",
|
|
||||||
departmentId: "",
|
|
||||||
contractorId: "",
|
|
||||||
employeeId: "",
|
|
||||||
subDepartmentId: "",
|
|
||||||
activity: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSupervisor = user?.role === "Supervisor";
|
const isSupervisor = user?.role === "Supervisor";
|
||||||
const isContractor = user?.role === "Contractor";
|
const isContractor = user?.role === "Contractor";
|
||||||
|
|
||||||
// Fetch filter options
|
// Fetch contractors and employees
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getUsers({ role: "Contractor" }).then(setContractors).catch(
|
api.getUsers({ role: "Contractor" }).then(setContractors).catch(console.error);
|
||||||
console.error,
|
|
||||||
);
|
|
||||||
api.getUsers({ role: "Employee" }).then(setEmployees).catch(console.error);
|
api.getUsers({ role: "Employee" }).then(setEmployees).catch(console.error);
|
||||||
api.getAllSubDepartments().then(setSubDepartments).catch(console.error);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch report data
|
// Filter contractors by department
|
||||||
const fetchReport = async () => {
|
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);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const params: any = {};
|
const params: Record<string, any> = {};
|
||||||
if (filters.startDate) params.startDate = filters.startDate;
|
|
||||||
if (filters.endDate) params.endDate = filters.endDate;
|
|
||||||
|
|
||||||
// Department filter - use user's department if Supervisor, otherwise use filter
|
if (dateRange.startDate) params.startDate = dateRange.startDate;
|
||||||
const deptId = isSupervisor
|
if (dateRange.endDate) params.endDate = dateRange.endDate;
|
||||||
? user?.department_id
|
|
||||||
: (filters.departmentId ? parseInt(filters.departmentId) : null);
|
|
||||||
if (deptId) params.departmentId = deptId;
|
|
||||||
|
|
||||||
// Contractor filter - use user's id if Contractor, otherwise use filter
|
// Use user's department if Supervisor
|
||||||
const contractorIdValue = isContractor
|
if (isSupervisor && user?.department_id) {
|
||||||
? user?.id
|
params.departmentId = user.department_id;
|
||||||
: (filters.contractorId ? parseInt(filters.contractorId) : null);
|
} else if (selectedDepartment && selectedDepartment !== "all") {
|
||||||
if (contractorIdValue) params.contractorId = contractorIdValue;
|
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);
|
const data = await api.getCompletedAllocationsReport(params);
|
||||||
setAllocations(data.allocations);
|
setAllocations(data.allocations);
|
||||||
setSummary(data.summary);
|
setSummary(data.summary);
|
||||||
|
setShowResults(true);
|
||||||
// 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);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to fetch report");
|
setError(err.message || "Failed to fetch report");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,303 +123,346 @@ export const ReportingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// Filter allocations by search
|
||||||
fetchReport();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Filter allocations based on search and dropdown filters
|
|
||||||
const filteredAllocations = useMemo(() => {
|
const filteredAllocations = useMemo(() => {
|
||||||
let result = allocations;
|
if (!searchQuery) return allocations;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
// Apply search filter
|
return allocations.filter((a) =>
|
||||||
if (searchQuery) {
|
a.employee_name?.toLowerCase().includes(query) ||
|
||||||
const query = searchQuery.toLowerCase();
|
a.contractor_name?.toLowerCase().includes(query) ||
|
||||||
result = result.filter((a) =>
|
a.sub_department_name?.toLowerCase().includes(query) ||
|
||||||
a.employee_name?.toLowerCase().includes(query) ||
|
a.activity?.toLowerCase().includes(query) ||
|
||||||
a.contractor_name?.toLowerCase().includes(query) ||
|
a.department_name?.toLowerCase().includes(query)
|
||||||
a.sub_department_name?.toLowerCase().includes(query) ||
|
|
||||||
a.activity?.toLowerCase().includes(query) ||
|
|
||||||
a.department_name?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sub-department filter (client-side)
|
|
||||||
if (filters.subDepartmentId) {
|
|
||||||
result = result.filter((a) =>
|
|
||||||
a.sub_department_id === parseInt(filters.subDepartmentId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply activity filter (client-side)
|
|
||||||
if (filters.activity) {
|
|
||||||
result = result.filter((a) => a.activity === filters.activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = () => {
|
|
||||||
if (filteredAllocations.length === 0) {
|
|
||||||
alert("No data to export");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
exportWorkReportToXLSX(
|
|
||||||
filteredAllocations,
|
|
||||||
selectedDeptName,
|
|
||||||
{ startDate: filters.startDate, endDate: filters.endDate },
|
|
||||||
);
|
);
|
||||||
|
}, [allocations, searchQuery]);
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
return { deptName, contractorName, employeeName };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export to Excel (XLSX format) - Simple List
|
// Export to Excel
|
||||||
const exportSimpleList = () => {
|
const handleExport = () => {
|
||||||
if (filteredAllocations.length === 0) {
|
if (filteredAllocations.length === 0) {
|
||||||
alert("No data to export");
|
alert("No data to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { deptName } = getSelectionSummary();
|
||||||
exportAllocationsToXLSX(filteredAllocations);
|
exportReportToXLSX(filteredAllocations, deptName, dateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterChange = (
|
// Reset wizard
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
const resetWizard = () => {
|
||||||
) => {
|
setWizardStep(1);
|
||||||
const { name, value } = e.target;
|
setShowResults(false);
|
||||||
setFilters((prev) => ({ ...prev, [name]: value }));
|
setDateRange({ startDate: "", endDate: "" });
|
||||||
|
setSelectedDepartment(null);
|
||||||
|
setSelectedContractor(null);
|
||||||
|
setSelectedEmployee(null);
|
||||||
|
setAllocations([]);
|
||||||
|
setSummary(null);
|
||||||
|
setSearchQuery("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyFilters = () => {
|
// Selection card component
|
||||||
fetchReport();
|
const SelectionCard = ({
|
||||||
};
|
item,
|
||||||
|
isSelected,
|
||||||
const clearFilters = () => {
|
onClick,
|
||||||
setFilters({
|
icon,
|
||||||
startDate: "",
|
}: {
|
||||||
endDate: "",
|
item: SelectionCard;
|
||||||
departmentId: "",
|
isSelected: boolean;
|
||||||
contractorId: "",
|
onClick: () => void;
|
||||||
employeeId: "",
|
icon: React.ReactNode;
|
||||||
subDepartmentId: "",
|
}) => (
|
||||||
activity: "",
|
<button
|
||||||
});
|
type="button"
|
||||||
setTimeout(fetchReport, 0);
|
onClick={onClick}
|
||||||
};
|
className={`p-4 rounded-lg border-2 transition-all text-left w-full ${
|
||||||
|
isSelected
|
||||||
return (
|
? "border-blue-500 bg-blue-50 ring-2 ring-blue-200"
|
||||||
<div className="p-6">
|
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
<Card>
|
}`}
|
||||||
<div className="border-b border-gray-200 px-6 py-4">
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className={`p-2 rounded-lg ${isSelected ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||||
<FileSpreadsheet className="text-green-600" size={24} />
|
{icon}
|
||||||
<h2 className="text-xl font-semibold text-gray-800">
|
|
||||||
Work Allocation Reports
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={exportFormattedReport}
|
|
||||||
disabled={filteredAllocations.length === 0}
|
|
||||||
>
|
|
||||||
<Download size={16} className="mr-2" />
|
|
||||||
Export Report (XLSX)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={exportSimpleList}
|
|
||||||
disabled={filteredAllocations.length === 0}
|
|
||||||
>
|
|
||||||
<Download size={16} className="mr-2" />
|
|
||||||
Export List
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
|
||||||
<CardContent>
|
// Step indicator
|
||||||
{/* Filters */}
|
const StepIndicator = () => (
|
||||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
{[1, 2, 3, 4].map((step) => (
|
||||||
<Filter size={18} className="text-gray-500" />
|
<React.Fragment key={step}>
|
||||||
<h3 className="font-medium text-gray-700">Filters</h3>
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="max-w-md mx-auto space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
|
||||||
Start Date
|
<input
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
type="date"
|
||||||
name="startDate"
|
value={dateRange.startDate}
|
||||||
value={filters.startDate}
|
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
|
||||||
onChange={handleFilterChange}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-2">End Date</label>
|
||||||
End Date
|
<input
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
type="date"
|
||||||
name="endDate"
|
value={dateRange.endDate}
|
||||||
value={filters.endDate}
|
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
|
||||||
onChange={handleFilterChange}
|
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>
|
||||||
<Select
|
<button
|
||||||
label="Department"
|
type="button"
|
||||||
name="departmentId"
|
onClick={() => {
|
||||||
value={isSupervisor
|
const today = new Date();
|
||||||
? String(user?.department_id || "")
|
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
: filters.departmentId}
|
setDateRange({
|
||||||
onChange={handleFilterChange}
|
startDate: firstDay.toISOString().split("T")[0],
|
||||||
disabled={isSupervisor}
|
endDate: today.toISOString().split("T")[0],
|
||||||
options={[
|
});
|
||||||
{ value: "", label: "All Departments" },
|
}}
|
||||||
...departments.map((d) => ({
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
value: String(d.id),
|
>
|
||||||
label: d.name,
|
Use Current Month
|
||||||
})),
|
</button>
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
|
</div>
|
||||||
<Select
|
);
|
||||||
label="Employee"
|
|
||||||
name="employeeId"
|
case 2:
|
||||||
value={filters.employeeId}
|
return (
|
||||||
onChange={handleFilterChange}
|
<div className="space-y-6">
|
||||||
options={[
|
<div className="text-center">
|
||||||
{ value: "", label: "All Employees" },
|
<Building2 className="mx-auto text-blue-600 mb-3" size={48} />
|
||||||
...employees.map((e) => ({
|
<h3 className="text-xl font-semibold text-gray-800">Select Department</h3>
|
||||||
value: String(e.id),
|
<p className="text-gray-500 mt-1">Choose a department or select all</p>
|
||||||
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>
|
||||||
<div className="flex gap-2 mt-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-3xl mx-auto">
|
||||||
<Button onClick={applyFilters} size="sm">
|
<SelectionCard
|
||||||
Apply Filters
|
item={{ id: "all", name: "All Departments", subtitle: `${departments.length} departments` }}
|
||||||
</Button>
|
isSelected={selectedDepartment === "all"}
|
||||||
<Button variant="outline" onClick={clearFilters} size="sm">
|
onClick={() => setSelectedDepartment("all")}
|
||||||
Clear
|
icon={<Building2 size={20} className={selectedDepartment === "all" ? "text-blue-600" : "text-gray-500"} />}
|
||||||
</Button>
|
/>
|
||||||
|
{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>
|
||||||
|
<div className="border-b border-gray-200 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileSpreadsheet className="text-green-600" size={24} />
|
||||||
|
<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 variant="secondary" onClick={resetWizard}>
|
||||||
|
<ArrowLeft size={16} className="mr-2" />
|
||||||
|
New Report
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={filteredAllocations.length === 0}>
|
||||||
|
<Download size={16} className="mr-2" />
|
||||||
|
Export to Excel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<CardContent>
|
||||||
{summary && (
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
{summary && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
<div className="text-sm text-blue-600 font-medium">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
Total Completed
|
<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>
|
||||||
<div className="text-2xl font-bold text-blue-800">
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
{summary.totalAllocations}
|
<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-2xl font-bold text-purple-800">
|
||||||
|
{parseFloat(summary.totalUnits).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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
|
{/* Search */}
|
||||||
</div>
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="text-2xl font-bold text-green-800">
|
<div className="relative flex-1">
|
||||||
₹{parseFloat(summary.totalAmount).toLocaleString()}
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||||
</div>
|
<input
|
||||||
</div>
|
type="text"
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
placeholder="Search results..."
|
||||||
<div className="text-sm text-purple-600 font-medium">
|
value={searchQuery}
|
||||||
Total Units
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</div>
|
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 className="text-2xl font-bold text-purple-800">
|
/>
|
||||||
{parseFloat(summary.totalUnits).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="ghost" onClick={generateReport}>
|
||||||
|
<RefreshCw size={16} className="mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search and Refresh */}
|
{error && (
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">Error: {error}</div>
|
||||||
<div className="relative flex-1">
|
)}
|
||||||
<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..."
|
|
||||||
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}>
|
|
||||||
<RefreshCw size={16} className="mr-2" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
{/* Table */}
|
||||||
{error && (
|
{loading ? (
|
||||||
<div className="text-center py-4 text-red-600 bg-red-50 rounded-md mb-4">
|
<div className="text-center py-8">Loading report data...</div>
|
||||||
Error: {error}
|
) : filteredAllocations.length > 0 ? (
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{loading
|
|
||||||
? <div className="text-center py-8">Loading report data...</div>
|
|
||||||
: filteredAllocations.length > 0
|
|
||||||
? (
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -412,71 +474,111 @@ export const ReportingPage: React.FC = () => {
|
|||||||
<TableHead>Activity</TableHead>
|
<TableHead>Activity</TableHead>
|
||||||
<TableHead>Assigned</TableHead>
|
<TableHead>Assigned</TableHead>
|
||||||
<TableHead>Completed</TableHead>
|
<TableHead>Completed</TableHead>
|
||||||
<TableHead>Rate (₹)</TableHead>
|
<TableHead>Actual Rate (₹)</TableHead>
|
||||||
|
<TableHead>Standard Rate (₹)</TableHead>
|
||||||
|
<TableHead>Difference (₹)</TableHead>
|
||||||
<TableHead>Units</TableHead>
|
<TableHead>Units</TableHead>
|
||||||
<TableHead>Total (₹)</TableHead>
|
<TableHead>Total (₹)</TableHead>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredAllocations.map((allocation) => {
|
{filteredAllocations.map((allocation) => {
|
||||||
const rate = parseFloat(allocation.rate) || 0;
|
const rate = parseFloat(allocation.rate) || 0;
|
||||||
|
const standardRate = parseFloat(allocation.standard_rate) || 0;
|
||||||
|
const difference = rate - standardRate;
|
||||||
const units = parseFloat(allocation.units) || 0;
|
const units = parseFloat(allocation.units) || 0;
|
||||||
const total = parseFloat(allocation.total_amount) || rate;
|
const total = parseFloat(allocation.total_amount) || rate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={allocation.id}>
|
<TableRow key={allocation.id}>
|
||||||
<TableCell>{allocation.id}</TableCell>
|
<TableCell>{allocation.id}</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">{allocation.employee_name || "-"}</TableCell>
|
||||||
{allocation.employee_name || "-"}
|
<TableCell>{allocation.contractor_name || "-"}</TableCell>
|
||||||
</TableCell>
|
<TableCell>{allocation.department_name || "-"}</TableCell>
|
||||||
|
<TableCell>{allocation.sub_department_name || "-"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{allocation.contractor_name || "-"}
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
</TableCell>
|
allocation.activity === "Loading" || allocation.activity === "Unloading"
|
||||||
<TableCell>
|
? "bg-purple-100 text-purple-700"
|
||||||
{allocation.department_name || "-"}
|
: "bg-gray-100 text-gray-700"
|
||||||
</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"
|
|
||||||
? "bg-purple-100 text-purple-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{allocation.activity || "Standard"}
|
{allocation.activity || "Standard"}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(allocation.assigned_date)
|
{allocation.completion_date ? new Date(allocation.completion_date).toLocaleDateString() : "-"}
|
||||||
.toLocaleDateString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{allocation.completion_date
|
|
||||||
? new Date(allocation.completion_date)
|
|
||||||
.toLocaleDateString()
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>₹{rate.toFixed(2)}</TableCell>
|
<TableCell>₹{rate.toFixed(2)}</TableCell>
|
||||||
<TableCell>{units > 0 ? units : "-"}</TableCell>
|
<TableCell>{standardRate > 0 ? `₹${standardRate.toFixed(2)}` : "-"}</TableCell>
|
||||||
<TableCell className="font-semibold text-green-600">
|
<TableCell>
|
||||||
₹{total.toFixed(2)}
|
{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>
|
||||||
|
<TableCell>{units > 0 ? units : "-"}</TableCell>
|
||||||
|
<TableCell className="font-semibold text-green-600">₹{total.toFixed(2)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)
|
) : (
|
||||||
: (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-8 text-gray-500">
|
||||||
No completed work allocations found. Adjust your filters or
|
No completed work allocations found for the selected criteria.
|
||||||
check back later.
|
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -324,3 +324,150 @@ export const exportAllocationsToXLSX = (
|
|||||||
// Write and download
|
// Write and download
|
||||||
XLSX.writeFile(wb, outputFilename);
|
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