Seera/src/pages/WorkOrderList.tsx

514 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWorkOrders, useWorkOrderMutations } from '../hooks/useWorkOrder';
import { FaPlus, FaSearch, FaEdit, FaEye, FaTrash, FaCopy, FaEllipsisV, FaDownload, FaPrint, FaFileExport, FaCheckCircle, FaClock, FaExclamationTriangle } from 'react-icons/fa';
const WorkOrderList: React.FC = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(null);
const [actionMenuOpen, setActionMenuOpen] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const limit = 20;
const filters = statusFilter ? { repair_status: statusFilter } : {};
const { workOrders, totalCount, hasMore, loading, error, refetch } = useWorkOrders(
filters,
limit,
page * limit,
'creation desc'
);
const { deleteWorkOrder, loading: mutationLoading } = useWorkOrderMutations();
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setActionMenuOpen(null);
}
};
if (actionMenuOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [actionMenuOpen]);
const handleCreateNew = () => {
navigate('/work-orders/new');
};
const handleView = (workOrderName: string) => {
navigate(`/work-orders/${workOrderName}`);
};
const handleEdit = (workOrderName: string) => {
navigate(`/work-orders/${workOrderName}`);
};
const handleDelete = async (workOrderName: string) => {
try {
await deleteWorkOrder(workOrderName);
setDeleteConfirmOpen(null);
refetch();
alert('Work order deleted successfully!');
} catch (err) {
alert(`Failed to delete work order: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleDuplicate = (workOrderName: string) => {
navigate(`/work-orders/new?duplicate=${workOrderName}`);
};
const handleExport = (workOrder: any) => {
const dataStr = JSON.stringify(workOrder, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `work_order_${workOrder.name}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handlePrint = (workOrderName: string) => {
window.open(`/work-orders/${workOrderName}?print=true`, '_blank');
};
const handleExportAll = () => {
const headers = ['Work Order ID', 'Asset', 'Type', 'Status', 'Department', 'Priority', 'Created'];
const csvContent = [
headers.join(','),
...workOrders.map(wo => [
wo.name,
wo.asset_name || wo.asset || '',
wo.work_order_type || '',
wo.repair_status || '',
wo.department || '',
wo.custom_priority_ || '',
wo.creation ? new Date(wo.creation).toLocaleDateString() : ''
].join(','))
].join('\n');
const dataBlob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `work_orders_export_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(url);
};
const getStatusIcon = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return <FaCheckCircle className="text-green-500" />;
case 'in progress':
return <FaClock className="text-blue-500" />;
case 'pending':
return <FaExclamationTriangle className="text-yellow-500" />;
default:
return <FaClock className="text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status?.toLowerCase()) {
case 'completed':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
case 'in progress':
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300';
case 'pending':
case 'open':
return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300';
case 'cancelled':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
const getPriorityColor = (priority: string) => {
switch (priority?.toLowerCase()) {
case 'high':
case 'urgent':
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
case 'medium':
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300';
case 'low':
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
default:
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300';
}
};
if (loading && page === 0) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading work orders...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
<h2 className="text-xl font-bold text-yellow-800 dark:text-yellow-300 mb-4"> Work Order API Not Available</h2>
<div className="text-yellow-700 dark:text-yellow-400 space-y-3">
<p><strong>The Work Order API endpoint is not deployed yet.</strong></p>
<p>To fix this, deploy the work_order_api.py file to your Frappe server.</p>
<div className="mt-4 flex gap-3">
<button
onClick={() => navigate('/work-orders/new')}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded"
>
Try Creating New (Demo)
</button>
<button
onClick={refetch}
className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
</div>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded border border-yellow-300 dark:border-yellow-700">
<p className="text-sm text-gray-600 dark:text-gray-400">
<strong>Technical Error:</strong> {error}
</p>
</div>
</div>
</div>
);
}
// Filter work orders by search term
const filteredWorkOrders = workOrders.filter(wo =>
wo.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.asset_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
wo.asset?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
{/* Header */}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">Work Orders</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Total: {totalCount} work order{totalCount !== 1 ? 's' : ''}
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExportAll}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-lg flex items-center gap-2 shadow transition-all"
disabled={workOrders.length === 0}
>
<FaFileExport />
<span className="font-medium">Export All</span>
</button>
<button
onClick={handleCreateNew}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
>
<FaPlus />
<span className="font-medium">New Work Order</span>
</button>
</div>
</div>
{/* Filters Bar */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Bar */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="flex items-center gap-2 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 bg-white dark:bg-gray-700">
<FaSearch className="text-gray-400 dark:text-gray-500" />
<input
type="text"
placeholder="Search by ID, asset name, description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 outline-none text-gray-700 dark:text-gray-200 bg-transparent"
/>
</div>
</div>
{/* Status Filter */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All Statuses</option>
<option value="Open">Open</option>
<option value="In Progress">In Progress</option>
<option value="Pending">Pending</option>
<option value="Completed">Completed</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Work Orders Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Work Order ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Asset
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Department
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredWorkOrders.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div className="flex flex-col items-center">
<FaSearch className="text-4xl text-gray-300 dark:text-gray-600 mb-2" />
<p>No work orders found</p>
<button
onClick={handleCreateNew}
className="mt-4 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline"
>
Create your first work order
</button>
</div>
</td>
</tr>
) : (
filteredWorkOrders.map((workOrder) => (
<tr
key={workOrder.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
onClick={() => handleView(workOrder.name)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900 dark:text-white">{workOrder.name}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{workOrder.creation ? new Date(workOrder.creation).toLocaleDateString() : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-white">{workOrder.asset_name || '-'}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{workOrder.asset || ''}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{workOrder.work_order_type || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
{workOrder.department || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
{getStatusIcon(workOrder.repair_status || '')}
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(workOrder.repair_status || '')}`}>
{workOrder.repair_status || 'Unknown'}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPriorityColor(workOrder.custom_priority_ || '')}`}>
{workOrder.custom_priority_ || 'Normal'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleView(workOrder.name)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
title="View Details"
>
<FaEye />
</button>
<button
onClick={() => handleEdit(workOrder.name)}
className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors"
title="Edit Work Order"
>
<FaEdit />
</button>
<button
onClick={() => handleDuplicate(workOrder.name)}
className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors"
title="Duplicate Work Order"
>
<FaCopy />
</button>
<button
onClick={() => setDeleteConfirmOpen(workOrder.name)}
className="text-red-600 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300 p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title="Delete Work Order"
disabled={mutationLoading}
>
<FaTrash />
</button>
{/* More Actions Dropdown */}
<div className="relative" ref={actionMenuOpen === workOrder.name ? dropdownRef : null}>
<button
onClick={() => setActionMenuOpen(actionMenuOpen === workOrder.name ? null : workOrder.name)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors"
title="More Actions"
>
<FaEllipsisV />
</button>
{actionMenuOpen === workOrder.name && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<button
onClick={() => {
handleExport(workOrder);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-t-lg"
>
<FaDownload className="text-blue-500" />
Export as JSON
</button>
<button
onClick={() => {
handlePrint(workOrder.name);
setActionMenuOpen(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 rounded-b-lg"
>
<FaPrint className="text-purple-500" />
Print Work Order
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredWorkOrders.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-700 px-6 py-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-600">
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{page * limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min((page + 1) * limit, totalCount)}
</span>{' '}
of <span className="font-medium">{totalCount}</span> results
</div>
<div className="flex gap-2">
<button
disabled={page === 0}
onClick={() => setPage(page - 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
disabled={!hasMore}
onClick={() => setPage(page + 1)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteConfirmOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<FaTrash className="text-red-600 dark:text-red-400 text-xl" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Delete Work Order
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to delete this work order? This action cannot be undone.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3 mb-4">
<p className="text-xs text-yellow-800 dark:text-yellow-300">
<strong>Work Order ID:</strong> {deleteConfirmOpen}
</p>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => setDeleteConfirmOpen(null)}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
disabled={mutationLoading}
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirmOpen)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={mutationLoading}
>
{mutationLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Deleting...
</>
) : (
<>
<FaTrash />
Delete Work Order
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default WorkOrderList;