512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
import React, { useEffect, useMemo, useState } from "react";
|
||
import { DollarSign, Edit, RefreshCw, Search, Trash2 } from "lucide-react";
|
||
import { Card, CardContent } from "../components/ui/Card.tsx";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
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, useSubDepartments } from "../hooks/useDepartments.ts";
|
||
import { useActivities } from "../hooks/useActivities.ts";
|
||
import { useAuth } from "../contexts/authContext.ts";
|
||
|
||
export const RatesPage: React.FC = () => {
|
||
const [activeTab, setActiveTab] = useState<"list" | "add">("list");
|
||
const { user } = useAuth();
|
||
const { departments } = useDepartments();
|
||
const [rates, setRates] = useState<any[]>([]);
|
||
const [contractors, setContractors] = useState<any[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
// Form state
|
||
const [formData, setFormData] = useState({
|
||
contractorId: "",
|
||
subDepartmentId: "",
|
||
activity: "",
|
||
rate: "",
|
||
effectiveDate: new Date().toISOString().split("T")[0],
|
||
});
|
||
const [selectedDept, setSelectedDept] = useState("");
|
||
const { subDepartments } = useSubDepartments(selectedDept);
|
||
const { activities } = useActivities(formData.subDepartmentId);
|
||
const [formError, setFormError] = useState("");
|
||
const [formLoading, setFormLoading] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
|
||
// Edit mode
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
|
||
// Fetch rates
|
||
const fetchRates = async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const data = await api.getContractorRates();
|
||
setRates(data);
|
||
} catch (err: any) {
|
||
setError(err.message || "Failed to fetch rates");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Fetch contractors
|
||
const fetchContractors = async () => {
|
||
try {
|
||
const data = await api.getUsers({ role: "Contractor" });
|
||
setContractors(data);
|
||
} catch (err) {
|
||
console.error("Failed to fetch contractors:", err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchRates();
|
||
fetchContractors();
|
||
}, []);
|
||
|
||
// Auto-select department for supervisors
|
||
useEffect(() => {
|
||
if (user?.role === "Supervisor" && user?.department_id) {
|
||
setSelectedDept(String(user.department_id));
|
||
}
|
||
}, [user]);
|
||
|
||
const handleInputChange = (
|
||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||
) => {
|
||
const { name, value } = e.target;
|
||
|
||
// Auto-select department when contractor is selected
|
||
if (name === "contractorId" && value) {
|
||
const selectedContractor = contractors.find((c) =>
|
||
String(c.id) === value
|
||
);
|
||
if (selectedContractor?.department_id) {
|
||
setSelectedDept(String(selectedContractor.department_id));
|
||
// Clear sub-department and activity when contractor changes
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[name]: value,
|
||
subDepartmentId: "",
|
||
activity: "",
|
||
}));
|
||
} else {
|
||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||
}
|
||
} // Clear activity when sub-department changes
|
||
else if (name === "subDepartmentId") {
|
||
setFormData((prev) => ({ ...prev, [name]: value, activity: "" }));
|
||
} else {
|
||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||
}
|
||
setFormError("");
|
||
};
|
||
|
||
const resetForm = () => {
|
||
setFormData({
|
||
contractorId: "",
|
||
subDepartmentId: "",
|
||
activity: "",
|
||
rate: "",
|
||
effectiveDate: new Date().toISOString().split("T")[0],
|
||
});
|
||
setEditingId(null);
|
||
setFormError("");
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
|
||
setFormError("Contractor, rate, and effective date are required");
|
||
return;
|
||
}
|
||
|
||
setFormLoading(true);
|
||
setFormError("");
|
||
|
||
try {
|
||
if (editingId) {
|
||
await api.updateContractorRate(editingId, {
|
||
rate: parseFloat(formData.rate),
|
||
activity: formData.activity || undefined,
|
||
effectiveDate: formData.effectiveDate,
|
||
});
|
||
} else {
|
||
await api.setContractorRate({
|
||
contractorId: parseInt(formData.contractorId),
|
||
subDepartmentId: formData.subDepartmentId
|
||
? parseInt(formData.subDepartmentId)
|
||
: undefined,
|
||
activity: formData.activity || undefined,
|
||
rate: parseFloat(formData.rate),
|
||
effectiveDate: formData.effectiveDate,
|
||
});
|
||
}
|
||
|
||
resetForm();
|
||
setActiveTab("list");
|
||
fetchRates();
|
||
} catch (err: any) {
|
||
setFormError(err.message || "Failed to save rate");
|
||
} finally {
|
||
setFormLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleEdit = (rate: any) => {
|
||
setFormData({
|
||
contractorId: String(rate.contractor_id),
|
||
subDepartmentId: rate.sub_department_id
|
||
? String(rate.sub_department_id)
|
||
: "",
|
||
activity: rate.activity || "",
|
||
rate: String(rate.rate),
|
||
effectiveDate: rate.effective_date?.split("T")[0] ||
|
||
new Date().toISOString().split("T")[0],
|
||
});
|
||
setEditingId(rate.id);
|
||
setActiveTab("add");
|
||
};
|
||
|
||
const handleDelete = async (id: number) => {
|
||
if (!confirm("Are you sure you want to delete this rate?")) return;
|
||
try {
|
||
await api.deleteContractorRate(id);
|
||
fetchRates();
|
||
} catch (err: any) {
|
||
alert(err.message || "Failed to delete rate");
|
||
}
|
||
};
|
||
|
||
const canManageRates = user?.role === "SuperAdmin" ||
|
||
user?.role === "Supervisor";
|
||
|
||
// Filter rates based on search
|
||
const filteredRates = useMemo(() => {
|
||
if (!searchQuery) return rates;
|
||
const query = searchQuery.toLowerCase();
|
||
return rates.filter((rate) =>
|
||
rate.contractor_name?.toLowerCase().includes(query) ||
|
||
rate.sub_department_name?.toLowerCase().includes(query) ||
|
||
rate.activity?.toLowerCase().includes(query)
|
||
);
|
||
}, [rates, searchQuery]);
|
||
|
||
return (
|
||
<div className="p-6">
|
||
<Card>
|
||
<div className="border-b border-gray-200">
|
||
<div className="flex space-x-8 px-6">
|
||
<button
|
||
onClick={() => {
|
||
setActiveTab("list");
|
||
resetForm();
|
||
}}
|
||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||
activeTab === "list"
|
||
? "border-blue-500 text-blue-600"
|
||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||
}`}
|
||
>
|
||
Rate List
|
||
</button>
|
||
{canManageRates && (
|
||
<button
|
||
onClick={() => setActiveTab("add")}
|
||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||
activeTab === "add"
|
||
? "border-blue-500 text-blue-600"
|
||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||
}`}
|
||
>
|
||
{editingId ? "Edit Rate" : "Add Rate"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<CardContent>
|
||
{activeTab === "list" && (
|
||
<div>
|
||
<div className="flex gap-4 mb-4">
|
||
<div className="relative min-w-[300px] 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 contractor, sub-department, activity..."
|
||
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={fetchRates}>
|
||
<RefreshCw size={16} className="mr-2" />
|
||
Refresh
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="mb-4 text-sm text-gray-600">
|
||
Total Rates: {filteredRates.length}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="text-center py-8 text-red-600">
|
||
Error: {error}
|
||
</div>
|
||
)}
|
||
|
||
{loading
|
||
? <div className="text-center py-8">Loading rates...</div>
|
||
: filteredRates.length > 0
|
||
? (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableHead>Contractor</TableHead>
|
||
<TableHead>Sub-Department</TableHead>
|
||
<TableHead>Activity</TableHead>
|
||
<TableHead>Rate Type</TableHead>
|
||
<TableHead>Rate (₹)</TableHead>
|
||
<TableHead>Effective Date</TableHead>
|
||
{canManageRates && <TableHead>Actions</TableHead>}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredRates.map((rate) => (
|
||
<TableRow key={rate.id}>
|
||
<TableCell className="font-medium">
|
||
{rate.contractor_name}
|
||
</TableCell>
|
||
<TableCell>
|
||
{rate.sub_department_name || "-"}
|
||
</TableCell>
|
||
<TableCell>
|
||
<span
|
||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||
rate.unit_of_measurement === "Per Bag"
|
||
? "bg-blue-100 text-blue-700"
|
||
: "bg-gray-100 text-gray-700"
|
||
}`}
|
||
>
|
||
{rate.activity || "Standard"}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-xs text-gray-500">
|
||
{rate.unit_of_measurement === "Per Bag"
|
||
? "Per Unit"
|
||
: "Flat Rate"}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-green-600 font-semibold">
|
||
₹{rate.rate}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
{new Date(rate.effective_date).toLocaleDateString()}
|
||
</TableCell>
|
||
{canManageRates && (
|
||
<TableCell>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleEdit(rate)}
|
||
className="text-blue-600"
|
||
title="Edit"
|
||
>
|
||
<Edit size={14} />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleDelete(rate.id)}
|
||
className="text-red-600"
|
||
title="Delete"
|
||
>
|
||
<Trash2 size={14} />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
)}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)
|
||
: (
|
||
<div className="text-center py-8 text-gray-500">
|
||
{searchQuery
|
||
? "No matching rates found"
|
||
: "No rates configured yet. Add one to get started!"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === "add" && canManageRates && (
|
||
<div className="max-w-2xl space-y-6">
|
||
<h3 className="text-lg font-semibold text-gray-800">
|
||
{editingId ? "Edit Rate" : "Add New Rate"}
|
||
</h3>
|
||
|
||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||
<h4 className="font-medium text-blue-800 mb-2">
|
||
Rate Calculation Info
|
||
</h4>
|
||
<ul className="text-sm text-blue-700 space-y-1">
|
||
<li>
|
||
<strong>Per Bag Activities:</strong>{" "}
|
||
Total = Units × Rate per Unit
|
||
</li>
|
||
<li>
|
||
<strong>Fixed Rate Activities:</strong>{" "}
|
||
Total = Flat Rate (no unit calculation)
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
{formError && (
|
||
<div className="p-3 bg-red-100 text-red-700 rounded-md">
|
||
{formError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<Select
|
||
label="Contractor"
|
||
name="contractorId"
|
||
value={formData.contractorId}
|
||
onChange={handleInputChange}
|
||
required
|
||
disabled={!!editingId}
|
||
options={[
|
||
{ value: "", label: "Select Contractor" },
|
||
...contractors.map((c) => ({
|
||
value: String(c.id),
|
||
label: c.name,
|
||
})),
|
||
]}
|
||
/>
|
||
{user?.role === "Supervisor"
|
||
? (
|
||
<Input
|
||
label="Department"
|
||
value={departments.find((d) =>
|
||
d.id === user?.department_id
|
||
)?.name || "Loading..."}
|
||
disabled
|
||
/>
|
||
)
|
||
: (
|
||
<Select
|
||
label="Department"
|
||
value={selectedDept}
|
||
onChange={(e) => setSelectedDept(e.target.value)}
|
||
options={[
|
||
{ value: "", label: "Select Department" },
|
||
...departments.map((d) => ({
|
||
value: String(d.id),
|
||
label: d.name,
|
||
})),
|
||
]}
|
||
/>
|
||
)}
|
||
<Select
|
||
label="Sub-Department"
|
||
name="subDepartmentId"
|
||
value={formData.subDepartmentId}
|
||
onChange={handleInputChange}
|
||
disabled={!!editingId}
|
||
options={[
|
||
{ value: "", label: "Select Sub-Department (Optional)" },
|
||
...subDepartments.map((s) => ({
|
||
value: String(s.id),
|
||
label: s.name,
|
||
})),
|
||
]}
|
||
/>
|
||
<Select
|
||
label="Activity Type"
|
||
name="activity"
|
||
value={formData.activity}
|
||
onChange={handleInputChange}
|
||
disabled={!formData.subDepartmentId}
|
||
options={[
|
||
{
|
||
value: "",
|
||
label: formData.subDepartmentId
|
||
? "Select Activity (Optional)"
|
||
: "Select Sub-Department First",
|
||
},
|
||
...activities.map((a) => ({
|
||
value: a.name,
|
||
label: `${a.name} (${
|
||
a.unit_of_measurement === "Per Bag"
|
||
? "per unit × rate"
|
||
: "flat rate"
|
||
})`,
|
||
})),
|
||
]}
|
||
/>
|
||
<Input
|
||
label={(() => {
|
||
const selectedActivity = activities.find((a) =>
|
||
a.name === formData.activity
|
||
);
|
||
return selectedActivity?.unit_of_measurement === "Per Bag"
|
||
? "Rate per Unit (₹)"
|
||
: "Rate Amount (₹)";
|
||
})()}
|
||
name="rate"
|
||
type="number"
|
||
value={formData.rate}
|
||
onChange={handleInputChange}
|
||
placeholder="Enter rate amount"
|
||
required
|
||
/>
|
||
<Input
|
||
label="Effective Date"
|
||
name="effectiveDate"
|
||
type="date"
|
||
value={formData.effectiveDate}
|
||
onChange={handleInputChange}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-4">
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => {
|
||
setActiveTab("list");
|
||
resetForm();
|
||
}}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={handleSubmit} disabled={formLoading}>
|
||
{formLoading ? "Saving..." : (
|
||
<>
|
||
<DollarSign size={16} className="mr-2" />
|
||
{editingId ? "Update Rate" : "Add Rate"}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|