Cliente/OestPan #2
|
|
@ -1,138 +0,0 @@
|
|||
import React from 'react';
|
||||
import { X, Filter, Trash2, Check, ChevronDown } from 'lucide-react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
const AdvancedFiltersModal = ({ isOpen, onClose, onApply, options = {}, initialFilters = {}, config = [] }) => {
|
||||
const [filters, setFilters] = React.useState(initialFilters);
|
||||
|
||||
// Sync with parent when modal opens or initialFilters change
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFilters(initialFilters);
|
||||
}
|
||||
}, [isOpen, initialFilters]);
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(filters);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const activeCount = Object.values(filters).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={isOpen} onOpenChange={onClose}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content className="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-0 bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl md:w-full overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-[#2a2a2a] bg-white dark:bg-[#1c1c1c]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className="w-5 h-5 text-gray-500 dark:text-stone-400 fill-gray-500/20" />
|
||||
<DialogPrimitive.Title asChild>
|
||||
<h2 className="text-lg font-bold text-gray-800 dark:text-white uppercase tracking-tight">Filtros Avançados</h2>
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Selecione os filtros detalhados para refinar a busca de veículos.
|
||||
</DialogPrimitive.Description>
|
||||
{activeCount > 0 && (
|
||||
<span className="bg-blue-500 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
{activeCount} filtros ativos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1 text-rose-500 hover:text-rose-600 font-bold text-[10px] uppercase tracking-wider transition-colors"
|
||||
>
|
||||
Limpar Tudo
|
||||
</button>
|
||||
<DialogPrimitive.Close className="rounded-full p-1.5 hover:bg-gray-100 dark:hover:bg-[#2a2a2a] transition-colors">
|
||||
<X className="w-5 h-5 text-gray-400 dark:text-stone-500" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable Form */}
|
||||
<div className="px-6 py-6 max-h-[60vh] overflow-y-auto space-y-5 bg-white dark:bg-[#1c1c1c] custom-scrollbar">
|
||||
{config.map((filter) => {
|
||||
const label = filter.label || filter.field.replace(/_/g, ' ').toUpperCase();
|
||||
|
||||
if (filter.type === 'select') {
|
||||
return (
|
||||
<div key={filter.field} className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-500 tracking-widest ml-1">{label}</label>
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
className="w-full appearance-none border border-gray-200 dark:border-[#2a2a2a] rounded-xl py-3 pl-4 pr-10 bg-gray-50 dark:bg-[#141414] hover:border-gray-300 dark:hover:border-[#333] transition-all text-left text-sm text-gray-700 dark:text-stone-300 font-bold focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
>
|
||||
<option value="" className="text-gray-400">TODOS</option>
|
||||
{options[filter.field]?.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 dark:text-stone-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.type === 'text') {
|
||||
return (
|
||||
<div key={filter.field} className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-500 tracking-widest ml-1">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={filter.placeholder || "Digite para buscar..."}
|
||||
className="w-full border border-gray-200 dark:border-[#2a2a2a] rounded-xl py-3 px-4 bg-gray-50 dark:bg-[#141414] text-sm text-gray-700 dark:text-white font-bold focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 placeholder:text-gray-400 dark:placeholder:text-stone-700 transition-all"
|
||||
value={filters[filter.field] || ''}
|
||||
onChange={(e) => handleChange(filter.field, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 dark:border-[#2a2a2a] bg-gray-50/50 dark:bg-[#1c1c1c] rounded-b-xl">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 text-xs font-bold uppercase tracking-widest text-gray-500 dark:text-stone-400 hover:text-gray-700 dark:hover:text-white transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="px-8 py-2.5 text-xs font-bold uppercase tracking-widest text-gray-900 bg-[#fbbf24] hover:bg-[#f59e0b] rounded-xl flex items-center gap-2 transition-all shadow-lg shadow-amber-500/10 active:scale-95"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
Aplicar Filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedFiltersModal;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
const AutocompleteInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
onSelect,
|
||||
placeholder = "Pesquisar...",
|
||||
displayKey = "label", // key to show in list
|
||||
valueKey = "value", // key to use for value
|
||||
searchKeys = [], // keys to search in. if empty, searches displayKey
|
||||
className = "",
|
||||
disabled = false,
|
||||
required = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState(value || '');
|
||||
const wrapperRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(value || '');
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredOptions = options.filter(option => {
|
||||
if (!searchTerm) return true;
|
||||
const term = searchTerm.toLowerCase();
|
||||
|
||||
// If searchKeys provided, search in those
|
||||
if (searchKeys.length > 0) {
|
||||
return searchKeys.some(key => {
|
||||
const val = option[key];
|
||||
return val && String(val).toLowerCase().includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise search in displayKey or direct string
|
||||
const label = typeof option === 'object' ? option[displayKey] : option;
|
||||
return String(label).toLowerCase().includes(term);
|
||||
});
|
||||
|
||||
const handleSelect = (option) => {
|
||||
const displayVal = typeof option === 'object' ? option[displayKey] : option;
|
||||
const actualVal = typeof option === 'object' ? (option[valueKey] || option[displayKey]) : option;
|
||||
|
||||
setSearchTerm(displayVal);
|
||||
onChange(displayVal); // Update the input value
|
||||
if (onSelect) onSelect(option); // Trigger specific selection logic
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 relative ${className}`} ref={wrapperRef}>
|
||||
{label && (
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-3 pr-8 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
onChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<ul className="absolute z-50 w-full mt-1 bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#333] rounded-lg shadow-xl max-h-60 overflow-auto custom-scrollbar">
|
||||
{filteredOptions.map((option, index) => {
|
||||
const display = typeof option === 'object' ? option[displayKey] : option;
|
||||
const isSelected = display === value;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between hover:bg-slate-100 dark:hover:bg-[#2a2a2a] ${
|
||||
isSelected ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 font-medium' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<span>{display}</span>
|
||||
{isSelected && <Check size={14} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{isOpen && filteredOptions.length === 0 && searchTerm && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#333] rounded-lg shadow-xl p-3 text-sm text-slate-500 text-center">
|
||||
Sem resultados
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompleteInput;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,459 +0,0 @@
|
|||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Edit2, Trash2, Filter, Columns, Layers, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import AdvancedFiltersModal from './AdvancedFiltersModal';
|
||||
|
||||
const ExcelTable = ({
|
||||
data,
|
||||
columns,
|
||||
filterDefs = [],
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRowClick,
|
||||
loading = false,
|
||||
|
||||
pageSize: initialPageSize = 50,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
rowKey = 'id'
|
||||
}) => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [filters, setFilters] = useState({});
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
|
||||
// Pagination State
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize);
|
||||
|
||||
// ... (existing useMemo logic) ...
|
||||
|
||||
// Helper para lidar com seleção
|
||||
|
||||
|
||||
// 1. Extract Unique Values...
|
||||
const options = React.useMemo(() => {
|
||||
const opts = {};
|
||||
const getUnique = (key) => [...new Set(data.map(item => item[key]).filter(Boolean))].sort();
|
||||
|
||||
filterDefs.forEach(def => {
|
||||
if (def.type === 'select') {
|
||||
opts[def.field] = getUnique(def.field);
|
||||
}
|
||||
});
|
||||
|
||||
return opts;
|
||||
}, [data, filterDefs]);
|
||||
|
||||
// 2. Process Data...
|
||||
const processedData = React.useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// Filter
|
||||
Object.keys(filters).forEach(key => {
|
||||
if (filters[key]) {
|
||||
result = result.filter(item => {
|
||||
const itemValue = String(item[key] || '').toLowerCase();
|
||||
const filterValue = String(filters[key]).toLowerCase();
|
||||
return itemValue.includes(filterValue);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort
|
||||
if (sortConfig.key) {
|
||||
result.sort((a, b) => {
|
||||
const valA = a[sortConfig.key] || '';
|
||||
const valB = b[sortConfig.key] || '';
|
||||
|
||||
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, filters, sortConfig]);
|
||||
|
||||
// 3. Paginate...
|
||||
const paginatedData = React.useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return processedData.slice(startIndex, startIndex + pageSize);
|
||||
}, [processedData, currentPage, pageSize]);
|
||||
|
||||
// Reset page...
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters, sortConfig, pageSize]);
|
||||
|
||||
// Helper para lidar com seleção (Movido para cá para ter acesso a processedData)
|
||||
const handleSelectAll = (e) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (e.target.checked) {
|
||||
const allIds = processedData.map(item => item[rowKey]);
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRow = (id) => {
|
||||
if (!onSelectionChange) return;
|
||||
const newSelected = selectedIds.includes(id)
|
||||
? selectedIds.filter(x => x !== id)
|
||||
: [...selectedIds, id];
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
const isAllSelected = processedData && processedData.length > 0 && processedData.every(item => selectedIds.includes(item[rowKey]));
|
||||
const isIndeterminate = processedData && processedData.some(item => selectedIds.includes(item[rowKey])) && !isAllSelected;
|
||||
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSortConfig(current => ({
|
||||
key,
|
||||
direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc'
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApplyFilters = (newFilters) => {
|
||||
setFilters(newFilters);
|
||||
setShowFilters(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-[#1b1b1b] border border-slate-200 dark:border-[#2a2a2a] rounded-xl flex flex-col h-full w-full max-w-full min-w-0 text-xs font-sans antialiased text-slate-700 dark:text-[#e0e0e0] relative overflow-hidden transition-colors">
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-50 bg-white/80 dark:bg-[#1b1b1b]/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 border-4 border-slate-200 dark:border-[#252525] border-t-orange-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-orange-500 font-bold uppercase tracking-widest text-[10px] animate-pulse">Carregando Dados...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Top Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-white dark:bg-[#1b1b1b] border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
|
||||
{/* Left Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
onClick={() => setShowFilters(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 border rounded font-semibold transition-colors ${
|
||||
Object.keys(filters).length > 0
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/50'
|
||||
: 'bg-slate-50 dark:bg-[#252525] hover:bg-slate-100 dark:hover:bg-[#333] border-slate-200 dark:border-[#333] text-slate-600 dark:text-stone-300'
|
||||
}`}
|
||||
>
|
||||
<Filter size={14} strokeWidth={2.5} />
|
||||
<span className="uppercase tracking-wide text-[11px]">
|
||||
{Object.keys(filters).length > 0 ? `${Object.keys(filters).length} Filtros` : 'Filtros Avançados'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Actions Button (Green Highlight) - Commented as requested
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-50 dark:bg-[#1f2824] hover:bg-orange-100 dark:hover:bg-[#25302b] border border-orange-200 dark:border-[#2b3832] rounded text-orange-600 dark:text-orange-400 font-semibold transition-colors">
|
||||
<span className="uppercase tracking-wide text-[11px]">Ações</span>
|
||||
<span className="bg-orange-100 dark:bg-[#15201b] text-orange-600 px-1 rounded text-[9px]">
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500 inline-block"></span>
|
||||
</span>
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* Right Actions - Commented out as requested
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 hover:bg-[#252525] border border-transparent hover:border-[#333] rounded text-stone-400 transition-colors">
|
||||
<Columns size={12} />
|
||||
<span className="text-[10px] font-semibold">Colunas</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 hover:bg-[#252525] border border-transparent hover:border-[#333] rounded text-stone-400 transition-colors">
|
||||
<Layers size={12} />
|
||||
<span className="text-[10px] font-semibold">Agrupar</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 hover:bg-[#252525] border border-transparent hover:border-[#333] rounded text-stone-400 transition-colors">
|
||||
<Download size={12} />
|
||||
<span className="text-[10px] font-semibold">Exportar</span>
|
||||
</button>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 2. Table Header (The Complex Part) */}
|
||||
<div className="overflow-x-auto overflow-y-auto flex-1 relative custom-scrollbar w-full min-w-0">
|
||||
<style>{`
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f8fafc;
|
||||
}
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: #334155 #0f172a;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #0f172a;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border: 2px solid #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border: 2px solid #0f172a;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-corner {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-corner {
|
||||
background: #0f172a;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<table className="border-collapse table-fixed custom-scrollbar w-full min-w-full">
|
||||
<thead className="sticky top-0 z-20 bg-slate-50 dark:bg-[#1b1b1b] shadow-lg shadow-black/5 dark:shadow-black/20">
|
||||
<tr className="border-b border-slate-200 dark:border-[#333]">
|
||||
{/* Checkbox Header */}
|
||||
<th className="sticky left-0 z-30 bg-slate-50 dark:bg-[#1b1b1b] w-[40px] min-w-[40px] h-[40px] border-r border-[#e2e8f0] dark:border-[#333]">
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
ref={input => { if (input) input.indeterminate = isIndeterminate; }}
|
||||
onChange={handleSelectAll}
|
||||
disabled={!onSelectionChange}
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-orange-500 checked:border-orange-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
{onEdit || onDelete ? (
|
||||
<th className="sticky left-[40px] z-30 bg-slate-50 dark:bg-[#1b1b1b] w-[60px] min-w-[60px] h-[40px] border-r border-slate-200 dark:border-[#333] px-2 text-left">
|
||||
<div className="flex items-center h-full w-full uppercase font-bold text-slate-500 dark:text-[#e0e0e0] tracking-wider text-[11px]">
|
||||
Ações
|
||||
</div>
|
||||
</th>
|
||||
) : null}
|
||||
|
||||
{/* Dynamic Columns */}
|
||||
{columns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
onClick={() => handleSort(col.field)}
|
||||
className="h-[40px] border-r border-slate-200 dark:border-[#333] bg-slate-50 dark:bg-[#1b1b1b] px-3 relative group hover:bg-slate-100 dark:hover:bg-[#222] transition-colors cursor-pointer select-none"
|
||||
style={{ width: col.width, minWidth: col.width }}
|
||||
>
|
||||
<div className="flex items-center justify-between h-full w-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`uppercase font-bold tracking-wider text-[11px] ${sortConfig.key === col.field ? 'text-orange-600 dark:text-orange-500' : 'text-slate-500 dark:text-[#e0e0e0]'}`}>
|
||||
{col.header}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sort/Menu Icons */}
|
||||
<div className={`flex flex-col gap-0.5 ${sortConfig.key === col.field ? 'opacity-100' : 'opacity-40 group-hover:opacity-100'}`}>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'asc' ? 'text-orange-600 dark:text-orange-500' : 'text-slate-400 dark:text-stone-400'} rotate-180`}>
|
||||
<path d="M4 4L0 0H8L4 4Z" />
|
||||
</svg>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'desc' ? 'text-orange-600 dark:text-orange-500' : 'text-slate-400 dark:text-stone-400'}`}>
|
||||
<path d="M4 4L0 0H8L4 4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Resizer Handle */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-[4px] cursor-col-resize hover:bg-orange-500/50 z-10 translate-x-1/2" onClick={(e) => e.stopPropagation()} />
|
||||
</th>
|
||||
))}
|
||||
{/* Spacer for scrollbar */}
|
||||
<th className="bg-slate-50 dark:bg-[#1b1b1b] border-b border-slate-200 dark:border-[#333]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* 3. Table Body - Displaying Paginated Data */}
|
||||
<tbody className="bg-white dark:bg-[#151515]">
|
||||
{paginatedData.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
onClick={(e) => {
|
||||
// Não disparar onRowClick se clicar em checkbox, botões ou links
|
||||
if (e.target.closest('input[type="checkbox"]') ||
|
||||
e.target.closest('button') ||
|
||||
e.target.closest('a')) {
|
||||
return;
|
||||
}
|
||||
onRowClick && onRowClick(row);
|
||||
}}
|
||||
className={`h-[44px] border-b border-slate-100 dark:border-[#252525] hover:bg-slate-50 dark:hover:bg-[#202020] transition-colors group text-sm ${idx % 2 === 0 ? 'bg-white dark:bg-[#151515]' : 'bg-slate-50/50 dark:bg-[#181818]'} ${idx === 0 ? '!bg-slate-100 dark:!bg-[#2b2b2b]' : ''} ${onRowClick ? 'cursor-pointer' : ''}`}>
|
||||
|
||||
{/* Checkbox Cell */}
|
||||
<td className={`sticky left-0 z-10 border-r border-slate-100 dark:border-[#252525] px-0 text-center ${idx % 2 === 0 ? 'bg-white dark:bg-[#151515]' : 'bg-slate-50/50 dark:bg-[#181818]'} ${idx === 0 ? '!bg-slate-100 dark:!bg-[#2b2b2b]' : ''} group-hover:bg-slate-50 dark:group-hover:bg-[#202020]`}>
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(row[rowKey])}
|
||||
onChange={() => handleSelectRow(row[rowKey])}
|
||||
disabled={!onSelectionChange}
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-orange-500 checked:border-orange-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions Cell */}
|
||||
{(onEdit || onDelete) ? (
|
||||
<td className={`sticky left-[40px] z-10 border-r border-slate-100 dark:border-[#252525] px-0 text-center ${idx % 2 === 0 ? 'bg-white dark:bg-[#151515]' : 'bg-slate-50/50 dark:bg-[#181818]'} ${idx === 0 ? '!bg-slate-100 dark:!bg-[#2b2b2b]' : ''} group-hover:bg-slate-50 dark:group-hover:bg-[#202020]`}>
|
||||
<div className="flex items-center justify-center h-full w-full gap-2">
|
||||
<button
|
||||
onClick={() => onEdit && onEdit(row)}
|
||||
className="text-slate-500 dark:text-stone-500 hover:text-orange-600 dark:hover:text-stone-300 transition-colors p-1"
|
||||
>
|
||||
<Edit2 size={16} strokeWidth={2} />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(row)}
|
||||
className="text-slate-500 dark:text-stone-500 hover:text-rose-600 dark:hover:text-rose-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
{/* Data Cells */}
|
||||
{columns.map((col, cIdx) => (
|
||||
<td key={cIdx} className="border-r border-slate-100 dark:border-[#252525] px-3 whitespace-nowrap overflow-hidden text-ellipsis text-slate-700 dark:text-stone-300">
|
||||
{col.render ? col.render(row) : (
|
||||
<span className={col.className}>{row[col.field] || '-'}</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className=""></td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Empty rows filler if needed */}
|
||||
{!loading && paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length + 3} className="h-24 text-center text-stone-500 italic">
|
||||
{filters && Object.keys(filters).length > 0 ? 'Nenhum registro encontrado para os filtros selecionados.' : 'Nenhum registro disponível.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 4. Footer Steps (Pagination Controls) */}
|
||||
<div className="bg-slate-50 dark:bg-[#1b1b1b] border-t border-slate-200 dark:border-[#333] h-[40px] flex items-center justify-between px-2 overflow-hidden select-none">
|
||||
|
||||
{/* Left: Totals */}
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[120px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-orange-500 rounded-r-sm"></div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Total:</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-stone-200">{processedData.length}</span>
|
||||
|
||||
<div className="absolute right-0 top-2 bottom-2 w-[1px] bg-slate-200 dark:bg-[#333]"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[100px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-orange-500 rounded-r-sm opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Página:</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-stone-200">{currentPage} / {totalPages || 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Pagination Controls */}
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<div className="text-[11px] text-slate-500 mr-3 hidden md:block">
|
||||
Exibindo <span className="font-bold text-slate-700 dark:text-stone-300">{Math.min((currentPage - 1) * pageSize + 1, processedData.length)} a {Math.min(currentPage * pageSize, processedData.length)}</span>
|
||||
</div>
|
||||
|
||||
{/* First Page */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsLeft size={14} />
|
||||
</button>
|
||||
|
||||
{/* Prev Page */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{/* Manual Page Buttons (Simple logic) */}
|
||||
<div className="flex gap-1 mx-1">
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded bg-orange-500 text-white dark:text-black font-bold text-[11px] transition-colors shadow-lg shadow-orange-500/10">
|
||||
{currentPage}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next Page */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
|
||||
{/* Last Page */}
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdvancedFiltersModal
|
||||
isOpen={showFilters}
|
||||
onClose={() => setShowFilters(false)}
|
||||
onApply={handleApplyFilters}
|
||||
options={options}
|
||||
initialFilters={filters}
|
||||
config={filterDefs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcelTable;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { CheckCircle2, AlertCircle, Info, XCircle, X } from 'lucide-react';
|
||||
import { create } from 'zustand';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
// Store to manage global feedback state
|
||||
export const useFeedbackStore = create((set) => ({
|
||||
notifications: [],
|
||||
notify: (type, title, message, duration = 5000) => {
|
||||
const id = Date.now();
|
||||
set((state) => ({
|
||||
notifications: [...state.notifications, { id, type, title, message, duration }]
|
||||
}));
|
||||
setTimeout(() => {
|
||||
set((state) => ({
|
||||
notifications: state.notifications.filter(n => n.id !== id)
|
||||
}));
|
||||
}, duration);
|
||||
},
|
||||
remove: (id) => set((state) => ({
|
||||
notifications: state.notifications.filter(n => n.id !== id)
|
||||
}))
|
||||
}));
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle2 className="w-6 h-6 text-orange-500" />,
|
||||
error: <XCircle className="w-6 h-6 text-rose-500" />,
|
||||
warning: <AlertCircle className="w-6 h-6 text-amber-500" />,
|
||||
info: <Info className="w-6 h-6 text-blue-500" />
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: "border-orange-500/20 bg-orange-50/50 dark:bg-orange-500/10",
|
||||
error: "border-rose-500/20 bg-rose-50/50 dark:bg-rose-500/10",
|
||||
warning: "border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/10",
|
||||
info: "border-blue-500/20 bg-blue-50/50 dark:bg-blue-500/10"
|
||||
};
|
||||
|
||||
export const FeedbackContainer = () => {
|
||||
const { notifications, remove } = useFeedbackStore();
|
||||
|
||||
return (
|
||||
<div className="fixed top-6 right-6 z-[9999] flex flex-col gap-3 pointer-events-none">
|
||||
<AnimatePresence>
|
||||
{notifications.map((n) => (
|
||||
<motion.div
|
||||
key={n.id}
|
||||
initial={{ opacity: 0, x: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
||||
className={`pointer-events-auto min-w-[320px] max-w-[400px] p-4 rounded-2xl border backdrop-blur-md shadow-2xl flex gap-4 relative overflow-hidden group ${colors[n.type]}`}
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<div className={`absolute -right-4 -top-4 w-24 h-24 blur-3xl opacity-20 group-hover:opacity-40 transition-opacity ${n.type === 'success' ? 'bg-orange-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}></div>
|
||||
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{icons[n.type]}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-1 pr-6">
|
||||
<h4 className="text-sm font-bold uppercase tracking-tight text-slate-800 dark:text-white">
|
||||
{n.title}
|
||||
</h4>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-stone-400 leading-relaxed">
|
||||
{n.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => remove(n.id)}
|
||||
className="absolute top-3 right-3 p-1 rounded-full hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3 text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* Progress Bar (Auto-expire) */}
|
||||
<motion.div
|
||||
initial={{ scaleX: 1 }}
|
||||
animate={{ scaleX: 0 }}
|
||||
transition={{ duration: Math.min((n.duration || 5000) / 1000, 30), ease: "linear" }}
|
||||
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${n.type === 'success' ? 'bg-orange-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFeedback = () => {
|
||||
const notify = useFeedbackStore(s => s.notify);
|
||||
|
||||
return {
|
||||
success: (title, message) => notify('success', title || 'Sucesso!', message),
|
||||
error: (title, message) => notify('error', title || 'Ops!', message),
|
||||
warning: (title, message) => notify('warning', title || 'Atenção!', message),
|
||||
info: (title, message) => notify('info', title || 'Informação', message),
|
||||
|
||||
// Friendly back-end error parser
|
||||
handleBackendError: (error) => {
|
||||
console.error(error);
|
||||
// Usa a função utilitária para obter mensagem amigável
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Ops!', friendlyMsg);
|
||||
},
|
||||
|
||||
notifyFields: (fields) => {
|
||||
notify('warning', 'Campos Obrigatórios', `Por favor, preencha: ${fields.join(', ')}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { FinesCard } from './FinesCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
|
||||
const FinesCardDebug = (initialProps) => {
|
||||
|
||||
const sampleData = [
|
||||
{ name: 'Excesso de Velocidade', value: 450.00, color: '#ef4444' }, // Red
|
||||
{ name: 'Estacionamento Irregular', value: 250.00, color: '#f59e0b' }, // Amber
|
||||
{ name: 'Documentação', value: 150.00, color: '#3b82f6' }, // Blue
|
||||
];
|
||||
|
||||
const totalSampleValue = sampleData.reduce((acc, curr) => acc + curr.value, 0);
|
||||
|
||||
// Helper to generate consistent data
|
||||
const generateChartData = (period, current, previous) => {
|
||||
if (period === '30d') {
|
||||
return [
|
||||
{ name: 'Sem 1', value: previous * 0.8 },
|
||||
{ name: 'Sem 2', value: previous * 1.1 },
|
||||
{ name: 'Sem 3', value: previous * 0.9 },
|
||||
{ name: 'Atual', value: current, active: true } // Highlighted
|
||||
];
|
||||
}
|
||||
|
||||
if (period === '60d') {
|
||||
return [
|
||||
{ name: 'Mês -1', value: previous * 0.9 },
|
||||
{ name: 'Anterior', value: previous },
|
||||
{ name: 'Atual', value: current, active: true }
|
||||
];
|
||||
}
|
||||
|
||||
if (period === '120d') {
|
||||
return [
|
||||
{ name: 'Mês -3', value: previous * 1.2 },
|
||||
{ name: 'Mês -2', value: previous * 0.8 },
|
||||
{ name: 'Anterior', value: previous },
|
||||
{ name: 'Atual', value: current, active: true }
|
||||
];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const [state, setState] = useState(() => {
|
||||
const initialCurrent = 850;
|
||||
const initialPrev = 1100;
|
||||
return {
|
||||
currentValue: initialCurrent,
|
||||
currentCount: 3,
|
||||
previousValue: initialPrev,
|
||||
previousCount: 6,
|
||||
hasData: true,
|
||||
isLoading: false,
|
||||
period: '30d',
|
||||
data: sampleData,
|
||||
monthlyData: generateChartData('30d', initialCurrent, initialPrev),
|
||||
...initialProps
|
||||
};
|
||||
});
|
||||
|
||||
const toggleData = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
hasData: !prev.hasData,
|
||||
currentValue: !prev.hasData ? totalSampleValue : 0,
|
||||
currentCount: !prev.hasData ? 3 : 0
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePeriodChange = (p) => {
|
||||
console.log('Period Changed:', p);
|
||||
|
||||
// Simulate API logic: distinct values for each period
|
||||
let newCurrent, newPrev, newCount;
|
||||
|
||||
if (p === '30d') { newCurrent = 850; newPrev = 1100; newCount = 3; }
|
||||
else if (p === '60d') { newCurrent = 1450; newPrev = 1200; newCount = 5; }
|
||||
else if (p === '120d') { newCurrent = 2100; newPrev = 1800; newCount = 8; }
|
||||
else { newCurrent = 0; newPrev = 0; newCount = 0; }
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
period: p, // Update the period in state
|
||||
currentValue: newCurrent,
|
||||
previousValue: newPrev,
|
||||
currentCount: newCount,
|
||||
monthlyData: generateChartData(p, newCurrent, newPrev)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrevValue = (e) => {
|
||||
const val = Number(e.target.value);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
previousValue: val,
|
||||
monthlyData: generateChartData(prev.period, prev.currentValue, val)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-8 items-start w-full">
|
||||
{/* Preview Area */}
|
||||
<div className="w-[350px] h-[450px] shrink-0">
|
||||
<FinesCard
|
||||
currentValue={state.currentValue}
|
||||
currentCount={state.currentCount}
|
||||
previousValue={state.previousValue}
|
||||
previousCount={state.previousCount}
|
||||
data={state.hasData ? sampleData : []}
|
||||
monthlyData={state.hasData ? state.monthlyData : []}
|
||||
isLoading={state.isLoading}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls Area */}
|
||||
<div className="flex-1 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-xl border border-slate-200 dark:border-white/10 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider">Debug Controls</h4>
|
||||
<Button variant="ghost" size="icon" onClick={() => setState(prev => ({ ...prev, isLoading: !prev.isLoading }))} className={state.isLoading ? "text-orange-500" : "text-slate-500 hover:text-slate-900 dark:hover:text-slate-300"}>
|
||||
<RefreshCcw className={state.isLoading ? "animate-spin" : ""} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-white dark:bg-white/5 border border-slate-200 dark:border-transparent rounded-lg shadow-sm dark:shadow-none">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-slate-700 dark:text-slate-300">Simular Dados</Label>
|
||||
<p className="text-[10px] text-slate-500">Alterna entre estado vazio e populado</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.hasData}
|
||||
onChange={toggleData}
|
||||
className="w-5 h-5 rounded border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 text-orange-500 focus:ring-orange-500 focus:ring-offset-white dark:focus:ring-offset-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Valor Mês Anterior</span>
|
||||
<span className="font-mono text-slate-900 dark:text-white">R$ {state.previousValue}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={state.previousValue}
|
||||
onChange={updatePrevValue}
|
||||
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-slate-100 dark:bg-black/20 border border-slate-200 dark:border-white/5 text-[10px] font-mono text-slate-500 dark:text-slate-400 space-y-2">
|
||||
<div><span className="text-purple-600 dark:text-purple-400">Current Value:</span> {state.currentValue}</div>
|
||||
<div><span className="text-purple-600 dark:text-purple-400">Reduction:</span> {state.previousValue > 0 ? ((state.currentValue - state.previousValue) / state.previousValue * 100).toFixed(1) : 0}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinesCardDebug;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PieChart as PieChartIcon, Ban, AlertCircle, TrendingDown, TrendingUp, Minus, BarChart3, PieChart as PieIcon } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, XAxis, YAxis, CartesianGrid, Bar } from 'recharts';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* FinesCard (Componente de Multas)
|
||||
*
|
||||
* Exibe o resumo de multas do mês atual comparado com o mês anterior.
|
||||
* Permite alternar entre visualização por Categoria (Pie) e Histórico Mensal (Bar).
|
||||
*/
|
||||
export const FinesCard = ({
|
||||
currentDate = 'Mês',
|
||||
currentValue = 0,
|
||||
currentCount = 0,
|
||||
previousValue = 0,
|
||||
previousCount = 0,
|
||||
data = [], // [{ name: 'Type', value: 100, color: '#f00' }]
|
||||
monthlyData = [], // [{ name: 'Jan', value: 500, count: 2 }]
|
||||
isLoading = false,
|
||||
onPeriodChange,
|
||||
className
|
||||
}) => {
|
||||
const [activeView, setActiveView] = useState('category'); // 'category' | 'history'
|
||||
const [period, setPeriod] = useState('30d'); // '30d' | '60d' | '120d'
|
||||
|
||||
const handlePeriodChange = (p) => {
|
||||
setPeriod(p);
|
||||
if (onPeriodChange) onPeriodChange(p);
|
||||
};
|
||||
|
||||
// Formatters
|
||||
const formatCurrency = (val) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val);
|
||||
const formatCompact = (val) => Intl.NumberFormat('pt-BR', { notation: "compact", maximumFractionDigits: 1 }).format(val);
|
||||
|
||||
// Calculation for reduction/increase
|
||||
const hasPrevious = previousValue > 0;
|
||||
const diff = currentValue - previousValue;
|
||||
const percentage = hasPrevious ? (diff / previousValue) * 100 : 0;
|
||||
const isReduction = percentage < 0;
|
||||
const isIncrease = percentage > 0;
|
||||
|
||||
const trendColor = isReduction ? 'text-orange-500' : isIncrease ? 'text-rose-500' : 'text-slate-500';
|
||||
const badgeColor = isReduction ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' : isIncrease ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' : 'bg-slate-500/10 text-slate-500 border-slate-500/20';
|
||||
const TrendIcon = isReduction ? TrendingDown : isIncrease ? TrendingUp : Minus;
|
||||
|
||||
const isEmpty = !data || data.length === 0 || currentValue === 0;
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const pData = payload[0];
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#18181b] border border-slate-200 dark:border-white/10 p-3 rounded-lg shadow-xl min-w-[120px]">
|
||||
<p className="text-[10px] uppercase font-bold text-slate-500 mb-1 tracking-wider">{label || pData.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: pData.payload.color || '#3b82f6' }} />
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-white font-mono">
|
||||
{formatCurrency(pData.value)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn("flex flex-col h-full bg-white dark:bg-[#18181b] border-slate-200 dark:border-white/10 shadow-sm dark:shadow-xl overflow-hidden relative", className)}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-2 shrink-0">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
|
||||
<PieChartIcon className="text-amber-600 dark:text-amber-500" size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-white leading-none">Multas</h3>
|
||||
<span className="text-[10px] text-slate-500 font-medium uppercase tracking-wider">Visão Geral</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={cn("text-2xl font-bold tracking-tight leading-none", currentValue > 0 ? "text-slate-900 dark:text-white" : "text-orange-600 dark:text-orange-500")}>
|
||||
{formatCurrency(currentValue)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium mt-1">{currentCount} registros</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison Box */}
|
||||
<div className="bg-slate-50 dark:bg-white/5 rounded-xl p-3 flex items-center justify-between border border-slate-200 dark:border-white/5 mb-4 relative overflow-hidden">
|
||||
<div className="flex flex-col relative z-10">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Mês Anterior</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">{hasPrevious ? `R$ ${formatCompact(previousValue)}` : '-'}</span>
|
||||
<span className="text-[10px] text-slate-400 dark:text-slate-500 italic">({previousCount} registros)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPrevious && (
|
||||
<Badge variant="outline" className={cn("gap-1.5 pl-2 pr-3 py-1 relative z-10", badgeColor)}>
|
||||
<TrendIcon size={12} />
|
||||
{Math.abs(percentage).toFixed(1)}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Background gradient for decoration */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-32 bg-gradient-to-l from-white/50 dark:from-white/5 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Filters & Toggles Row */}
|
||||
{!isEmpty && !isLoading && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Time Period Selector */}
|
||||
<div className="flex-1 bg-slate-100 dark:bg-black/20 p-1 rounded-lg border border-slate-200 dark:border-white/5 flex">
|
||||
{['30d', '60d', '120d'].map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => handlePeriodChange(p)}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 rounded-md text-[10px] uppercase font-bold tracking-wider transition-all",
|
||||
period === p
|
||||
? "bg-white dark:bg-orange-500 text-orange-600 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-0"
|
||||
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
{p.replace('d', ' dias')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Type Toggle (Icon Only) */}
|
||||
<div className="flex bg-slate-100 dark:bg-black/20 p-1 rounded-lg border border-slate-200 dark:border-white/5">
|
||||
<button
|
||||
onClick={() => setActiveView('category')}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-all",
|
||||
activeView === 'category'
|
||||
? "bg-white dark:bg-white/10 text-amber-600 dark:text-amber-500 shadow-sm ring-1 ring-black/5 dark:ring-0"
|
||||
: "text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
)}
|
||||
title="Agrupado"
|
||||
>
|
||||
<PieIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('history')}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-all",
|
||||
activeView === 'history'
|
||||
? "bg-white dark:bg-white/10 text-blue-600 dark:text-blue-500 shadow-sm ring-1 ring-black/5 dark:ring-0"
|
||||
: "text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
)}
|
||||
title="Histórico"
|
||||
>
|
||||
<BarChart3 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-[px] relative px-4 pb-4 pt-2 flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 animate-pulse">
|
||||
<div className="w-24 h-24 rounded-full bg-slate-200 dark:bg-white/5" />
|
||||
<div className="h-4 w-32 bg-slate-200 dark:bg-white/5 rounded" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
// Empty State
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mb-2">
|
||||
<PieChartIcon className="text-slate-300 dark:text-slate-600 opacity-50" size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 dark:text-white">Sem dados</h4>
|
||||
<p className="text-[10px] text-slate-500 leading-relaxed max-w-[150px] mx-auto mt-1">
|
||||
Nenhum registro encontrado para o período.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Charts Area
|
||||
<div className="flex-1 w-full h-full min-h-0 flex flex-col">
|
||||
{activeView === 'category' ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-2 justify-center pb-2 shrink-0">
|
||||
{data.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 pt-2">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlyData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(148, 163, 184, 0.1)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#64748b', fontSize: 10 }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: '#64748b', fontSize: 10 }}
|
||||
tickFormatter={(val) => `R$${val/1000}k`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(148, 163, 184, 0.1)' }} />
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]} maxBarSize={40}>
|
||||
{monthlyData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={entry.active ? '#3b82f6' : '#94a3b8'} // Blue active, Slate-400 others
|
||||
className="dark:fill-slate-700"
|
||||
style={{ fill: entry.active ? '#3b82f6' : undefined }} // Override inline for active
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="text-center mt-2 pb-2">
|
||||
<span className="text-[10px] text-slate-400 dark:text-slate-500 italic">Evolução últimos 6 meses</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export { FinesCard } from './FinesCard';
|
||||
export { FinesCard as default } from './FinesCard';
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,404 +0,0 @@
|
|||
/* PrafrotSidebar CSS - Adaptive Theme (Light/Dark) */
|
||||
|
||||
:root {
|
||||
/* Light Theme Variables (Default) */
|
||||
--pfs-bg: #ffffff;
|
||||
--pfs-text: #475569;
|
||||
--pfs-text-active: #0f172a;
|
||||
--pfs-text-muted: #64748b;
|
||||
--pfs-border: #f1f5f9;
|
||||
--pfs-bg-search: #f8fafc;
|
||||
--pfs-bg-hover: rgba(0, 0, 0, 0.03);
|
||||
--pfs-tree-line: #e2e8f0;
|
||||
--pfs-footer-bg: #f8fafc;
|
||||
--pfs-user-card-bg: rgba(0, 0, 0, 0.01);
|
||||
--pfs-toggle-bg: #ffffff;
|
||||
--pfs-toggle-border: #e2e8f0;
|
||||
--pfs-shadow: 0 10px 30px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.dark .pfs-container {
|
||||
/* Dark Theme Variables */
|
||||
--pfs-bg: #1a1a1a;
|
||||
--pfs-text: #ffffff;
|
||||
--pfs-text-active: #141414;
|
||||
--pfs-text-muted: #a0a0a0;
|
||||
--pfs-border: rgba(255, 255, 255, 0.03);
|
||||
--pfs-bg-search: #252525;
|
||||
--pfs-bg-hover: rgba(255, 255, 255, 0.03);
|
||||
--pfs-tree-line: #262626;
|
||||
--pfs-footer-bg: #181818;
|
||||
--pfs-user-card-bg: rgba(255, 255, 255, 0.02);
|
||||
--pfs-toggle-bg: #262626;
|
||||
--pfs-toggle-border: #333;
|
||||
--pfs-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.pfs-container {
|
||||
width: 260px;
|
||||
height: calc(100vh - 20px);
|
||||
background: var(--pfs-bg);
|
||||
color: var(--pfs-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 40;
|
||||
border-radius: 16px;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--pfs-shadow);
|
||||
border: 1px solid var(--pfs-border);
|
||||
}
|
||||
|
||||
/* Subtle Top Glow - orange */
|
||||
.pfs-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #f97316, transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.pfs-container.collapsed {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
/* Toggle Area */
|
||||
.pfs-toggle-container {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsed .pfs-toggle-container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pfs-toggle-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: var(--pfs-toggle-bg);
|
||||
border: 1px solid var(--pfs-toggle-border);
|
||||
color: var(--pfs-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pfs-toggle-btn:hover {
|
||||
background: var(--pfs-bg-search);
|
||||
color: #f97316;
|
||||
border-color: #f97316;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Search Box */
|
||||
.pfs-search-wrapper {
|
||||
padding: 0.5rem 1.25rem 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .pfs-search-wrapper {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pfs-search-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: var(--pfs-bg-search);
|
||||
border: 1px solid var(--pfs-border);
|
||||
border-radius: 12px;
|
||||
color: var(--pfs-text);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pfs-search-input:focus {
|
||||
border-color: #f97316;
|
||||
background: var(--pfs-bg);
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
/* Navigation Content */
|
||||
.pfs-nav-content {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.pfs-nav-content::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.pfs-nav-content::-webkit-scrollbar-thumb {
|
||||
background: var(--pfs-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Menu Item Wrapper */
|
||||
.pfs-item-wrapper {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Links & Tree Structure */
|
||||
.pfs-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
color: var(--pfs-text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pfs-link:hover:not(.active) {
|
||||
background: var(--pfs-bg-hover);
|
||||
color: var(--pfs-text);
|
||||
}
|
||||
|
||||
.pfs-link.active {
|
||||
background: #f97316 !important;
|
||||
color: var(--pfs-text-active) !important;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.pfs-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pfs-link:hover .pfs-icon:not(.active) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.pfs-label {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.collapsed .pfs-label,
|
||||
.collapsed .pfs-chevron {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pfs-chevron {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--pfs-text-muted);
|
||||
}
|
||||
|
||||
.pfs-chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Submenu & Tree Lines */
|
||||
.pfs-submenu {
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
border-left: 2px solid var(--pfs-tree-line);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pfs-sublink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 10px;
|
||||
color: var(--pfs-text-muted);
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pfs-sublink::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.25rem;
|
||||
top: 50%;
|
||||
width: 0.75rem;
|
||||
height: 2px;
|
||||
background: var(--pfs-tree-line);
|
||||
}
|
||||
|
||||
.pfs-sublink:hover {
|
||||
color: var(--pfs-text);
|
||||
background: var(--pfs-bg-hover);
|
||||
}
|
||||
|
||||
.pfs-sublink.active {
|
||||
color: #f97316;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Icons in sublink */
|
||||
.pfs-sublink .pfs-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pfs-sublink.active .pfs-icon {
|
||||
opacity: 1;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
/* Footer Section */
|
||||
.pfs-footer {
|
||||
padding: 1.25rem;
|
||||
border-top: 1px solid var(--pfs-border);
|
||||
background: var(--pfs-footer-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.collapsed .pfs-footer {
|
||||
padding: 1.25rem 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Brand Area in Footer */
|
||||
.pfs-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pfs-brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #f97316;
|
||||
color: #141414;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 0 15px rgba(249, 115, 22, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pfs-brand-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pfs-brand-name {
|
||||
font-weight: 800;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--pfs-text);
|
||||
}
|
||||
|
||||
.pfs-brand-name span {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.pfs-app-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--pfs-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* User Card */
|
||||
.pfs-user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0.625rem;
|
||||
background: var(--pfs-user-card-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--pfs-border);
|
||||
}
|
||||
|
||||
.pfs-user-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pfs-user-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--pfs-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pfs-logout-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--pfs-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.pfs-logout-link:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Legal & Version */
|
||||
.pfs-legal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--pfs-border);
|
||||
}
|
||||
|
||||
.pfs-legal-item, .pfs-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--pfs-text-muted);
|
||||
}
|
||||
|
||||
.pfs-legal-item svg, .pfs-version svg {
|
||||
color: #f97316;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Locks */
|
||||
.pfs-locked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.pfs-lock-icon {
|
||||
margin-left: auto;
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ChevronDown,
|
||||
LayoutDashboard,
|
||||
Car,
|
||||
Users,
|
||||
Wrench,
|
||||
Activity,
|
||||
Radio,
|
||||
AlertTriangle,
|
||||
Store,
|
||||
ClipboardList,
|
||||
LogOut,
|
||||
ShieldAlert,
|
||||
Building2,
|
||||
Award,
|
||||
GitBranch,
|
||||
Lock,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import logoOestePan from '@/assets/Img/Util/Oeste_Pan/Logo Oeste Pan.png';
|
||||
import './PrafrotSidebar.css';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Estatísticas',
|
||||
icon: LayoutDashboard,
|
||||
path: '/plataforma/oest-pan/estatisticas'
|
||||
},
|
||||
{
|
||||
id: 'cadastros',
|
||||
label: 'Cadastros',
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{ id: 'c-veiculos', label: 'Veículos', path: '/plataforma/oest-pan/veiculos', icon: Car },
|
||||
{ id: 'c-dispatcher', label: 'Dispatcher', path: '/plataforma/oest-pan/dispatcher', icon: ClipboardList },
|
||||
// { id: 'c-motoristas', label: 'Motoristas', path: '/plataforma/oest-pan/motoristas', icon: Users, disabled: true, disabledReason: 'Funcionalidade em manutenção' },
|
||||
{ id: 'c-oficinas', label: 'Oficinas', path: '/plataforma/oest-pan/oficinas', icon: Store }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gerencia',
|
||||
label: 'Gerência',
|
||||
icon: Activity,
|
||||
children: [
|
||||
{ id: 'g-monitoramento', label: 'Monitoramento', path: '/plataforma/oest-pan/monitoramento', icon: Radio },
|
||||
{ id: 'g-status', label: 'Status Frota', path: '/plataforma/oest-pan/status', icon: Activity },
|
||||
{ id: 'g-manutencao', label: 'Manutenção', path: '/plataforma/oest-pan/manutencao', icon: Wrench },
|
||||
{ id: 'g-sinistros', label: 'Sinistros', path: '/plataforma/oest-pan/sinistros', icon: AlertTriangle }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: 'Configurações',
|
||||
icon: Settings,
|
||||
path: '/plataforma/oest-pan/configuracoes'
|
||||
}
|
||||
];
|
||||
|
||||
// Optimized MenuItem component outside main render loop
|
||||
const MenuItem = React.memo(({ item, isSub = false, isCollapsed, expandedItems, toggleExpand, pathname, searchTerm }) => {
|
||||
const Icon = item.icon;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isExpanded = expandedItems[item.id];
|
||||
const isActive = pathname.startsWith(item.path) || (hasChildren && item.children.some(c => pathname.startsWith(c.path)));
|
||||
const isLocked = item.disabled;
|
||||
|
||||
// Filter sub-items if searching
|
||||
const subItems = item.children?.filter(child =>
|
||||
!searchTerm || child.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
isSub ? "pfs-sublink" : "pfs-link",
|
||||
isActive && !hasChildren && "active",
|
||||
isLocked && "pfs-locked"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isLocked) return;
|
||||
if (hasChildren) toggleExpand(item.id);
|
||||
}}
|
||||
title={isLocked ? item.disabledReason : (isCollapsed ? item.label : '')}
|
||||
>
|
||||
<Icon size={isSub ? 16 : 20} className="pfs-icon" />
|
||||
{(!isCollapsed || isSub) && <span className="pfs-label">{item.label}</span>}
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("pfs-chevron", isExpanded && "expanded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLocked && !isCollapsed && (
|
||||
<Lock size={12} className="pfs-lock-icon" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pfs-item-wrapper">
|
||||
{item.path && !hasChildren && !isLocked ? (
|
||||
<Link to={item.path} style={{ textDecoration: 'none' }}>
|
||||
{content}
|
||||
</Link>
|
||||
) : content}
|
||||
|
||||
<AnimatePresence>
|
||||
{hasChildren && isExpanded && !isCollapsed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="pfs-submenu"
|
||||
>
|
||||
{subItems?.map(child => (
|
||||
<MenuItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
isSub
|
||||
isCollapsed={isCollapsed}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpand={toggleExpand}
|
||||
pathname={pathname}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
|
||||
export const OestPanSidebar = ({ isCollapsed, onToggle }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedItems, setExpandedItems] = useState({ cadastros: true, gerencia: true });
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthContext();
|
||||
|
||||
const toggleExpand = React.useCallback((id) => {
|
||||
setExpandedItems(prev => ({
|
||||
...prev,
|
||||
[id]: !prev[id]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm) return MENU_ITEMS;
|
||||
|
||||
return MENU_ITEMS.filter(item => {
|
||||
const matchParent = item.label.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchChildren = item.children?.some(child =>
|
||||
child.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return matchParent || matchChildren;
|
||||
});
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout('auth_oestepan');
|
||||
window.location.href = '/plataforma/oest-pan/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={cn("pfs-container", isCollapsed && "collapsed")}>
|
||||
<div className="pfs-toggle-container">
|
||||
<button className="pfs-toggle-btn" onClick={onToggle}>
|
||||
{isCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pfs-search-wrapper">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--pfs-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
className="pfs-search-input"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="pfs-nav-content custom-scrollbar">
|
||||
{filteredItems.map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isCollapsed={isCollapsed}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpand={toggleExpand}
|
||||
pathname={location.pathname}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<footer className="pfs-footer">
|
||||
<div className="pfs-brand">
|
||||
<img src={logoOestePan} alt="OP" className="w-12 h-12 object-contain" />
|
||||
{!isCollapsed && (
|
||||
<div className="pfs-brand-info">
|
||||
<span className="pfs-brand-name">Oeste <span>Pan</span></span>
|
||||
<span className="pfs-app-sub">Fleet Management</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pfs-user-section">
|
||||
<div className={cn("pfs-user-card", isCollapsed && "justify-center")}>
|
||||
<Avatar className="h-8 w-8 border border-[var(--pfs-border)]">
|
||||
<AvatarImage src={`https://ui-avatars.com/api/?name=${user?.name || 'User'}&background=f97316&color=141414`} />
|
||||
<AvatarFallback className="bg-orange-500 text-zinc-950 font-bold text-xs">
|
||||
{(user?.name || 'U').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="pfs-user-data">
|
||||
<span className="pfs-user-name">{user?.name || 'Usuário'}</span>
|
||||
<button onClick={handleLogout} className="pfs-logout-link">
|
||||
<LogOut size={10} /> Sair
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useAvailability = create((set, get) => ({
|
||||
availabilities: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchAvailabilities: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getAvailability();
|
||||
set({ availabilities: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createAvailability: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createAvailability(payload);
|
||||
toast.success('Disponibilidade registrada com sucesso!');
|
||||
get().fetchAvailabilities();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateAvailability: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateAvailability(id, payload);
|
||||
toast.success('Disponibilidade atualizada com sucesso!');
|
||||
get().fetchAvailabilities();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteAvailability: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este registro de disponibilidade?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteAvailability(id);
|
||||
toast.success('Disponibilidade excluída com sucesso!');
|
||||
get().fetchAvailabilities();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { useFeedbackStore } from '../components/FeedbackNotification';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
const notify = (type, title, message) => useFeedbackStore.getState().notify(type, title, message);
|
||||
|
||||
export const useClaims = create((set, get) => ({
|
||||
claims: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchClaims: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getClaims();
|
||||
set({ claims: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
notify('error', 'Falha na Busca', friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createClaim: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createClaims(payload);
|
||||
notify('success', 'Evento Registrado', 'O sinistro/devolução foi salvo com sucesso.');
|
||||
get().fetchClaims();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Falha no Registro', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateClaim: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateClaims(id, payload);
|
||||
notify('success', 'Registro Atualizado', 'As informações do evento foram modificadas.');
|
||||
get().fetchClaims();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Atualização', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteClaim: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este registro de sinistro/devolução?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteClaim(id);
|
||||
notify('success', 'Registro Removido', 'O registro foi excluído do sistema.');
|
||||
get().fetchClaims();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Falha na Exclusão', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
|
||||
import { create } from 'zustand';
|
||||
import { dispatcherService } from '../services/dispatcherService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useDispatcher = create((set) => ({
|
||||
data: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchData: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const result = await dispatcherService.getDispatcherData();
|
||||
// Handle potential API variations (e.g. { data: [...] } vs [...])
|
||||
const list = Array.isArray(result) ? result : (result?.data || []);
|
||||
set({ data: list });
|
||||
} catch (error) {
|
||||
console.error('Error fetching dispatcher data:', error);
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useDrivers = create((set, get) => ({
|
||||
drivers: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchDrivers: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getDrivers();
|
||||
set({ drivers: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useFleetLists = create((set, get) => ({
|
||||
listsConfig: null,
|
||||
statusFrotaOptions: [],
|
||||
statusManutencaoOptions: [],
|
||||
motivoAtendimentoOptions: [],
|
||||
responsaveisOptions: [],
|
||||
validacaoOptions: [],
|
||||
aprovacaoOptions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchListsConfig: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const config = await prafrotService.getListasConfig();
|
||||
set({ listsConfig: config });
|
||||
|
||||
// Se tivermos a configuração para status_frota, já buscamos as opções
|
||||
if (config && config.status_frota && config.status_frota.rota) {
|
||||
await get().fetchListOptions('status_frota', config.status_frota.rota);
|
||||
}
|
||||
|
||||
// Se tivermos a configuração para status_manutencao, buscamos as opções
|
||||
if (config && config.status_manutencao && config.status_manutencao.rota) {
|
||||
await get().fetchListOptions('status_manutencao', config.status_manutencao.rota);
|
||||
}
|
||||
|
||||
// Se tivermos a configuração para motivo_atendimento, buscamos as opções
|
||||
if (config && config.motivo_atendimento && config.motivo_atendimento.rota) {
|
||||
await get().fetchListOptions('motivo_atendimento', config.motivo_atendimento.rota);
|
||||
}
|
||||
|
||||
// Se tivermos a configuração para responsaveis, buscamos as opções
|
||||
if (config && config.responsaveis && config.responsaveis.rota) {
|
||||
await get().fetchListOptions('responsaveis', config.responsaveis.rota);
|
||||
}
|
||||
|
||||
// Se tivermos a configuração para validacao, buscamos as opções
|
||||
if (config && config.validacao && config.validacao.rota) {
|
||||
await get().fetchListOptions('validacao', config.validacao.rota);
|
||||
}
|
||||
|
||||
// Se tivermos a configuração para aprovacao, buscamos as opções
|
||||
if (config && config.aprovacao && config.aprovacao.rota) {
|
||||
await get().fetchListOptions('aprovacao', config.aprovacao.rota);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar configuração de listas:', error);
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchListOptions: async (listName, route) => {
|
||||
try {
|
||||
const data = await prafrotService.getListasByRoute(route);
|
||||
let options = Array.isArray(data) ? data : (data.data || []);
|
||||
|
||||
if (options.length > 0 && typeof options[0] === 'object') {
|
||||
if (listName === 'status_frota') {
|
||||
options = options.map(item => item.status_frota).filter(Boolean);
|
||||
} else if (listName === 'status_manutencao') {
|
||||
options = options.map(item => item.status_manutencao || item.status || Object.values(item)[0]).filter(Boolean);
|
||||
} else if (listName === 'motivo_atendimento') {
|
||||
options = options.map(item => item.motivo_atendimento || item.motivo || Object.values(item)[0]).filter(Boolean);
|
||||
} else if (listName === 'responsaveis') {
|
||||
options = options.map(item => item.responsaveis || item.responsavel || item.nome || Object.values(item)[0]).filter(Boolean);
|
||||
} else if (listName === 'validacao') {
|
||||
options = options.map(item => item.validacao || item.validacao_financeiro || Object.values(item)[0]).filter(Boolean);
|
||||
} else if (listName === 'aprovacao') {
|
||||
options = options.map(item => item.aprovacao || item.nome || item.responsavel || Object.values(item)[0]).filter(Boolean);
|
||||
} else {
|
||||
options = options.map(item => Object.values(item)[0] || JSON.stringify(item));
|
||||
}
|
||||
} else {
|
||||
options = options.map(String);
|
||||
}
|
||||
|
||||
if (listName === 'status_frota') {
|
||||
set({ statusFrotaOptions: options });
|
||||
} else if (listName === 'status_manutencao') {
|
||||
set({ statusManutencaoOptions: options });
|
||||
} else if (listName === 'motivo_atendimento') {
|
||||
set({ motivoAtendimentoOptions: options });
|
||||
} else if (listName === 'responsaveis') {
|
||||
set({ responsaveisOptions: options });
|
||||
} else if (listName === 'validacao') {
|
||||
set({ validacaoOptions: options });
|
||||
} else if (listName === 'aprovacao') {
|
||||
set({ aprovacaoOptions: options });
|
||||
}
|
||||
|
||||
return options;
|
||||
} catch (error) {
|
||||
console.error(`Erro ao buscar opções para ${listName}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { useFeedbackStore } from '../components/FeedbackNotification';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
const notify = (type, title, message) => useFeedbackStore.getState().notify(type, title, message);
|
||||
|
||||
export const useMaintenance = create((set, get) => ({
|
||||
maintenances: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchMaintenances: async (type = 'total') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
let list = [];
|
||||
if (type === 'total') {
|
||||
const data = await prafrotService.getMaintenance();
|
||||
list = Array.isArray(data) ? data : (data.data || []);
|
||||
} else {
|
||||
const data = await prafrotService.getAbertoFechado();
|
||||
// Concatena abertas e fechadas para manter o estado global se necessário,
|
||||
// mas a tela de manutenção gerencia o estado `abertoFechadoData` via hook.
|
||||
// No entanto, para padronizar as ações (save/update), vamos manter o fetchMaintenances funcional.
|
||||
const abertas = data.abertas || data.aberto || data.abertos || [];
|
||||
const fechadas = data.fechadas || data.fechado || data.fechados || [];
|
||||
list = [...abertas, ...fechadas];
|
||||
}
|
||||
|
||||
const normalized = list.map(m => ({ ...m, previsao_entrega: m.previsao_entrega ?? m.previcao_entrega }));
|
||||
set({ maintenances: normalized });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
notify('error', 'Falha na Busca', friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Helper para decidir qual rota atualizar baseado no contexto (geralmente chamado após mutações)
|
||||
refreshMaintenances: async (currentFilter = 'total') => {
|
||||
const fetch = get().fetchMaintenances;
|
||||
const getGroups = get().getAbertoFechado;
|
||||
|
||||
if (currentFilter === 'total') {
|
||||
await fetch('total');
|
||||
} else {
|
||||
// Atualiza ambos para garantir consistência
|
||||
await fetch('total');
|
||||
await getGroups();
|
||||
}
|
||||
},
|
||||
|
||||
createMaintenance: async (payload, files = null) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createMaintenance(payload, files);
|
||||
notify('success', 'Cadastro Concluído', 'Manutenção registrada com sucesso no sistema.');
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Falha no Registro', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMaintenance: async (id, payload, files = null) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateMaintenance(id, payload, files);
|
||||
notify('success', 'Atualização!', 'Dados da manutenção atualizados.');
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Atualização', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMaintenanceBatch: async (ids, status) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateMaintenanceBatch(ids, status);
|
||||
|
||||
notify('success', 'Lote Atualizado', `${ids.length} manutenções foram atualizadas.`);
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Falha em Massa', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
calcularDifOrcamento: async (orcamento_inicial, orcamento_final) => {
|
||||
try {
|
||||
const data = await prafrotService.calcularDifOrcamento(orcamento_inicial, orcamento_final);
|
||||
const valor = data?.dif_orcamento ?? data?.valor ?? (typeof data === 'number' ? data : null);
|
||||
return valor;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteMaintenance: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir esta manutenção?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteMaintenance(id);
|
||||
notify('success', 'Removido', 'Registro de manutenção excluído.');
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Remoção', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fecharManutencao: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.fecharManutencao(id);
|
||||
notify('success', 'Manutenção Fechada', 'Manutenção fechada com sucesso.');
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Fechar', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
abrirManutencao: async (id) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.abrirManutencao(id);
|
||||
notify('success', 'Manutenção Aberta', 'Manutenção aberta com sucesso.');
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Abrir', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
getAbertoFechado: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const data = await prafrotService.getAbertoFechado();
|
||||
// Atualiza também a lista principal 'maintenances' para refletir as mudanças
|
||||
const abertas = data.abertas || data.aberto || data.abertos || [];
|
||||
const fechadas = data.fechadas || data.fechado || data.fechados || [];
|
||||
const normalized = [...abertas, ...fechadas].map(m => ({ ...m, previsao_entrega: m.previsao_entrega ?? m.previcao_entrega }));
|
||||
set({ maintenances: normalized });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Busca', friendlyMsg);
|
||||
return null;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Histórico de manutenção
|
||||
getHistoricoCompleto: async () => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoCompleto();
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoDetalhado: async () => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoDetalhado();
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoResumido: async () => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoResumido();
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoEstatisticas: async () => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoEstatisticas();
|
||||
return data;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Estatísticas', friendlyMsg);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoPeriodo: async (params) => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoPeriodo(params);
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoTop: async () => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoTop();
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getHistoricoPorPlaca: async (placa) => {
|
||||
try {
|
||||
const data = await prafrotService.getHistoricoPorPlaca(placa);
|
||||
return Array.isArray(data) ? data : (data.data || []);
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro ao Buscar Histórico', friendlyMsg);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useMoki = create((set, get) => ({
|
||||
mokis: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchMokis: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getMoki();
|
||||
set({ mokis: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createMoki: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createMoki(payload);
|
||||
toast.success('Moki registrado com sucesso!');
|
||||
get().fetchMokis();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMoki: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateMoki(id, payload);
|
||||
toast.success('Moki atualizado com sucesso!');
|
||||
get().fetchMokis();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteMoki: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este checklist?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteMoki(id);
|
||||
toast.success('Checklist excluído com sucesso!');
|
||||
get().fetchMokis();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useMonitoring = create((set, get) => ({
|
||||
monitorings: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchMonitoring: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getMonitoring();
|
||||
set({ monitorings: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createMonitoring: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createMonitoring(payload);
|
||||
toast.success('Monitoramento registrado com sucesso!');
|
||||
get().fetchMonitoring();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateMonitoring: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateMonitoring(id, payload);
|
||||
toast.success('Monitoramento atualizado com sucesso!');
|
||||
get().fetchMonitoring();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteMonitoring: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este monitoramento?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteMonitoring(id);
|
||||
toast.success('Monitoramento excluído com sucesso!');
|
||||
get().fetchMonitoring();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { prafrotStatisticsService } from '../services/prafrotStatisticsService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const usePrafrotStatistics = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState({
|
||||
totalPlacas: 0,
|
||||
placasPorBase: [],
|
||||
placasPorAno: [],
|
||||
placasPorCategoria: [],
|
||||
placasPorModelo: [],
|
||||
placasPorTipo: [],
|
||||
placasPorStatus: [],
|
||||
placasPorManutencao: [],
|
||||
placasPorProprietario: [],
|
||||
// placasPorDisponibilidade: [],
|
||||
placasPorUnidade: [],
|
||||
placasPorSinistro: [],
|
||||
placasPorManutencaoStatus: [],
|
||||
quantitativoManutencao: []
|
||||
});
|
||||
|
||||
const fetchStatistics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [
|
||||
totalRes,
|
||||
baseRes,
|
||||
anoRes,
|
||||
catRes,
|
||||
modRes,
|
||||
tipoRes,
|
||||
statusRes,
|
||||
manutRes,
|
||||
propRes,
|
||||
// dispRes,
|
||||
unidRes,
|
||||
sinRes,
|
||||
mStatusRes,
|
||||
qManutRes
|
||||
] = await Promise.all([
|
||||
prafrotStatisticsService.getTotalPlacas(),
|
||||
prafrotStatisticsService.getPlacasPorBase(),
|
||||
prafrotStatisticsService.getPlacasPorAno(),
|
||||
prafrotStatisticsService.getPlacasPorCategoria(),
|
||||
prafrotStatisticsService.getPlacasPorModelo(),
|
||||
prafrotStatisticsService.getPlacasPorTipo(),
|
||||
prafrotStatisticsService.getPlacasPorStatus(),
|
||||
prafrotStatisticsService.getPlacasPorManutencao(),
|
||||
prafrotStatisticsService.getPlacasPorProprietario(),
|
||||
// prafrotStatisticsService.getPlacasPorDisponibilidade(),
|
||||
prafrotStatisticsService.getPlacasPorUnidade(),
|
||||
prafrotStatisticsService.getPlacasPorSinistro(),
|
||||
prafrotStatisticsService.getPlacasPorManutencaoStatus(),
|
||||
prafrotStatisticsService.getQuantitativoManutencao()
|
||||
]);
|
||||
|
||||
setData({
|
||||
totalPlacas: totalRes?.total_placas || totalRes?.[0]?.total_placas || 0,
|
||||
placasPorBase: Array.isArray(baseRes) ? baseRes : [],
|
||||
placasPorAno: Array.isArray(anoRes) ? anoRes : [],
|
||||
placasPorCategoria: Array.isArray(catRes) ? catRes : [],
|
||||
placasPorModelo: Array.isArray(modRes) ? modRes : [],
|
||||
placasPorTipo: Array.isArray(tipoRes) ? tipoRes : [],
|
||||
placasPorStatus: Array.isArray(statusRes) ? statusRes : [],
|
||||
placasPorManutencao: Array.isArray(manutRes) ? manutRes : [],
|
||||
placasPorProprietario: Array.isArray(propRes) ? propRes : [],
|
||||
// placasPorDisponibilidade: Array.isArray(dispRes) ? dispRes : [],
|
||||
placasPorUnidade: Array.isArray(unidRes) ? unidRes : [],
|
||||
placasPorSinistro: Array.isArray(sinRes) ? sinRes : [],
|
||||
placasPorManutencaoStatus: Array.isArray(mStatusRes) ? mStatusRes : [],
|
||||
quantitativoManutencao: Array.isArray(qManutRes) ? qManutRes : []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar estatísticas:', error);
|
||||
toast.error('Erro ao carregar dados estatísticos');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, [fetchStatistics]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
data,
|
||||
refresh: fetchStatistics
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useStatus = create((set, get) => ({
|
||||
statusList: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchStatus: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getStatus();
|
||||
set({ statusList: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createStatus: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createStatus(payload);
|
||||
toast.success('Status registrado com sucesso!');
|
||||
get().fetchStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateStatus: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateStatus(id, payload);
|
||||
toast.success('Status atualizado com sucesso!');
|
||||
get().fetchStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateStatusInline: async (id, statusFrota) => {
|
||||
// Agora utiliza o endpoint em lote mesmo para uma única atualização inline
|
||||
try {
|
||||
await prafrotService.updateStatusBatch([id], statusFrota);
|
||||
toast.success('Status atualizado!');
|
||||
get().fetchStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
updateStatusBatch: async (ids, statusFrota) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateStatusBatch(ids, statusFrota);
|
||||
toast.success(`${ids.length} itens atualizados com sucesso!`);
|
||||
get().fetchStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteStatus: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este registro de status?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteStatus(id);
|
||||
toast.success('Status excluído com sucesso!');
|
||||
get().fetchStatus();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { useFeedbackStore } from '../components/FeedbackNotification';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
const notify = (type, title, message) => useFeedbackStore.getState().notify(type, title, message);
|
||||
|
||||
export const useVehicles = create((set, get) => ({
|
||||
vehicles: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchVehicles: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getVehicles();
|
||||
// Ensure data is an array
|
||||
set({ vehicles: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
notify('error', 'Falha na Busca', friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createVehicle: async (vehicleData) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createVehicle(vehicleData);
|
||||
notify('success', 'Cadastro Concluído', 'Veículo registrado com sucesso no sistema.');
|
||||
get().fetchVehicles(); // Refresh list
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Falha no Cadastro', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateVehicle: async (id, vehicleData) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateVehicle(id, vehicleData);
|
||||
notify('success', 'Atualização!', 'Os dados do veículo foram atualizados.');
|
||||
get().fetchVehicles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Atualização', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateVehicleStatus: async (id, statusFrota) => {
|
||||
try {
|
||||
await prafrotService.updateVehicleStatus(id, statusFrota);
|
||||
notify('success', 'Status Alterado', 'O status do veículo foi modificado com sucesso.');
|
||||
get().fetchVehicles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro de Status', friendlyMsg);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteVehicle: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir este veículo?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteVehicle(id);
|
||||
notify('success', 'Registro Removido', 'O veículo foi removido permanentemente.');
|
||||
get().fetchVehicles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
notify('error', 'Erro na Remoção', friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { create } from 'zustand';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
import { extractFriendlyMessage } from '../utils/errorMessages';
|
||||
|
||||
export const useWorkshops = create((set, get) => ({
|
||||
workshops: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchWorkshops: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await prafrotService.getWorkshops();
|
||||
set({ workshops: Array.isArray(data) ? data : (data.data || []) });
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
set({ error: friendlyMsg });
|
||||
toast.error(friendlyMsg);
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createWorkshop: async (payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.createWorkshop(payload);
|
||||
toast.success('Oficina registrada com sucesso!');
|
||||
get().fetchWorkshops();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateWorkshop: async (id, payload) => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.updateWorkshop(id, payload);
|
||||
toast.success('Oficina atualizada com sucesso!');
|
||||
get().fetchWorkshops();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deleteWorkshop: async (id) => {
|
||||
if (!window.confirm('Tem certeza que deseja excluir esta oficina?')) return;
|
||||
set({ loading: true });
|
||||
try {
|
||||
await prafrotService.deleteWorkshop(id);
|
||||
toast.success('Oficina excluída com sucesso!');
|
||||
get().fetchWorkshops();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
toast.error(friendlyMsg);
|
||||
return false;
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import {
|
||||
Menu,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDocumentMetadata } from '@/hooks/useDocumentMetadata';
|
||||
import { OestPanSidebar } from '../components/PrafrotSidebar';
|
||||
import { FeedbackContainer } from '../components/FeedbackNotification';
|
||||
|
||||
export const OestPanLayout = () => {
|
||||
useDocumentMetadata('Oeste Pan', 'oest-pan');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex min-h-screen font-inter selection:bg-orange-500/30 theme-frota",
|
||||
isDarkMode ? "dark bg-[#141414] text-slate-100" : "bg-slate-50 text-slate-900"
|
||||
)}>
|
||||
<FeedbackContainer />
|
||||
{/* New Sidebar Component */}
|
||||
<OestPanSidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggle={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main
|
||||
className={cn(
|
||||
"flex-1 transition-all duration-300 min-h-screen flex flex-col min-w-0",
|
||||
isSidebarCollapsed ? "ml-[100px]" : "ml-[280px]"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className={cn(
|
||||
"h-16 px-8 sticky top-0 z-40 backdrop-blur-md border-b flex items-center justify-between",
|
||||
isDarkMode ? "bg-[#141414]/80 border-[#2a2a2a]" : "bg-slate-50/80 border-slate-200"
|
||||
)}>
|
||||
{/* <div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className="text-slate-400 hover:text-orange-500 transition-colors p-2 rounded-xl hover:bg-orange-500/10"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
</div> */}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className={cn(
|
||||
"p-2 rounded-full transition-colors",
|
||||
isDarkMode ? "text-slate-400 hover:text-yellow-400" : "text-slate-500 hover:text-orange-500"
|
||||
)}
|
||||
>
|
||||
{isDarkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6 min-w-0 overflow-hidden">
|
||||
<div className={cn(
|
||||
"h-full w-full rounded-2xl border overflow-hidden flex flex-col shadow-sm",
|
||||
isDarkMode
|
||||
? "bg-[#1b1b1b] border-zinc-800 shadow-black/20"
|
||||
: "bg-white border-slate-200 shadow-slate-200/50"
|
||||
)}>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { OestPanLayout } from './layout/PrafrotLayout';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
// Lazy loading views
|
||||
const VehiclesView = lazy(() => import('./views/VehiclesView'));
|
||||
const MaintenanceView = lazy(() => import('./views/MaintenanceView'));
|
||||
const AvailabilityView = lazy(() => import('./views/AvailabilityView'));
|
||||
const MokiView = lazy(() => import('./views/MokiView'));
|
||||
const StatusView = lazy(() => import('./views/StatusView'));
|
||||
const MonitoringView = lazy(() => import('./views/MonitoringView'));
|
||||
const WorkshopsView = lazy(() => import('./views/WorkshopsView'));
|
||||
const ClaimsView = lazy(() => import('./views/ClaimsView'));
|
||||
const DriversView = lazy(() => import('./views/DriversView'));
|
||||
const LoginView = lazy(() => import('./views/LoginView'));
|
||||
const StatisticsView = lazy(() => import('./views/StatisticsView'));
|
||||
const DispatcherView = lazy(() => import('./views/DispatcherView'));
|
||||
const ConfigView = lazy(() => import('./views/ConfigView'));
|
||||
|
||||
// Loading component matching Prafrot theme
|
||||
const OestPanLoader = () => (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#141414]">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-orange-500 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-500/20 animate-bounce">
|
||||
<Zap size={32} className="text-[#1c1c1c]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="absolute -inset-4 border-2 border-orange-500/20 border-t-orange-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-orange-500 text-[10px] font-bold uppercase tracking-[0.3em] animate-pulse">Oeste Pan System</span>
|
||||
<span className="text-slate-600 text-[8px] font-bold uppercase tracking-widest">Carregando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const OestPanRoutes = () => {
|
||||
return (
|
||||
<Suspense fallback={<OestPanLoader />}>
|
||||
<Routes>
|
||||
<Route element={<OestPanLayout />}>
|
||||
<Route path="veiculos" element={<VehiclesView />} />
|
||||
<Route path="manutencao" element={<MaintenanceView />} />
|
||||
<Route path="disponibilidade" element={<AvailabilityView />} />
|
||||
<Route path="moki" element={<MokiView />} />
|
||||
<Route path="status" element={<StatusView />} />
|
||||
<Route path="monitoramento" element={<MonitoringView />} />
|
||||
<Route path="oficinas" element={<WorkshopsView />} />
|
||||
<Route path="sinistros" element={<ClaimsView />} />
|
||||
<Route path="motoristas" element={<DriversView />} />
|
||||
<Route path="estatisticas" element={<StatisticsView />} />
|
||||
<Route path="dispatcher" element={<DispatcherView />} />
|
||||
<Route path="configuracoes" element={<ConfigView />} />
|
||||
|
||||
<Route index element={<Navigate to="/plataforma/oest-pan/estatisticas" replace />} />
|
||||
<Route path="*" element={<Navigate to="/plataforma/oest-pan/estatisticas" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
// Export LoginView separately for use in App.jsx
|
||||
export { LoginView };
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
|
||||
import api from '@/services/api';
|
||||
import { handleRequest } from '@/services/serviceUtils';
|
||||
|
||||
const ENDPOINT = '/dispatcher';
|
||||
|
||||
export const dispatcherService = {
|
||||
getDispatcherData: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINT}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
import api from '@/services/api';
|
||||
import { handleRequest } from '@/services/serviceUtils';
|
||||
|
||||
// Endpoints definidos pelo usuário
|
||||
const ENDPOINTS = {
|
||||
VEHICLES: '/cadastro_frota',
|
||||
MAINTENANCE: '/manutencao_frota',
|
||||
AVAILABILITY: '/disponibilidade_frota',
|
||||
MOKI: '/moki_frota',
|
||||
STATUS: '/status_frota',
|
||||
MONITORING: '/monitoramento_frota',
|
||||
CLAIMS: '/sinistro_devolucao_venda_frota',
|
||||
WORKSHOPS: '/oficinas_frota',
|
||||
AUTH: '/auth_monitoramento'
|
||||
};
|
||||
|
||||
export const prafrotService = {
|
||||
// --- Autenticação ---
|
||||
login: (credentials) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.AUTH, credentials);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Cadastro de Frota (Veículos) ---
|
||||
getVehicles: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.VEHICLES}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createVehicle: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.VEHICLES, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateVehicle: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.VEHICLES}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteVehicle: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.VEHICLES}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Manutenção ---
|
||||
getMaintenance: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.MAINTENANCE}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
calcularDifOrcamento: (orcamento_inicial, orcamento_final) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(`${ENDPOINTS.MAINTENANCE}/calcular_dif_orcamento`, {
|
||||
orcamento_inicial,
|
||||
orcamento_final
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createMaintenance: (payload, files = null) => handleRequest({
|
||||
apiFn: async () => {
|
||||
// Envia previsao_entrega diretamente
|
||||
const body = { ...payload };
|
||||
if (files && (files.pdf_orcamento || files.nota_fiscal)) {
|
||||
const form = new FormData();
|
||||
form.append('data_json', JSON.stringify(body));
|
||||
if (files.pdf_orcamento instanceof File) form.append('pdf_orcamento', files.pdf_orcamento);
|
||||
if (files.nota_fiscal instanceof File) form.append('nota_fiscal', files.nota_fiscal);
|
||||
const { data } = await api.post(ENDPOINTS.MAINTENANCE, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
const { data } = await api.post(ENDPOINTS.MAINTENANCE, body);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateMaintenance: (id, payload, files = null) => handleRequest({
|
||||
apiFn: async () => {
|
||||
// Envia previsao_entrega diretamente (o backend deve estar atualizado ou o frontend deve padronizar)
|
||||
const body = { ...payload };
|
||||
if (files && (files.pdf_orcamento || files.nota_fiscal)) {
|
||||
const form = new FormData();
|
||||
form.append('data_json', JSON.stringify(body));
|
||||
if (files.pdf_orcamento instanceof File) form.append('pdf_orcamento', files.pdf_orcamento);
|
||||
if (files.nota_fiscal instanceof File) form.append('nota_fiscal', files.nota_fiscal);
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/${id}`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/${id}`, body);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateMaintenanceBatch: (ids, status) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/edit/em_lote`, { ids, status });
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteMaintenance: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
// Fechar/Abrir manutenção
|
||||
fecharManutencao: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/fechar_manutencao/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
abrirManutencao: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MAINTENANCE}/abrir_manutencao/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getAbertoFechado: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.MAINTENANCE}/aberto_fechado/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
// Histórico de manutenção
|
||||
getHistoricoCompleto: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/completo');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoDetalhado: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/detalhado');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoResumido: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/resumido');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoEstatisticas: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/estatisticas');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoPeriodo: (params) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/periodo', { params });
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoTop: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/historico/top');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
getHistoricoPorPlaca: (placa) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`/historico/placa/${placa}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Disponibilidade ---
|
||||
getAvailability: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.AVAILABILITY}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createAvailability: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.AVAILABILITY, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateAvailability: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.AVAILABILITY}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteAvailability: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.AVAILABILITY}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Moki (Checklists) ---
|
||||
getMoki: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.MOKI}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createMoki: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.MOKI, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateMoki: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MOKI}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteMoki: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MOKI}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Status Frota ---
|
||||
getStatus: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.STATUS}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createStatus: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.STATUS, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateStatus: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.STATUS}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateStatusBatch: (ids, statusFrota) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.STATUS}/edit/em_lote`, { ids, status_frota: statusFrota });
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteStatus: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.STATUS}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Monitoramento ---
|
||||
getMonitoring: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.MONITORING}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createMonitoring: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.MONITORING, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateMonitoring: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MONITORING}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteMonitoring: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.MONITORING}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Sinistro / Devolução ---
|
||||
getClaims: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.CLAIMS}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createClaims: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.CLAIMS, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateClaims: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.CLAIMS}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteClaim: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
// Usando a rota específica fornecida pelo usuário para exclusão de sinistro
|
||||
const { data } = await api.put(`/sinistro_frota/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Oficinas ---
|
||||
getWorkshops: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`${ENDPOINTS.WORKSHOPS}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
createWorkshop: (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(ENDPOINTS.WORKSHOPS, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
updateWorkshop: (id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.WORKSHOPS}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
deleteWorkshop: (id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`${ENDPOINTS.WORKSHOPS}/delete/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Motoristas (Drivers) ---
|
||||
getDrivers: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/motoristas/listagem');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Listas de Configuração (Config Lists) ---
|
||||
getListasConfig: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/listas_frota/config');
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
getListasByRoute: (route) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get(`/listas_frota/${route}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
createLista: (route, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post(`/listas_frota/${route}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
updateLista: (route, id, payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`/listas_frota/${route}/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
deleteLista: (route, id) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.delete(`/listas_frota/${route}/${id}`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Update Vehicle Status (Inline Edit) ---
|
||||
updateVehicleStatus: (idVehicle, statusFrota) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put('/status_frota/edit/em_lote', {
|
||||
ids: [idVehicle],
|
||||
status_frota: statusFrota
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
||||
// --- Update Status Batch (Bulk Edit) ---
|
||||
updateStatusBatch: (ids, statusFrota) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put('/status_frota/edit/em_lote', {
|
||||
ids,
|
||||
status_frota: statusFrota
|
||||
});
|
||||
return data;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import api from '@/services/api';
|
||||
import { handleRequest } from '@/services/serviceUtils';
|
||||
|
||||
const ENDPOINTS = {
|
||||
TOTAL_PLACAS: '/dashs_frota/cadastro_frota/total_placas',
|
||||
PLACAS_POR_BASE: '/dashs_frota/cadastro_frota/placas_por_base',
|
||||
PLACAS_POR_ANO: '/dashs_frota/cadastro_frota/placas_por_ano',
|
||||
PLACAS_POR_CATEGORIA: '/dashs_frota/cadastro_frota/placas_por_categoria',
|
||||
PLACAS_POR_MODELO: '/dashs_frota/cadastro_frota/placas_por_modelo',
|
||||
PLACAS_POR_TIPO: '/dashs_frota/cadastro_frota/placas_por_tipo',
|
||||
PLACAS_POR_STATUS: '/dashs_frota/status_frota/placas_por_status',
|
||||
PLACAS_POR_MANUTENCAO: '/dashs_frota/status_frota/placas_por_manutencao',
|
||||
PLACAS_POR_PROPRIETARIO: '/dashs_frota/status_frota/placas_por_proprietario',
|
||||
// PLACAS_POR_DISPONIBILIDADE: '/dashs_frota/disponibilidade/placas',
|
||||
PLACAS_POR_UNIDADE: '/dashs_frota/monitoramento/placas_por_unidade',
|
||||
PLACAS_POR_SINISTRO: '/dashs_frota/sinistro/placas_por_status',
|
||||
PLACAS_POR_MANUTENCAO_STATUS: '/dashs_frota/manutencao/placas_por_manutencao_status',
|
||||
QUANTITATIVO_MANUTENCAO: '/quantitativo_manutencao'
|
||||
};
|
||||
|
||||
export const prafrotStatisticsService = {
|
||||
getTotalPlacas: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.TOTAL_PLACAS)
|
||||
}),
|
||||
|
||||
getPlacasPorBase: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_BASE)
|
||||
}),
|
||||
|
||||
getPlacasPorAno: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_ANO)
|
||||
}),
|
||||
|
||||
getPlacasPorCategoria: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_CATEGORIA)
|
||||
}),
|
||||
|
||||
getPlacasPorModelo: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_MODELO)
|
||||
}),
|
||||
|
||||
getPlacasPorTipo: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_TIPO)
|
||||
}),
|
||||
|
||||
getPlacasPorStatus: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_STATUS)
|
||||
}),
|
||||
|
||||
getPlacasPorManutencao: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_MANUTENCAO)
|
||||
}),
|
||||
|
||||
getPlacasPorProprietario: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_PROPRIETARIO)
|
||||
}),
|
||||
|
||||
/* getPlacasPorDisponibilidade: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_DISPONIBILIDADE)
|
||||
}), */
|
||||
|
||||
getPlacasPorUnidade: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_UNIDADE)
|
||||
}),
|
||||
|
||||
getPlacasPorSinistro: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_SINISTRO)
|
||||
}),
|
||||
|
||||
getPlacasPorManutencaoStatus: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.PLACAS_POR_MANUTENCAO_STATUS)
|
||||
}),
|
||||
|
||||
getQuantitativoManutencao: () => handleRequest({
|
||||
apiFn: () => api.get(ENDPOINTS.QUANTITATIVO_MANUTENCAO)
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
/**
|
||||
* Utilitário para traduzir erros HTTP em mensagens amigáveis ao usuário
|
||||
* Sem expor detalhes técnicos ou códigos do servidor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Traduz um erro HTTP em uma mensagem amigável ao usuário
|
||||
* @param {Error} error - Objeto de erro do Axios ou erro genérico
|
||||
* @returns {string} Mensagem amigável ao usuário
|
||||
*/
|
||||
export const getFriendlyErrorMessage = (error) => {
|
||||
// Se o erro não tiver resposta (erro de rede, timeout, etc.)
|
||||
if (!error.response) {
|
||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
||||
return 'A operação está demorando mais que o esperado. Por favor, tente novamente.';
|
||||
}
|
||||
if (error.message?.includes('Network Error') || error.message?.includes('ERR_NETWORK')) {
|
||||
return 'Não foi possível conectar ao servidor. Verifique sua conexão com a internet.';
|
||||
}
|
||||
return 'Não foi possível completar a operação. Por favor, tente novamente.';
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
const errorData = error.response?.data;
|
||||
|
||||
// Tenta usar mensagem do backend se for amigável
|
||||
if (errorData?.message && typeof errorData.message === 'string') {
|
||||
// Verifica se a mensagem não contém códigos técnicos
|
||||
const technicalPatterns = [
|
||||
/^HTTP \d{3}/,
|
||||
/^Error \d{3}/,
|
||||
/status code/i,
|
||||
/status: \d{3}/i,
|
||||
/\[.*\]/,
|
||||
/at .*\.js/,
|
||||
/line \d+/i,
|
||||
/stack trace/i
|
||||
];
|
||||
|
||||
const isTechnical = technicalPatterns.some(pattern => pattern.test(errorData.message));
|
||||
if (!isTechnical) {
|
||||
return errorData.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Traduz códigos de status HTTP em mensagens amigáveis
|
||||
switch (status) {
|
||||
case 400:
|
||||
// Bad Request - Dados inválidos
|
||||
if (errorData?.errors && Array.isArray(errorData.errors)) {
|
||||
const errors = errorData.errors.map(e => e.message || e).join(', ');
|
||||
return `Verifique os dados informados: ${errors}`;
|
||||
}
|
||||
return 'Os dados informados estão incorretos. Por favor, verifique e tente novamente.';
|
||||
|
||||
case 401:
|
||||
// Unauthorized - Não autorizado
|
||||
return 'Sua sessão expirou. Por favor, faça login novamente.';
|
||||
|
||||
case 403:
|
||||
// Forbidden - Acesso negado
|
||||
return 'Você não tem permissão para realizar esta operação.';
|
||||
|
||||
case 404:
|
||||
// Not Found - Recurso não encontrado
|
||||
return 'A informação solicitada não foi encontrada.';
|
||||
|
||||
case 409:
|
||||
// Conflict - Conflito
|
||||
return 'Já existe um registro com essas informações. Verifique os dados e tente novamente.';
|
||||
|
||||
case 422:
|
||||
// Unprocessable Entity - Erro de validação
|
||||
if (errorData?.errors && Array.isArray(errorData.errors)) {
|
||||
const errors = errorData.errors.map(e => e.message || e).join(', ');
|
||||
return `Dados inválidos: ${errors}`;
|
||||
}
|
||||
return 'Os dados informados não são válidos. Verifique e tente novamente.';
|
||||
|
||||
case 500:
|
||||
// Internal Server Error
|
||||
return 'Ocorreu um erro no servidor. Nossa equipe foi notificada. Por favor, tente novamente em alguns instantes.';
|
||||
|
||||
case 502:
|
||||
// Bad Gateway
|
||||
return 'O servidor está temporariamente indisponível. Tente novamente em alguns instantes.';
|
||||
|
||||
case 503:
|
||||
// Service Unavailable
|
||||
return 'O serviço está temporariamente indisponível. Tente novamente em alguns instantes.';
|
||||
|
||||
case 504:
|
||||
// Gateway Timeout
|
||||
return 'A operação está demorando mais que o esperado. Por favor, tente novamente.';
|
||||
|
||||
default:
|
||||
// Outros erros HTTP
|
||||
if (status >= 500) {
|
||||
return 'Ocorreu um erro no servidor. Por favor, tente novamente mais tarde.';
|
||||
}
|
||||
if (status >= 400) {
|
||||
return 'Não foi possível processar sua solicitação. Verifique os dados e tente novamente.';
|
||||
}
|
||||
return 'Ocorreu um erro inesperado. Por favor, tente novamente.';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extrai uma mensagem de erro amigável, priorizando mensagens do backend
|
||||
* mas garantindo que sejam amigáveis
|
||||
* @param {Error} error - Objeto de erro
|
||||
* @returns {string} Mensagem amigável
|
||||
*/
|
||||
export const extractFriendlyMessage = (error) => {
|
||||
// Se já temos uma mensagem amigável do backend, usa ela
|
||||
if (error.response?.data?.message && typeof error.response.data.message === 'string') {
|
||||
const msg = error.response.data.message;
|
||||
// Verifica se não é uma mensagem técnica
|
||||
if (!msg.match(/^(HTTP|Error|status|\[|at |line )/i)) {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso contrário, usa a tradução baseada no status
|
||||
return getFriendlyErrorMessage(error);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useAvailability } from '../hooks/useAvailability';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search, Calendar as CalendarIcon } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AvailabilityView() {
|
||||
const { availabilities, fetchAvailabilities, createAvailability, updateAvailability, deleteAvailability } = useAvailability();
|
||||
const { vehicles, fetchVehicles } = useVehicles();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
iddisponibilidade_frota: '', idveiculo_frota: '', placa: '', disponibilidade: '', status_disponibilidade: 'Disponível'
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAvailabilities();
|
||||
fetchVehicles();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...initialFormState, ...item });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePlateChange = (e) => {
|
||||
const val = e.target.value;
|
||||
const foundVehicle = vehicles.find(v => v.placa === val);
|
||||
setFormData({
|
||||
...formData,
|
||||
placa: val,
|
||||
idveiculo_frota: foundVehicle ? foundVehicle.idveiculo_frota : ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = {
|
||||
...formData,
|
||||
iddisponibilidade_frota: formData.iddisponibilidade_frota ? Number(formData.iddisponibilidade_frota) : undefined,
|
||||
idveiculo_frota: formData.idveiculo_frota ? Number(formData.idveiculo_frota) : null
|
||||
};
|
||||
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateAvailability(Number(editingItem.iddisponibilidade_frota || editingItem.id), payload);
|
||||
} else {
|
||||
success = await createAvailability(payload);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = availabilities.filter(item =>
|
||||
item.placa?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Disponibilidade e Agenda</h1>
|
||||
<p className="text-slate-500 text-sm">Visualização de disponibilidade da frota.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Registro
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'iddisponibilidade_frota', width: '80px' },
|
||||
{ header: 'VEÍCULO ID', field: 'idveiculo_frota', width: '100px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '120px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'DATA', field: 'disponibilidade', width: '180px', render: (row) => row.disponibilidade?.split('T')[0] },
|
||||
{ header: 'STATUS', field: 'status_disponibilidade', width: '150px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status_disponibilidade === 'Disponível' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
}`}>
|
||||
{row.status_disponibilidade}
|
||||
</span>
|
||||
)}
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'Buscar placa...' },
|
||||
{ field: 'status_disponibilidade', label: 'Status', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteAvailability(item.iddisponibilidade_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-md bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold">
|
||||
{editingItem ? `Editando ID: ${editingItem.iddisponibilidade_frota}` : 'Nova Disponibilidade'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4 max-h-[75vh] overflow-y-auto custom-scrollbar">
|
||||
{formData.iddisponibilidade_frota && (
|
||||
<div className="bg-orange-500/5 p-3 rounded-xl border border-orange-500/10 mb-2">
|
||||
<p className="text-[10px] uppercase font-bold text-orange-500/60 tracking-widest">ID do Registro</p>
|
||||
<p className="text-lg font-bold text-orange-500">{formData.iddisponibilidade_frota}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Placa (Pesquisar)</label>
|
||||
<input
|
||||
list="veiculos-list"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
value={formData.placa}
|
||||
onChange={handlePlateChange}
|
||||
placeholder="Digite ou selecione a placa..."
|
||||
required
|
||||
/>
|
||||
<datalist id="veiculos-list">
|
||||
{vehicles.map(v => (
|
||||
<option key={v.idveiculo_frota} value={v.placa} />
|
||||
))}
|
||||
</datalist>
|
||||
{formData.idveiculo_frota && (
|
||||
<span className="text-[10px] text-green-500 ml-1">Veículo vinculado (ID: {formData.idveiculo_frota})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DarkInput type="date" label="Data de Disponibilidade" value={formData.disponibilidade?.split('T')[0]} onChange={e => setFormData({...formData, disponibilidade: e.target.value})} />
|
||||
<DarkSelect label="Status" options={['Disponível', 'Indisponível', 'Reserva']} value={formData.status_disponibilidade} onChange={v => setFormData({...formData, status_disponibilidade: v})} />
|
||||
|
||||
<DialogFooter className="bg-slate-50 dark:bg-transparent border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit">Salvar</DarkButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,380 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useClaims } from '../hooks/useClaims';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useDrivers } from '../hooks/useDrivers';
|
||||
import AutocompleteInput from '../components/AutocompleteInput';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ClaimsView() {
|
||||
const { claims, loading, fetchClaims, createClaim, updateClaim, deleteClaim } = useClaims();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
agendamento: '', ano_fabricacao: '', ano_modelo: '', atuacao: '', base: '',
|
||||
categoria: '', chassi: '', cmou: '', combustivel: '', contrato: '',
|
||||
coordenador: '', cor: '', d: '', data_entrada: '', data_limite: '',
|
||||
dias_parado: '', dispatcher: '', fabricante: '', fiscal_operacao: '',
|
||||
geotab: 'NÃO', gestor: '', id_rota: '', idsinistro_devolucao_frota: '',
|
||||
km_atual: '', km_preventiva: '', melifleet: 'No', modelo: '', motorista: '',
|
||||
obs: '', placa: '', pooltrack: 'NÃO', previsao: '', primeira_locacao: '',
|
||||
proprietario: '', prox_preventiva: '', renavan: '', sascar: 'NÃO',
|
||||
status: '', t4s: 'NÃO', tipo_frota: '', tipo_placa: '', u: 'SIM',
|
||||
uf: '', ultima_preventiva: '', valor_aluguel: '', valor_fipe: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
const { vehicles, fetchVehicles } = useVehicles();
|
||||
const { drivers, fetchDrivers } = useDrivers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchClaims();
|
||||
fetchVehicles();
|
||||
fetchDrivers();
|
||||
}, []);
|
||||
|
||||
const handleVehicleSelect = (vehicle) => {
|
||||
if (!vehicle) return;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
placa: vehicle.placa || prev.placa,
|
||||
chassi: vehicle.chassi || prev.chassi,
|
||||
renavan: vehicle.renavam || vehicle.renavan || prev.renavan,
|
||||
modelo: vehicle.modelo || vehicle.mod_veiculo || prev.modelo,
|
||||
fabricante: vehicle.fabricante || vehicle.marca || prev.fabricante,
|
||||
cor: vehicle.cor || prev.cor,
|
||||
ano_fabricacao: vehicle.ano_fabricacao || vehicle.ano_fab || prev.ano_fabricacao,
|
||||
ano_modelo: vehicle.ano_modelo || vehicle.ano_mod || prev.ano_modelo,
|
||||
combustivel: vehicle.combustivel || prev.combustivel,
|
||||
categoria: vehicle.categoria || vehicle.cat_veiculo || prev.categoria,
|
||||
tipo_placa: vehicle.tipo_placa || prev.tipo_placa,
|
||||
proprietario: vehicle.proprietario || prev.proprietario,
|
||||
// Add more fields if available in vehicle object
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...initialFormState, ...item });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateClaim(editingItem.idsinistro_devolucao_frota, formData);
|
||||
} else {
|
||||
success = await createClaim(formData);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = Array.isArray(claims) ? claims.filter(item =>
|
||||
item.placa?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.motorista?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Sinistros e Devoluções</h1>
|
||||
<p className="text-slate-500 text-sm">Gestão de incidentes e movimentações de frota.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar registro..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Registro
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idsinistro_devolucao_frota', width: '80px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS', field: 'status', width: '120px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status === 'Sinistro' ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' :
|
||||
row.status === 'Devolução' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}>
|
||||
{row.status}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'MOTORISTA', field: 'motorista', width: '150px' },
|
||||
{ header: 'DATA ENTRADA', field: 'data_entrada', width: '120px' },
|
||||
{ header: 'PREVISÃO', field: 'previsao', width: '120px' },
|
||||
{ header: 'DIAS PARADO', field: 'dias_parado', width: '100px', className: 'text-red-400 font-bold' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '120px' },
|
||||
{ header: 'FABRICANTE', field: 'fabricante', width: '120px' },
|
||||
{ header: 'COMBUSTÍVEL', field: 'combustivel', width: '120px' },
|
||||
{ header: 'ANO FAB.', field: 'ano_fabricacao', width: '80px' },
|
||||
{ header: 'ANO MOD.', field: 'ano_modelo', width: '80px' },
|
||||
{ header: 'TIPO FROTA', field: 'tipo_frota', width: '120px' },
|
||||
{ header: 'CATEGORIA', field: 'categoria', width: '120px' },
|
||||
{ header: 'TIPO PLACA', field: 'tipo_placa', width: '100px' },
|
||||
{ header: 'BASE', field: 'base', width: '100px' },
|
||||
{ header: 'UF', field: 'uf', width: '60px' },
|
||||
{ header: 'GESTOR', field: 'gestor', width: '140px' },
|
||||
{ header: 'COORDENADOR', field: 'coordenador', width: '140px' },
|
||||
{ header: 'DISPATCHER', field: 'dispatcher', width: '140px' },
|
||||
{ header: 'FISCAL OPER.', field: 'fiscal_operacao', width: '140px' },
|
||||
{ header: 'ATUAÇÃO', field: 'atuacao', width: '140px' },
|
||||
{ header: 'ID ROTA', field: 'id_rota', width: '120px' },
|
||||
{ header: 'AGENDAMENTO', field: 'agendamento', width: '120px' },
|
||||
{ header: 'PROPRIETÁRIO', field: 'proprietario', width: '140px' },
|
||||
{ header: 'CONTRATO', field: 'contrato', width: '120px' },
|
||||
{ header: 'DATA LIMITE', field: 'data_limite', width: '110px' },
|
||||
{ header: '1ª LOCAÇÃO', field: 'primeira_locacao', width: '110px' },
|
||||
{ header: 'KM ATUAL', field: 'km_atual', width: '100px' },
|
||||
{ header: 'KM PREV.', field: 'km_preventiva', width: '100px' },
|
||||
{ header: 'ÚLT. PREV.', field: 'ultima_preventiva', width: '100px' },
|
||||
{ header: 'PRÓX. PREV.', field: 'prox_preventiva', width: '100px' },
|
||||
{ header: 'MELIFLEET', field: 'melifleet', width: '100px' },
|
||||
{ header: 'GEOTAB', field: 'geotab', width: '80px' },
|
||||
{ header: 'SASCAR', field: 'sascar', width: '80px' },
|
||||
{ header: 'POOLTRACK', field: 'pooltrack', width: '80px' },
|
||||
{ header: 'T4S', field: 't4s', width: '80px' },
|
||||
{ header: 'RENAVAN', field: 'renavan', width: '150px' },
|
||||
{ header: 'CHASSI', field: 'chassi', width: '180px' },
|
||||
{ header: 'CMOU', field: 'cmou', width: '80px' },
|
||||
{ header: 'D', field: 'd', width: '100px' },
|
||||
{ header: 'U', field: 'u', width: '100px' },
|
||||
{ header: 'VALOR ALUGUEL', field: 'valor_aluguel', width: '120px' },
|
||||
{ header: 'VALOR FIPE', field: 'valor_fipe', width: '120px' },
|
||||
{ header: 'OBS', field: 'obs', width: '200px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'Buscar placa...' },
|
||||
{ field: 'status', label: 'Status', type: 'select' },
|
||||
{ field: 'base', label: 'Base', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteClaim(item.idsinistro_devolucao_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold">
|
||||
{editingItem ? 'Editar Registro' : 'Novo Registro (Sinistro/Devolução)'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
Informações detalhadas sobre o evento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 max-h-[75vh] overflow-y-auto custom-scrollbar">
|
||||
<Tabs defaultValue="basicos" className="w-full">
|
||||
<TabsList className="bg-slate-100 dark:bg-[#141414] border border-slate-200 dark:border-[#2a2a2a] w-full justify-start p-1 h-auto mb-4">
|
||||
{['basicos', 'ocorrencia', 'operacional', 'financeiro', 'tecnico'].map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
>
|
||||
{tab}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-[300px]">
|
||||
<TabsContent value="basicos" className="space-y-4 m-0 pb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AutocompleteInput
|
||||
label="Placa"
|
||||
value={formData.placa}
|
||||
onChange={v => setFormData({...formData, placa: v})}
|
||||
options={vehicles}
|
||||
displayKey="placa"
|
||||
valueKey="placa"
|
||||
searchKeys={['placa']}
|
||||
onSelect={handleVehicleSelect}
|
||||
required
|
||||
/>
|
||||
<DarkInput label="Chassi" value={formData.chassi} onChange={e => setFormData({...formData, chassi: e.target.value})} />
|
||||
<DarkInput label="Renavam" value={formData.renavan} onChange={e => setFormData({...formData, renavan: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Modelo" value={formData.modelo} onChange={e => setFormData({...formData, modelo: e.target.value})} />
|
||||
<DarkInput label="Fabricante" value={formData.fabricante} onChange={e => setFormData({...formData, fabricante: e.target.value})} />
|
||||
<DarkInput label="Cor" value={formData.cor} onChange={e => setFormData({...formData, cor: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Ano Fab." value={formData.ano_fabricacao} onChange={e => setFormData({...formData, ano_fabricacao: e.target.value})} />
|
||||
<DarkInput label="Ano Mod." value={formData.ano_modelo} onChange={e => setFormData({...formData, ano_modelo: e.target.value})} />
|
||||
<DarkInput label="Combustível" value={formData.combustivel} onChange={e => setFormData({...formData, combustivel: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Categoria" value={formData.categoria} onChange={e => setFormData({...formData, categoria: e.target.value})} />
|
||||
<DarkInput label="Tipo Placa" value={formData.tipo_placa} onChange={e => setFormData({...formData, tipo_placa: e.target.value})} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ocorrencia" className="space-y-4 m-0 pb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkSelect label="Status" options={['Sinistro', 'Devolução', 'Venda', 'Manutenção']} value={formData.status} onChange={v => setFormData({...formData, status: v})} />
|
||||
<DarkInput type="date" label="Data Entrada" value={formData.data_entrada?.split('T')[0]} onChange={e => setFormData({...formData, data_entrada: e.target.value})} />
|
||||
<DarkInput type="date" label="Previsão" value={formData.previsao?.split('T')[0]} onChange={e => setFormData({...formData, previsao: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Dias Parado" value={formData.dias_parado} onChange={e => setFormData({...formData, dias_parado: e.target.value})} />
|
||||
<DarkInput label="Agendamento" value={formData.agendamento} onChange={e => setFormData({...formData, agendamento: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="D (Col)" value={formData.d} onChange={e => setFormData({...formData, d: e.target.value})} />
|
||||
<DarkInput label="U (Col)" value={formData.u} onChange={e => setFormData({...formData, u: e.target.value})} />
|
||||
</div>
|
||||
<div className="gap-1.5 flex flex-col">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="operacional" className="space-y-4 m-0 pb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Base" value={formData.base} onChange={e => setFormData({...formData, base: e.target.value})} />
|
||||
<DarkInput label="UF" value={formData.uf} onChange={e => setFormData({...formData, uf: e.target.value})} />
|
||||
<DarkInput label="Proprietário" value={formData.proprietario} onChange={e => setFormData({...formData, proprietario: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<AutocompleteInput
|
||||
label="Motorista"
|
||||
value={formData.motorista}
|
||||
onChange={v => setFormData({...formData, motorista: v})}
|
||||
options={drivers}
|
||||
displayKey="NOME_FAVORECIDO"
|
||||
valueKey="NOME_FAVORECIDO"
|
||||
placeholder="Buscar motorista..."
|
||||
/>
|
||||
<DarkInput label="ID Rota" value={formData.id_rota} onChange={e => setFormData({...formData, id_rota: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Gestor" value={formData.gestor} onChange={e => setFormData({...formData, gestor: e.target.value})} />
|
||||
<DarkInput label="Coordenador" value={formData.coordenador} onChange={e => setFormData({...formData, coordenador: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Dispatcher" value={formData.dispatcher} onChange={e => setFormData({...formData, dispatcher: e.target.value})} />
|
||||
<DarkInput label="Fiscal Operação" value={formData.fiscal_operacao} onChange={e => setFormData({...formData, fiscal_operacao: e.target.value})} />
|
||||
</div>
|
||||
<DarkInput label="Atuação" value={formData.atuacao} onChange={e => setFormData({...formData, atuacao: e.target.value})} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="financeiro" className="space-y-4 m-0 pb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput type="number" label="Valor FIPE" value={formData.valor_fipe} onChange={e => setFormData({...formData, valor_fipe: e.target.value})} />
|
||||
<DarkInput type="number" label="Valor Aluguel" value={formData.valor_aluguel} onChange={e => setFormData({...formData, valor_aluguel: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Contrato" value={formData.contrato} onChange={e => setFormData({...formData, contrato: e.target.value})} />
|
||||
<DarkInput type="date" label="Data Limite" value={formData.data_limite?.split('T')[0]} onChange={e => setFormData({...formData, data_limite: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Tipo Frota" value={formData.tipo_frota} onChange={e => setFormData({...formData, tipo_frota: e.target.value})} />
|
||||
<DarkInput type="date" label="Primeira Locação" value={formData.primeira_locacao?.split('T')[0]} onChange={e => setFormData({...formData, primeira_locacao: e.target.value})} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tecnico" className="space-y-4 m-0 pb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="KM Preventiva" value={formData.km_preventiva} onChange={e => setFormData({...formData, km_preventiva: e.target.value})} />
|
||||
<DarkInput label="KM Atual" value={formData.km_atual} onChange={e => setFormData({...formData, km_atual: e.target.value})} />
|
||||
<DarkInput label="CMOU" value={formData.cmou} onChange={e => setFormData({...formData, cmou: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput type="date" label="Última Prev." value={formData.ultima_preventiva?.split('T')[0]} onChange={e => setFormData({...formData, ultima_preventiva: e.target.value})} />
|
||||
<DarkInput type="date" label="Próx. Prev." value={formData.prox_preventiva?.split('T')[0]} onChange={e => setFormData({...formData, prox_preventiva: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 pt-2">
|
||||
{['geotab', 'sascar', 'pooltrack', 't4s'].map(tracker => (
|
||||
<DarkSelect key={tracker} label={tracker.toUpperCase()} options={['SIM', 'NÃO']} value={formData[tracker]} onChange={v => setFormData({...formData, [tracker]: v})} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="sticky bottom-0 bg-white dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit" onClick={handleSubmit}>Salvar Registro</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search, Settings, Save, X, Edit, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// --- Components Styled ---
|
||||
const SidebarItem = ({ active, label, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 text-sm font-medium transition-colors border-l-2 ${
|
||||
active
|
||||
? 'bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-500 border-orange-500'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#2a2a2a] border-transparent'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white",
|
||||
danger: "bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-500 border border-red-500/20"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-2">
|
||||
{label && <label className="text-[11px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1 block">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2.5 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function ConfigView() {
|
||||
const [configOptions, setConfigOptions] = useState({});
|
||||
const [selectedRoute, setSelectedRoute] = useState(null);
|
||||
const [items, setItems] = useState([]);
|
||||
const [loadingItems, setLoadingItems] = useState(false);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
|
||||
// Fetch Config Options on Mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const data = await prafrotService.getListasConfig();
|
||||
setConfigOptions(data);
|
||||
|
||||
// Select first option by default
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length > 0) {
|
||||
setSelectedRoute(data[keys[0]].rota);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading config options:", error);
|
||||
}
|
||||
};
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
// Fetch Items when Selected Route changes
|
||||
useEffect(() => {
|
||||
if (!selectedRoute) return;
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoadingItems(true);
|
||||
try {
|
||||
const data = await prafrotService.getListasByRoute(selectedRoute);
|
||||
// Ensure data is array
|
||||
const list = Array.isArray(data) ? data : (data.results || []);
|
||||
setItems(list);
|
||||
} catch (error) {
|
||||
console.error(`Error loading items for ${selectedRoute}:`, error);
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoadingItems(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadItems();
|
||||
setSearchTerm('');
|
||||
}, [selectedRoute]);
|
||||
|
||||
// Derived columns from first item
|
||||
const columns = useMemo(() => {
|
||||
if (!items || items.length === 0) return [];
|
||||
|
||||
const firstItem = items[0];
|
||||
const keys = Object.keys(firstItem);
|
||||
|
||||
return keys
|
||||
.filter(key => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// Hide ID, created_at, updated_at, deleted_at, active
|
||||
return !lowerKey.startsWith('id') && !['created_at', 'updated_at', 'deleted_at'].includes(lowerKey);
|
||||
})
|
||||
.map(key => ({
|
||||
header: key.toUpperCase().replace(/_/g, ' '),
|
||||
field: key,
|
||||
width: '200px'
|
||||
}));
|
||||
}, [items]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (!searchTerm) return items;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return items.filter(item =>
|
||||
Object.values(item).some(val =>
|
||||
String(val).toLowerCase().includes(lower)
|
||||
)
|
||||
);
|
||||
}, [items, searchTerm]);
|
||||
|
||||
// Actions
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...item });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
const emptyState = {};
|
||||
if (items.length > 0) {
|
||||
Object.keys(items[0]).forEach(k => emptyState[k] = '');
|
||||
} else {
|
||||
// Fallback default fields
|
||||
emptyState.label = '';
|
||||
emptyState.valor = '';
|
||||
}
|
||||
setFormData(emptyState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedRoute) return;
|
||||
|
||||
try {
|
||||
// Create payload sending info as 'valor'
|
||||
// We extract all non-ID fields. If there's only one, we map it to 'valor'.
|
||||
// If there are multiple, we prioritize fields already named 'valor' or 'value'.
|
||||
const payload = {};
|
||||
const nonIdKeys = Object.keys(formData).filter(key => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
return !lowerKey.startsWith('id') && !['created_at', 'updated_at', 'deleted_at'].includes(lowerKey);
|
||||
});
|
||||
|
||||
if (nonIdKeys.length === 1) {
|
||||
payload.valor = formData[nonIdKeys[0]];
|
||||
} else if (nonIdKeys.length > 1) {
|
||||
// If multiple, send all but ensure 'valor' is present if 'value' was used
|
||||
nonIdKeys.forEach(k => {
|
||||
if (k === 'value') payload.valor = formData[k];
|
||||
else payload[k] = formData[k];
|
||||
});
|
||||
if (formData.valor) payload.valor = formData.valor;
|
||||
} else {
|
||||
// Fallback for empty schema
|
||||
payload.label = formData.label;
|
||||
payload.valor = formData.valor || formData.value;
|
||||
}
|
||||
|
||||
if (editingItem) {
|
||||
const idKey = Object.keys(editingItem).find(k => k.toLowerCase().startsWith('id'));
|
||||
const idVal = editingItem[idKey];
|
||||
|
||||
if (!idVal) {
|
||||
alert("Não foi possível identificar o ID deste item.");
|
||||
return;
|
||||
}
|
||||
|
||||
await prafrotService.updateLista(selectedRoute, idVal, payload);
|
||||
} else {
|
||||
await prafrotService.createLista(selectedRoute, payload);
|
||||
}
|
||||
|
||||
const data = await prafrotService.getListasByRoute(selectedRoute);
|
||||
setItems(Array.isArray(data) ? data : (data.results || []));
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving:", error);
|
||||
alert("Erro ao salvar. Verifique o console.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item) => {
|
||||
if (!window.confirm("Tem certeza que deseja excluir este item?")) return;
|
||||
|
||||
const idKey = Object.keys(item).find(k => k.toLowerCase().startsWith('id'));
|
||||
const idVal = item[idKey];
|
||||
|
||||
if (!idVal) {
|
||||
alert("Não foi possível identificar o ID deste item para exclusão.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await prafrotService.deleteLista(selectedRoute, idVal);
|
||||
|
||||
// Refresh list
|
||||
const data = await prafrotService.getListasByRoute(selectedRoute);
|
||||
setItems(Array.isArray(data) ? data : (data.results || []));
|
||||
} catch (error) {
|
||||
console.error("Erro ao excluir:", error);
|
||||
alert("Erro ao excluir o item. Verifique o console.");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLabel = configOptions[selectedRoute]?.label || selectedRoute;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 dark:bg-[#0f0f0f] overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 bg-white dark:bg-[#141414] border-r border-slate-200 dark:border-[#2a2a2a] flex flex-col">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-white flex items-center gap-2">
|
||||
<Settings className="text-orange-500" size={20} />
|
||||
Configurações
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">Gerencie as listas do sistema</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2 custom-scrollbar">
|
||||
{Object.entries(configOptions).map(([key, opt]) => (
|
||||
<SidebarItem
|
||||
key={key}
|
||||
active={selectedRoute === opt.rota}
|
||||
label={opt.label}
|
||||
onClick={() => setSelectedRoute(opt.rota)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-[#141414] border-b border-slate-200 dark:border-[#2a2a2a] px-8 py-5 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">{selectedLabel}</h1>
|
||||
<p className="text-slate-500 text-sm">Gerenciamento de opções para {selectedLabel?.toLowerCase()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-slate-50 dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-64"
|
||||
placeholder="Pesquisar..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Item
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 p-8 overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-[#141414] rounded-xl shadow-sm border border-slate-200 dark:border-[#2a2a2a] overflow-hidden flex flex-col">
|
||||
{loadingItems ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500">
|
||||
Carregando...
|
||||
</div>
|
||||
) : (
|
||||
<ExcelTable
|
||||
data={filteredItems}
|
||||
columns={columns}
|
||||
loading={loadingItems}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={handleDelete} // Optional
|
||||
// Fallback ID key logic usually generic in ExcelTable or pass rowKey
|
||||
rowKey={(row) => {
|
||||
// Try to find the primary key dynamically
|
||||
const idKey = Object.keys(row).find(k => k.toLowerCase().startsWith('id'));
|
||||
return idKey ? row[idKey] : Math.random();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-2xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200">
|
||||
<DialogHeader className="border-b border-slate-200 dark:border-[#2a2a2a] pb-4">
|
||||
<DialogTitle className="text-slate-800 dark:text-white">
|
||||
{editingItem ? `Editar ${selectedLabel}` : `Novo Item em ${selectedLabel}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className="py-4 grid grid-cols-1 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Dynamic Form Generation */}
|
||||
{Object.keys(formData).map(key => {
|
||||
// Hide ID fields completely from form
|
||||
const isId = key.toLowerCase().startsWith('id') || key === 'created_at' || key === 'updated_at';
|
||||
if (isId) return null;
|
||||
|
||||
return (
|
||||
<DarkInput
|
||||
key={key}
|
||||
label={key.replace(/_/g, ' ')}
|
||||
value={formData[key] || ''}
|
||||
onChange={e => setFormData({...formData, [key]: e.target.value})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Fallback if formData is empty (new item with unknown schema) */}
|
||||
{Object.keys(formData).length === 0 && (
|
||||
<div className="text-center text-slate-500 p-4">
|
||||
Não é possível criar um item sem conhecer a estrutura. Adicione a lógica de campos manuais se necessário.
|
||||
(Normalmente, campos seriam: Nome, Descrição, etc.)
|
||||
<div className="mt-4 space-y-2">
|
||||
<DarkInput
|
||||
label="Label / Nome"
|
||||
value={formData['label'] || ''}
|
||||
onChange={e => setFormData({...formData, label: e.target.value})}
|
||||
/>
|
||||
<DarkInput
|
||||
label="Valor / Código"
|
||||
value={formData['valor'] || ''}
|
||||
onChange={e => setFormData({...formData, valor: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<DialogFooter className="border-t border-slate-200 dark:border-[#2a2a2a] pt-4">
|
||||
<DarkButton variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton onClick={handleSave}>
|
||||
<Save size={16} /> Salvar
|
||||
</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Truck, AlertCircle, Clock, CheckCircle2,
|
||||
TrendingUp, TrendingDown, DollarSign, MapPin
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
const StatCard = ({ title, value, subtext, icon: Icon, color, trend }) => (
|
||||
<Card className="bg-[#1c1c1c] border-[#2a2a2a] text-slate-200 overflow-hidden relative group hover:border-[#333] transition-all">
|
||||
<div className={`absolute top-0 left-0 w-1 h-full ${color}`} />
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mb-1">{title}</p>
|
||||
<h3 className="text-3xl font-bold text-white tracking-tight">{value}</h3>
|
||||
{subtext && <p className="text-xs text-slate-500 mt-1 font-medium">{subtext}</p>}
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl bg-opacity-10 ${color.replace('bg-', 'bg-').replace('w-1', '')} ${color.replace('bg-', 'text-')}`}>
|
||||
<Icon size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded flex items-center gap-1 ${trend > 0 ? 'text-orange-500 bg-orange-500/10' : 'text-red-500 bg-red-500/10'}`}>
|
||||
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-600 uppercase font-bold">vs mês anterior</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default function DashboardView() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">Visão Geral</h1>
|
||||
<p className="text-slate-500 text-sm">Monitoramento em tempo real da operação.</p>
|
||||
</div>
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Frota Total"
|
||||
value="1,240"
|
||||
subtext="1033 em operação"
|
||||
icon={Truck}
|
||||
color="bg-blue-600"
|
||||
trend={12.5}
|
||||
/>
|
||||
<StatCard
|
||||
title="Manutenção"
|
||||
value="28"
|
||||
subtext="4 críticos"
|
||||
icon={AlertCircle}
|
||||
color="bg-red-500"
|
||||
trend={-2.4}
|
||||
/>
|
||||
<StatCard
|
||||
title="Disponibilidade"
|
||||
value="94.2%"
|
||||
subtext="Meta: 95%"
|
||||
icon={CheckCircle2}
|
||||
color="bg-orange-500"
|
||||
trend={1.8}
|
||||
/>
|
||||
<StatCard
|
||||
title="Custo Médio"
|
||||
value="R$ 2.4k"
|
||||
subtext="Por veículo/mês"
|
||||
icon={DollarSign}
|
||||
color="bg-yellow-500"
|
||||
trend={-0.5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="bg-[#1c1c1c] border-[#2a2a2a] lg:col-span-2">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="font-bold text-white text-lg">Distribuição por Base</h3>
|
||||
<button className="text-xs font-bold text-yellow-500 hover:text-yellow-400">VER RELATÓRIO</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'SP - Capital (SRJ10)', val: 450, tot: 1240, col: 'bg-blue-600' },
|
||||
{ label: 'RJ - Rio de Janeiro (GIG)', val: 320, tot: 1240, col: 'bg-orange-500' },
|
||||
{ label: 'MG - Belo Horizonte', val: 210, tot: 1240, col: 'bg-yellow-500' },
|
||||
{ label: 'Outras Bases', val: 260, tot: 1240, col: 'bg-slate-600' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-xs font-medium text-slate-400">
|
||||
<span>{item.label}</span>
|
||||
<span className="text-white">{item.val}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
|
||||
<div className={`h-full ${item.col} rounded-full`} style={{ width: `${(item.val / item.tot) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#1c1c1c] border-[#2a2a2a]">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="font-bold text-white text-lg mb-6">Alertas Recentes</h3>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ msg: 'Manutenção Preventiva - ABC-1234', time: 'Há 2h', type: 'warn' },
|
||||
{ msg: 'Multa Registrada - XYZ-9876', time: 'Há 4h', type: 'crit' },
|
||||
{ msg: 'Novo Veículo Cadastrado', time: 'Há 5h', type: 'info' },
|
||||
{ msg: 'Checklist Atrasado - GOL-5544', time: 'Ontem', type: 'warn' },
|
||||
].map((alert, i) => (
|
||||
<div key={i} className="flex items-start gap-3 pb-3 border-b border-[#2a2a2a] last:border-0 last:pb-0">
|
||||
<div className={`w-2 h-2 mt-1.5 rounded-full ${alert.type === 'crit' ? 'bg-red-500' : alert.type === 'warn' ? 'bg-yellow-500' : 'bg-blue-500'}`} />
|
||||
<div>
|
||||
<p className="text-sm text-slate-300 font-medium">{alert.msg}</p>
|
||||
<span className="text-[10px] text-slate-500 uppercase font-bold flex items-center gap-1">
|
||||
<Clock size={10} /> {alert.time}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useDispatcher } from '../hooks/useDispatcher';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
|
||||
const DispatcherView = () => {
|
||||
const { data, loading, fetchData } = useDispatcher();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// Take the first item to generate columns
|
||||
const firstItem = data[0];
|
||||
return Object.keys(firstItem).map(key => ({
|
||||
field: key,
|
||||
header: key.replace(/_/g, ' '), // Simple formatter: replace underscores with spaces
|
||||
width: 150, // Default width
|
||||
className: 'text-xs'
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const filterDefs = useMemo(() => {
|
||||
// Create filters for all columns by default, or maybe just some specific ones?
|
||||
// For "advanced filters", let's just enable all of them as 'select' type for now
|
||||
return columns.map(col => ({
|
||||
field: col.field,
|
||||
label: col.header,
|
||||
type: 'select'
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col p-4 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">Dispatcher</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Visualização de dados do Dispatcher.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<ExcelTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
filterDefs={filterDefs}
|
||||
loading={loading}
|
||||
pageSize={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DispatcherView;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useDrivers } from '../hooks/useDrivers';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DriversView() {
|
||||
const { drivers, fetchDrivers } = useDrivers();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Note: Drivers View is Read-Only for now as per instructions (just listing)
|
||||
// If editing is needed, we can implement it later.
|
||||
|
||||
useEffect(() => {
|
||||
fetchDrivers();
|
||||
}, []);
|
||||
|
||||
const filteredData = Array.isArray(drivers) ? drivers.filter(item =>
|
||||
item.NOME_FAVORECIDO?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.CPF_CNPJ_FAVORECIDO?.includes(searchTerm)
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Motoristas</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar motorista..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
columns={[
|
||||
{ header: 'NOME', field: 'NOME_FAVORECIDO', width: '300px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'CPF/CNPJ', field: 'CPF_CNPJ_FAVORECIDO', width: '180px' },
|
||||
{ header: 'TELEFONE', field: 'TELEFONE', width: '150px' },
|
||||
{ header: 'ENDEREÇO', field: 'ENDERECO', width: '250px' },
|
||||
{ header: 'BASE', field: 'BASE', width: '150px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'NOME_FAVORECIDO', label: 'Nome', type: 'text' },
|
||||
{ field: 'BASE', label: 'Base', type: 'select' },
|
||||
]}
|
||||
// Edit disabled for now
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Lock, Mail } from 'lucide-react';
|
||||
import { useDocumentMetadata } from '@/hooks/useDocumentMetadata';
|
||||
import logoOestePan from '@/assets/Img/Util/Oeste_Pan/Logo Oeste Pan.png';
|
||||
|
||||
export default function LoginView() {
|
||||
useDocumentMetadata('Login | Oeste Pan', 'oest-pan');
|
||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||
const { login, loading, error } = useAuthContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
// Login for auth_oestepan environment
|
||||
const success = await login(formData, 'auth_oestepan');
|
||||
if (success) {
|
||||
navigate('/plataforma/oest-pan/estatisticas');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#141414] flex items-center justify-center p-4" style={{ fontFamily: 'var(--font-main)' }}>
|
||||
<div className="w-full max-w-5xl h-[600px] flex shadow-2xl rounded-3xl overflow-hidden border border-[#2a2a2a]">
|
||||
|
||||
{/* Visual Side */}
|
||||
<div className="hidden md:flex flex-1 bg-[#1c1c1c] relative items-center justify-center p-12">
|
||||
<div className="absolute inset-0 bg-orange-500/5 mix-blend-overlay" />
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://images.unsplash.com/photo-1592838064575-70ed431fb924?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-10" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<img src={logoOestePan} alt="Oeste Pan Logo" className="w-64 h-auto drop-shadow-2xl" />
|
||||
</motion.div>
|
||||
<h1 className="text-4xl font-bold text-white tracking-tighter mb-2">Oeste <span className="text-orange-500">Pan</span></h1>
|
||||
<p className="text-slate-500 font-medium tracking-widest uppercase text-xs">Gestão Inteligente de Ativos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Side */}
|
||||
<div className="flex-1 bg-[#18181b] flex items-center justify-center p-10">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="text-center md:text-left">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Acesso ao Monitoramento</h2>
|
||||
<p className="text-slate-400 text-sm">Entre com suas credenciais de gestor.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({...formData, email: e.target.value})}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="gestor@Oeste_Pan.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({...formData, password: e.target.value})}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-bold rounded-lg text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-400 text-[#141414] font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-orange-500/10 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? 'Acessando...' : <>Acessar Painel <ArrowRight size={18} /></>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* <div className="text-center">
|
||||
<span className="text-xs text-slate-600 font-medium">© 2024 Oeste Pan System v2.0</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,224 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useMoki } from '../hooks/useMoki';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function MokiView() {
|
||||
const { mokis, fetchMokis, createMoki, updateMoki, deleteMoki } = useMoki();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
aprovacao: '', autor: '', checklist: '', cod_unidade: '', data: '',
|
||||
idmoki_frota: '', nome_unidade: '', origem: '', status: 'Não Conforme',
|
||||
status_checklist: '', status_unidade: '', unidade: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMokis();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...initialFormState,
|
||||
...item,
|
||||
data: (item.data || item.data_moki || item.data_checklist || '').split('T')[0]
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const { idmoki_frota, ...rest } = formData;
|
||||
const payload = {
|
||||
...rest,
|
||||
id: idmoki_frota ? Number(idmoki_frota) : undefined
|
||||
};
|
||||
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateMoki(Number(idmoki_frota || editingItem.id), payload);
|
||||
} else {
|
||||
success = await createMoki(payload);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = mokis.filter(item =>
|
||||
item.checklist?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.unidade?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.nome_unidade?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Checklists Moki</h1>
|
||||
<p className="text-slate-500 text-sm">Inspeções e vistorias realizadas.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="w-full md:w-64 bg-white dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-10 pr-4 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
placeholder="Buscar checklist..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Checklist
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idmoki_frota', width: '80px' },
|
||||
{ header: 'DATA', field: 'data', width: '100px', render: (row) => row.data?.split('T')[0] || row.data_checklist?.split('T')[0] },
|
||||
{ header: 'CHECKLIST', field: 'checklist', width: '220px', className: 'font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS CHECKLIST', field: 'status_checklist', width: '150px' },
|
||||
{ header: 'STATUS', field: 'status', width: '120px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status === 'Não Conforme' ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' :
|
||||
row.status === 'Aprovado' || row.status === 'Conforme' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}>
|
||||
{row.status || 'Pendente'}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'UNIDADE', field: 'unidade', width: '150px' },
|
||||
{ header: 'COD. UNIDADE', field: 'cod_unidade', width: '120px' },
|
||||
{ header: 'NOME UNIDADE', field: 'nome_unidade', width: '180px' },
|
||||
{ header: 'STATUS UNIDADE', field: 'status_unidade', width: '120px' },
|
||||
{ header: 'AUTOR', field: 'autor', width: '120px' },
|
||||
{ header: 'ORIGEM', field: 'origem', width: '120px' },
|
||||
{ header: 'APROVAÇÃO', field: 'aprovacao', width: '120px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'origem', label: 'Origem', type: 'select' },
|
||||
{ field: 'unidade', label: 'Unidade', type: 'select' },
|
||||
{ field: 'checklist', label: 'Checklist', type: 'select' },
|
||||
{ field: 'status', label: 'Status', type: 'select' },
|
||||
{ field: 'autor', label: 'Autor', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteMoki(item.idmoki_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold">
|
||||
{editingItem ? `Editando Checklist ID: ${editingItem.idmoki_frota}` : 'Novo Checklist Moki'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
Informações da vistoria via sistema Moki.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
<div className="bg-orange-500/5 p-4 rounded-xl border border-orange-500/10 mb-2">
|
||||
<DarkInput
|
||||
type="number"
|
||||
label="ID do Moki (Obrigatório)"
|
||||
value={formData.idmoki_frota}
|
||||
onChange={e => setFormData({...formData, idmoki_frota: e.target.value})}
|
||||
required
|
||||
placeholder="Ex: 123456"
|
||||
/>
|
||||
<p className="text-[10px] text-orange-500/60 mt-1 ml-1 uppercase font-bold tracking-widest">Este campo deve ser preenchido manualmente para novos registros.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Origem" value={formData.origem} onChange={e => setFormData({...formData, origem: e.target.value})} />
|
||||
<DarkInput label="Unidade" value={formData.unidade} onChange={e => setFormData({...formData, unidade: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Cod. Unidade" value={formData.cod_unidade} onChange={e => setFormData({...formData, cod_unidade: e.target.value})} />
|
||||
<DarkInput label="Nome Unidade" value={formData.nome_unidade} onChange={e => setFormData({...formData, nome_unidade: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Status Unidade" value={formData.status_unidade} onChange={e => setFormData({...formData, status_unidade: e.target.value})} />
|
||||
<DarkInput label="Checklist" value={formData.checklist} onChange={e => setFormData({...formData, checklist: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Status Checklist" value={formData.status_checklist} onChange={e => setFormData({...formData, status_checklist: e.target.value})} />
|
||||
<DarkInput type="date" label="Data" value={formData.data?.split('T')[0]} onChange={e => setFormData({...formData, data: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Autor" value={formData.autor} onChange={e => setFormData({...formData, autor: e.target.value})} />
|
||||
<DarkSelect label="Status" options={['Pendente', 'Conforme', 'Não Conforme', 'Aprovado']} value={formData.status} onChange={v => setFormData({...formData, status: v})} />
|
||||
</div>
|
||||
<DarkInput label="Aprovação (Obs)" value={formData.aprovacao} onChange={e => setFormData({...formData, aprovacao: e.target.value})} />
|
||||
|
||||
<DialogFooter className="sticky bottom-0 bg-white dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit">Salvar Checklist</DarkButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useMonitoring } from '../hooks/useMonitoring';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useDrivers } from '../hooks/useDrivers';
|
||||
import AutocompleteInput from '../components/AutocompleteInput';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Wrench, CheckCircle, Truck, Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
import { prafrotStatisticsService } from '../services/prafrotStatisticsService';
|
||||
|
||||
export default function MonitoringView() {
|
||||
const { monitorings, loading, fetchMonitoring, createMonitoring, updateMonitoring, deleteMonitoring } = useMonitoring();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [statusStats, setStatusStats] = useState([]);
|
||||
const [selectedStatusRecords, setSelectedStatusRecords] = useState(null);
|
||||
const [isRecordsModalOpen, setIsRecordsModalOpen] = useState(false);
|
||||
|
||||
// Initial Form State
|
||||
const initialFormState = {
|
||||
data_carga: '', id_externo: '', idmonitoramento_frota: '', motorista: '',
|
||||
placa: '', unidade: '', obs: '', rota: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
const { vehicles, fetchVehicles, deleteVehicle } = useVehicles();
|
||||
const { drivers, fetchDrivers } = useDrivers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitoring();
|
||||
fetchVehicles();
|
||||
fetchDrivers();
|
||||
|
||||
// Fetch Status Stats
|
||||
prafrotStatisticsService.getPlacasPorStatus().then(data => {
|
||||
if (Array.isArray(data)) setStatusStats(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getStatusData = (status) => {
|
||||
return statusStats.find(item => {
|
||||
const itemStatus = (item.status || item.status_frota || '');
|
||||
return itemStatus.trim().toLowerCase() === status?.trim().toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusClick = (status) => {
|
||||
const data = getStatusData(status);
|
||||
if (data && data.registros) {
|
||||
setSelectedStatusRecords({
|
||||
title: status,
|
||||
records: JSON.parse(data.registros).filter(r => r !== null)
|
||||
});
|
||||
setIsRecordsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVehicleSelect = (vehicle) => {
|
||||
if (!vehicle) return;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
placa: vehicle.placa || prev.placa,
|
||||
unidade: vehicle.base || prev.unidade,
|
||||
rota: vehicle.id_rota || prev.rota,
|
||||
// Add other relevant fields for monitoring
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
// Format date properly for HTML date input (YYYY-MM-DD)
|
||||
const formattedItem = { ...item };
|
||||
if (formattedItem.data_carga) {
|
||||
// Handle various date formats from backend
|
||||
const date = new Date(formattedItem.data_carga);
|
||||
if (!isNaN(date.getTime())) {
|
||||
// Format to YYYY-MM-DD for HTML date input
|
||||
formattedItem.data_carga = date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
setFormData({ ...initialFormState, ...formattedItem });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateMonitoring(editingItem.idmonitoramento_frota, formData);
|
||||
} else {
|
||||
success = await createMonitoring(formData);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => Array.isArray(monitorings) ? monitorings.filter(item =>
|
||||
item.unidade?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.motorista?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [], [monitorings, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Monitoramento & Logística</h1>
|
||||
<p className="text-slate-500 text-sm">Acompanhamento em tempo real e rastreabilidade.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar unidade..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Monitoramento
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ label: 'Em Operação', status: 'Em Operação', color: 'bg-orange-500/10 text-orange-600', icon: <CheckCircle size={20} />, hover: 'hover:border-orange-500/30' },
|
||||
{ label: 'Veículo Alugado', status: 'Veículo Alugado', color: 'bg-blue-500/10 text-blue-600', icon: <Truck size={20} />, hover: 'hover:border-blue-500/30' },
|
||||
].map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => handleStatusClick(card.status)}
|
||||
className={`bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex flex-col sm:flex-row items-center justify-between group cursor-pointer ${card.hover}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-4 rounded-xl ${card.color} group-hover:scale-110 transition-transform`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">{card.label}</span>
|
||||
<div className="text-3xl font-bold text-slate-800 dark:text-white mt-0.5">
|
||||
{getStatusData(card.status)?.total || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idmonitoramento_frota', width: '80px' },
|
||||
{ header: 'ID EXTERNO', field: 'id_externo', width: '120px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'UNIDADE', field: 'unidade', width: '150px' },
|
||||
{ header: 'MOTORISTA', field: 'motorista', width: '150px' },
|
||||
{ header: 'DATA CARGA', field: 'data_carga', width: '150px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'Buscar placa...' },
|
||||
{ field: 'unidade', label: 'Unidade', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteMonitoring(item.idmonitoramento_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold">
|
||||
{editingItem ? 'Editar Monitoramento' : 'Novo Registro de Monitoramento'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
Detalhes sobre o acompanhamento da viagem e carga.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 max-h-[75vh] overflow-y-auto custom-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-4 pb-4">
|
||||
<AutocompleteInput
|
||||
label="Placa"
|
||||
value={formData.placa}
|
||||
onChange={v => setFormData({...formData, placa: v})}
|
||||
options={vehicles}
|
||||
displayKey="placa"
|
||||
valueKey="placa"
|
||||
searchKeys={['placa']}
|
||||
onSelect={handleVehicleSelect}
|
||||
required
|
||||
/>
|
||||
<DarkInput label="Unidade" value={formData.unidade} onChange={e => setFormData({...formData, unidade: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 pb-4">
|
||||
<DarkInput label="Rota" value={formData.rota} onChange={e => setFormData({...formData, rota: e.target.value})} />
|
||||
<AutocompleteInput
|
||||
label="Motorista"
|
||||
value={formData.motorista}
|
||||
onChange={v => setFormData({...formData, motorista: v})}
|
||||
options={drivers}
|
||||
displayKey="NOME_FAVORECIDO"
|
||||
valueKey="NOME_FAVORECIDO"
|
||||
placeholder="Buscar motorista..."
|
||||
/>
|
||||
<DarkInput type="date" label="Data Carga" value={formData.data_carga || ''} onChange={e => setFormData({...formData, data_carga: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
<DarkInput label="ID Externo" value={formData.id_externo} onChange={e => setFormData({...formData, id_externo: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 pb-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações de Monitoramento</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[100px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-50 dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit">Salvar Registro</DarkButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Records Detail Modal */}
|
||||
<Dialog open={isRecordsModalOpen} onOpenChange={setIsRecordsModalOpen}>
|
||||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl text-orange-600 shadow-inner">
|
||||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
Veículos: {selectedStatusRecords?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Monitoramento detalhado dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-orange-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6 bg-white dark:bg-[#1c1c1c]">
|
||||
<div className="h-full w-full rounded-2xl border border-slate-200 dark:border-[#2a2a2a] overflow-hidden shadow-sm">
|
||||
<ExcelTable
|
||||
data={selectedStatusRecords?.records || []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
{ header: 'Status', field: 'status', width: '140px' },
|
||||
{ header: 'Manutenção', field: 'manutencao', width: '120px' },
|
||||
{ header: 'Atuação', field: 'atuacao', width: '140px' },
|
||||
{ header: 'Proprietário', field: 'proprietario', width: '140px' },
|
||||
{ header: 'VecFleet', field: 'vecfleet', width: '140px' },
|
||||
{ header: 'OBS', field: 'obs', width: '250px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text' },
|
||||
{ field: 'motorista', label: 'Motorista', type: 'text' },
|
||||
{ field: 'base', label: 'Unidade', type: 'select' }
|
||||
]}
|
||||
onDelete={(item) => deleteVehicle(item.idveiculo_frota)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-4 border-t border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#1c1c1c]">
|
||||
<DarkButton variant="secondary" onClick={() => setIsRecordsModalOpen(false)}>
|
||||
Fechar Listagem
|
||||
</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,561 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { usePrafrotStatistics } from '../hooks/usePrafrotStatistics';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { StatsGrid } from '@/components/shared/StatsGrid';
|
||||
import {
|
||||
Truck, Wrench, CheckCircle2, AlertTriangle,
|
||||
BarChart3, PieChart as PieChartIcon, LineChart as LineChartIcon,
|
||||
RefreshCw, Download, Calendar, Layers, MapPin, Gauge
|
||||
} from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
PieChart, Pie, Cell, LineChart, Line, Legend, CartesianGrid, Bar,
|
||||
ResponsiveContainer, Tooltip, XAxis, YAxis, BarChart
|
||||
} from 'recharts';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from "@/components/ui/dialog";
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
|
||||
// Cores premium para os gráficos
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#14b8a6'];
|
||||
|
||||
const Skeleton = ({ className }) => (
|
||||
<div className={`animate-pulse bg-slate-200 dark:bg-slate-800 rounded-xl ${className}`} />
|
||||
);
|
||||
|
||||
// Componente de Tooltip Customizado e Premium
|
||||
const CustomTooltip = ({ active, payload, label }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] p-4 rounded-2xl shadow-2xl backdrop-blur-md bg-opacity-95 dark:bg-opacity-95 ring-1 ring-black/5 z-[9999] pointer-events-none">
|
||||
{label && (
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 dark:text-slate-500 mb-2">
|
||||
{label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const rawValue = item?.value;
|
||||
const isNumber = typeof rawValue === 'number' && !Number.isNaN(rawValue);
|
||||
const displayValue = isNumber ? rawValue.toLocaleString() : (rawValue ?? '-');
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: item?.color || item?.fill || '#3b82f6' }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold text-slate-700 dark:text-slate-200">
|
||||
{item?.name ?? 'Valor'}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-white">
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StatisticsView() {
|
||||
const { data, loading: statsLoading, refresh } = usePrafrotStatistics();
|
||||
const { deleteVehicle } = useVehicles();
|
||||
const [selectedStatus, setSelectedStatus] = useState(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
if (statsLoading) {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-32 w-full" />)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
<Skeleton className="h-[400px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Prepara dados para os KPIs - Baseado nos dados reais fornecidos
|
||||
const totalVeiculos = data.totalPlacas;
|
||||
|
||||
// Em manutenção: Somar status "Aberto" de manutencao_frota ou status relacionados
|
||||
const emManutencao = data.placasPorManutencao
|
||||
.filter(m => m.manutencao === "Aberto" || m.manutencao === "ABERTO")
|
||||
.reduce((acc, curr) => acc + curr.total, 0);
|
||||
|
||||
// Disponíveis: Filtrar pela data mais recente e status DISPONÍVEL ou ROTA
|
||||
/* const disponiveis = data.placasPorDisponibilidade
|
||||
.filter(d => d.status_disponibilidade === 'DISPONÍVEL' || d.status_disponibilidade === 'ROTA' || d.status_disponibilidade === 'DISP')
|
||||
.reduce((acc, curr) => acc + curr.total, 0); */
|
||||
|
||||
const sinistros = data.placasPorSinistro.reduce((acc, curr) => acc + curr.total, 0);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Frota Total',
|
||||
value: totalVeiculos.toLocaleString(),
|
||||
icon: <Truck />,
|
||||
color: 'bg-orange-500/10 text-orange-600'
|
||||
},
|
||||
{
|
||||
label: 'Em Manutenção (Aberta)',
|
||||
value: emManutencao.toLocaleString(),
|
||||
icon: <Wrench />,
|
||||
color: 'bg-orange-500/10 text-orange-600'
|
||||
},
|
||||
/* {
|
||||
label: 'Em Operação/Disp.',
|
||||
value: disponiveis.toLocaleString(),
|
||||
icon: <CheckCircle2 />,
|
||||
color: 'bg-blue-500/10 text-blue-600'
|
||||
}, */
|
||||
/* {
|
||||
label: 'Sinistros/Vendas/Dev.',
|
||||
value: sinistros.toLocaleString(),
|
||||
icon: <AlertTriangle />,
|
||||
color: 'bg-red-500/10 text-red-600'
|
||||
} */
|
||||
];
|
||||
|
||||
// Nomes dos meses para o gráfico quantitativo
|
||||
const monthNames = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"];
|
||||
const formattedQuantitativo = data.quantitativoManutencao.map(item => ({
|
||||
...item,
|
||||
name: monthNames[item.mes - 1]
|
||||
}));
|
||||
|
||||
const chartStatusData = data.placasPorStatus.map(item => ({
|
||||
...item,
|
||||
status: (item.status || item.status_frota || 'NÃO INFORMADO').trim()
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-12">
|
||||
{/* Header com Design Premium */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
Dashboard <span className="text-orange-500">Estatístico</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium text-lg">
|
||||
Monitoramento de BI e KPIs em tempo real da operação de frota.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-600 dark:text-slate-300 font-bold text-sm hover:shadow-lg hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<RefreshCw size={18} className={statsLoading ? "animate-spin" : ""} />
|
||||
Sincronizar Dados
|
||||
</button>
|
||||
{/* <button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-orange-600 text-white font-bold text-sm hover:bg-orange-700 hover:shadow-xl hover:shadow-orange-500/20 hover:-translate-y-0.5 transition-all">
|
||||
<Download size={18} />
|
||||
Exportar BI
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Section */}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-orange-500/10 to-blue-500/10 blur-2xl opacity-50 -z-10" />
|
||||
<StatsGrid stats={stats} />
|
||||
</div>
|
||||
|
||||
{/* Status Distribution - Full Width */}
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all overflow-hidden bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b dark:border-[#2a2a2a] mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/10 rounded-xl text-blue-600">
|
||||
<PieChartIcon size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">Distribuição por Status</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8">
|
||||
{/* Graph Left */}
|
||||
<div className="h-[300px] w-full lg:w-1/2 min-h-[300px] relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartStatusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={80}
|
||||
outerRadius={110}
|
||||
paddingAngle={5}
|
||||
dataKey="total"
|
||||
nameKey="status"
|
||||
stroke="none"
|
||||
onClick={(_, index) => {
|
||||
const entry = chartStatusData[index];
|
||||
if (!entry) return;
|
||||
setSelectedStatus(entry);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{chartStatusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Center Text Overlay */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none text-center px-4">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-3xl font-bold text-slate-800 dark:text-white leading-tight">
|
||||
{totalVeiculos.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-[0.25em]">
|
||||
Veículos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Right - Row Visualization */}
|
||||
<div className="w-full lg:w-1/2 flex flex-col gap-3 h-[300px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{chartStatusData.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setSelectedStatus(entry);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="flex items-center justify-between p-4 rounded-xl bg-slate-50 dark:bg-[#252525] border border-slate-100 dark:border-[#333] hover:border-blue-500/30 transition-all cursor-pointer group hover:scale-[1.01] hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-3 h-3 rounded-full shrink-0 shadow-sm" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
<span className="text-sm font-bold text-slate-600 dark:text-slate-300 uppercase tracking-tight">{entry.status}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-2 w-24 bg-slate-200 dark:bg-[#333] rounded-full overflow-hidden hidden sm:block">
|
||||
<div className="h-full rounded-full" style={{ width: `${(entry.total / totalVeiculos) * 100}%`, backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-900 dark:text-white min-w-[40px] text-right">{entry.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Records Modal */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl text-orange-600 shadow-inner">
|
||||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
Veículos: {selectedStatus?.status}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Listagem completa dos {selectedStatus?.total} veículos com status <span className="text-orange-500 font-bold">"{selectedStatus?.status}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6 bg-white dark:bg-[#1c1c1c]">
|
||||
<div className="h-full w-full rounded-2xl border border-slate-200 dark:border-[#2a2a2a] overflow-hidden shadow-sm">
|
||||
<ExcelTable
|
||||
data={selectedStatus?.registros ? JSON.parse(selectedStatus.registros).filter(r => r !== null) : []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
{ header: 'Status', field: 'status', width: '140px' },
|
||||
{ header: 'Manutenção', field: 'manutencao', width: '120px' },
|
||||
{ header: 'Atuação', field: 'atuacao', width: '140px' },
|
||||
{ header: 'Proprietário', field: 'proprietario', width: '140px' },
|
||||
{ header: 'VecFleet', field: 'vecfleet', width: '140px' },
|
||||
{ header: 'OBS', field: 'obs', width: '250px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text' },
|
||||
{ field: 'motorista', label: 'Motorista', type: 'text' },
|
||||
{ field: 'base', label: 'Unidade', type: 'select' }
|
||||
]}
|
||||
onDelete={(item) => deleteVehicle(item.idveiculo_frota)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-4 border-t border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#1c1c1c]">
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-6 py-2 rounded-xl bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 font-bold text-sm transition-all border border-slate-200 dark:border-[#333]"
|
||||
>
|
||||
Fechar Listagem
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Main Charts - Line Chart Full Width now */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Fluxo de Manutenção */}
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all overflow-hidden bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b dark:border-[#2a2a2a] mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500/10 rounded-xl text-orange-600">
|
||||
<LineChartIcon size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">Fluxo de Manutenção Mensal</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={formattedQuantitativo} margin={{ top: 20, right: 30, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorEntrada" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.1}/>
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorSaida" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#94a3b8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94a3b8"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend iconType="circle" wrapperStyle={{ paddingTop: '20px' }} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total_entrada"
|
||||
name="Veículos Entrando"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={4}
|
||||
dot={{ r: 4, fill: '#f59e0b', strokeWidth: 2, stroke: '#fff' }}
|
||||
activeDot={{ r: 6, strokeWidth: 0 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total_saida"
|
||||
name="Veículos Liberados"
|
||||
stroke="#10b981"
|
||||
strokeWidth={4}
|
||||
dot={{ r: 4, fill: '#10b981', strokeWidth: 2, stroke: '#fff' }}
|
||||
activeDot={{ r: 6, strokeWidth: 0 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 3 - Regional (Full Width) */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Veículos por Base */}
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Regional</span>
|
||||
<MapPin size={14} className="text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-bold">Veículos por Base</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.placasPorBase} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
|
||||
<XAxis dataKey="base" stroke="#94a3b8" fontSize={11} axisLine={false} tickLine={false} />
|
||||
<YAxis stroke="#94a3b8" fontSize={11} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} content={<CustomTooltip />} />
|
||||
<Bar dataKey="total" fill="#10b981" radius={[6, 6, 0, 0]} barSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - Chronology and Category */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Ano de Fabricação */}
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Cronologia</span>
|
||||
<Calendar size={14} className="text-blue-500" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-bold">Idade da Frota (Ano)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.placasPorAno}>
|
||||
<XAxis dataKey="ano_fabricacao" stroke="#94a3b8" fontSize={9} axisLine={false} tickLine={false} />
|
||||
<YAxis hide />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} content={<CustomTooltip />} />
|
||||
<Bar dataKey="total" name="Total de Veículos" fill="#3b82f6" radius={[6, 6, 0, 0]} barSize={32} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Categorias */}
|
||||
<Card className="border-none shadow-sm hover:shadow-md transition-all bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Classificação</span>
|
||||
<Layers size={14} className="text-purple-500" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-bold">Frota por Categoria</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.placasPorCategoria} layout="vertical" margin={{ left: 0, right: 40, top: 10, bottom: 10 }}>
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#8b5cf6" />
|
||||
<stop offset="100%" stopColor="#a78bfa" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
dataKey="categoria"
|
||||
type="category"
|
||||
fontSize={10}
|
||||
stroke="#94a3b8"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={90}
|
||||
tick={{ fontWeight: '600' }}
|
||||
/>
|
||||
<Tooltip cursor={{ fill: 'rgba(139, 92, 246, 0.05)' }} content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="total"
|
||||
name="Total de Veículos"
|
||||
fill="url(#barGradient)"
|
||||
radius={[0, 10, 10, 0]}
|
||||
barSize={18}
|
||||
label={{
|
||||
position: 'right',
|
||||
fill: '#94a3b8',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
formatter: (val) => val.toLocaleString()
|
||||
}}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - Details and Units */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* <Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="flex flex-row items-center justify-between overflow-hidden">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold">Disponibilidade Detalhada</CardTitle>
|
||||
<p className="text-sm text-slate-500">Breakdown por status de operação.</p>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-500/10 rounded-2xl text-orange-600">
|
||||
<Gauge size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="space-y-5">
|
||||
{data.placasPorDisponibilidade.slice(0, 10).map((item, i) => (
|
||||
<div key={i} className="group cursor-default">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-slate-100 group-hover:text-orange-500 transition-colors uppercase tracking-tight truncate">{item.status_disponibilidade || 'NÃO INFORMADO'}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase truncate">{item.disponibilidade}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-white">{item.total}</span>
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded bg-slate-100 dark:bg-[#2a2a2a] text-slate-500">
|
||||
{((item.total / (totalVeiculos || 1)) * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-100 dark:bg-[#2a2a2a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${(item.total / (totalVeiculos || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card className="border-none shadow-sm bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold">Monitoramento por Unidade</CardTitle>
|
||||
<p className="text-sm text-slate-500">Volume de monitoramento ativo por central.</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-500/10 rounded-2xl text-blue-600">
|
||||
<BarChart3 size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-[500px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{data.placasPorUnidade.map((item, i) => (
|
||||
<div key={i} className="p-4 rounded-2xl bg-slate-50 dark:bg-[#252525] border border-slate-100 dark:border-[#2a2a2a] flex justify-between items-center hover:border-blue-500/50 hover:shadow-md transition-all">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Unidade</span>
|
||||
<span className="text-sm font-bold text-slate-700 dark:text-slate-200 truncate">{item.unidade}</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-blue-500 shrink-0">{item.total}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.placasPorUnidade.length === 0 && (
|
||||
<div className="col-span-2 py-12 flex flex-col items-center justify-center text-slate-400">
|
||||
<Layers size={48} strokeWidth={1} className="mb-2 opacity-20" />
|
||||
<p className="text-sm font-medium">Nenhum dado de unidade encontrado</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,349 +0,0 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useStatus } from '../hooks/useStatus';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useDrivers } from '../hooks/useDrivers';
|
||||
import { useFleetLists } from '../hooks/useFleetLists';
|
||||
import AutocompleteInput from '../components/AutocompleteInput';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Inline Status Editor ---
|
||||
const StatusCell = ({ currentStatus, id, options, onUpdate }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState(currentStatus);
|
||||
|
||||
useEffect(() => {
|
||||
setTempValue(currentStatus);
|
||||
}, [currentStatus]);
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (tempValue !== currentStatus) {
|
||||
onUpdate(id, tempValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setTempValue(e.target.value);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<select
|
||||
autoFocus
|
||||
value={tempValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-orange-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
currentStatus === 'Disponível' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
currentStatus === 'Em Manutenção' ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}
|
||||
>
|
||||
{currentStatus || 'N/A'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function StatusView() {
|
||||
const { statusList, loading, fetchStatus, createStatus, updateStatus, updateStatusInline, updateStatusBatch, deleteStatus } = useStatus();
|
||||
const { fetchListsConfig, statusFrotaOptions } = useFleetLists();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
atuacao: '', base: '', categoria: '', id_rota: '', idstatus_frota: '',
|
||||
manutencao: '', modelo: '', motorista: '', obs: '', placa: '',
|
||||
placa_reserva: '', proprietario: '', status_frota: 'Disponível',
|
||||
tipo_placa: '', uf: '', vecfleet: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
const { vehicles, fetchVehicles } = useVehicles();
|
||||
const { drivers, fetchDrivers } = useDrivers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
fetchVehicles();
|
||||
fetchDrivers();
|
||||
fetchListsConfig();
|
||||
}, []);
|
||||
|
||||
const handleVehicleSelect = (vehicle) => {
|
||||
if (!vehicle) return;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
placa: vehicle.placa || prev.placa,
|
||||
placa_reserva: vehicle.placa_reserva || prev.placa_reserva,
|
||||
modelo: vehicle.modelo || vehicle.mod_veiculo || prev.modelo,
|
||||
categoria: vehicle.categoria || vehicle.cat_veiculo || prev.categoria,
|
||||
tipo_placa: vehicle.tipo_placa || prev.tipo_placa,
|
||||
base: vehicle.base || prev.base,
|
||||
uf: vehicle.uf || prev.uf,
|
||||
proprietario: vehicle.proprietario || prev.proprietario,
|
||||
atuacao: vehicle.atuacao || prev.atuacao,
|
||||
vecfleet: vehicle.vecfleet || prev.vecfleet,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...initialFormState, ...item });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateStatus(editingItem.idstatus_frota, formData);
|
||||
} else {
|
||||
success = await createStatus(formData);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
// ... (effects)
|
||||
|
||||
const handleStatusUpdate = async (id, newStatus) => {
|
||||
// Verificação robusta com conversão para String para evitar incompatibilidade de tipos
|
||||
const isSelected = selectedIds.some(sid => String(sid) === String(id));
|
||||
|
||||
if (isSelected && selectedIds.length > 1) {
|
||||
// Confirmação simples para evitar acidentes apenas se houver mais de um item selecionado
|
||||
const confirmUpdate = window.confirm(`Você selecionou ${selectedIds.length} itens. Deseja atualizar o status de TODOS eles para "${newStatus}"?`);
|
||||
|
||||
if (confirmUpdate) {
|
||||
const success = await updateStatusBatch(selectedIds, newStatus);
|
||||
if (success) {
|
||||
setSelectedIds([]); // Limpa a seleção após atualizar com sucesso
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Agora o Single Update também utiliza o endpoint em lote
|
||||
await updateStatusBatch([id], newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => Array.isArray(statusList) ? statusList.filter(item =>
|
||||
item.placa?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.motorista?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [], [statusList, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ... Header ... */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Status da Frota</h1>
|
||||
<p className="text-slate-500 text-sm">Histórico de movimentação e estados dos veículos.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
{/* ... Search ... */}
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Nova Alteração Status
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
rowKey="idstatus_frota"
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idstatus_frota', width: '80px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS', field: 'status_frota', width: '150px', render: (row) => (
|
||||
<StatusCell
|
||||
currentStatus={row.status_frota}
|
||||
id={row.idstatus_frota}
|
||||
options={statusFrotaOptions}
|
||||
onUpdate={handleStatusUpdate}
|
||||
/>
|
||||
)},
|
||||
{ header: 'MOTORISTA', field: 'motorista', width: '150px' },
|
||||
{ header: 'PLACA RESERVA', field: 'placa_reserva', width: '110px' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '120px' },
|
||||
{ header: 'CATEGORIA', field: 'categoria', width: '100px' },
|
||||
{ header: 'TIPO PLACA', field: 'tipo_placa', width: '100px' },
|
||||
{ header: 'BASE', field: 'base', width: '100px' },
|
||||
{ header: 'UF', field: 'uf', width: '60px' },
|
||||
{ header: 'PROPRIETÁRIO', field: 'proprietario', width: '140px' },
|
||||
{ header: 'ATUAÇÃO', field: 'atuacao', width: '140px' },
|
||||
{ header: 'MANUTENÇÃO', field: 'manutencao', width: '150px' },
|
||||
{ header: 'ID ROTA', field: 'id_rota', width: '100px' },
|
||||
{ header: 'VECFLEET', field: 'vecfleet', width: '100px' },
|
||||
{ header: 'OBS', field: 'obs', width: '200px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'Buscar placa...' },
|
||||
{ field: 'base', label: 'Base', type: 'select' },
|
||||
{ field: 'status_frota', label: 'Status', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteStatus(item.idstatus_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold">
|
||||
{editingItem ? 'Editar Registro de Status' : 'Nova Alteração de Status'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
Atualize o estado atual do veículo na frota.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
<div className="grid grid-cols-3 gap-4 pb-4">
|
||||
<AutocompleteInput
|
||||
label="Placa"
|
||||
value={formData.placa}
|
||||
onChange={v => setFormData({...formData, placa: v})}
|
||||
options={vehicles}
|
||||
displayKey="placa"
|
||||
valueKey="placa"
|
||||
searchKeys={['placa']}
|
||||
onSelect={handleVehicleSelect}
|
||||
required
|
||||
/>
|
||||
<DarkInput label="Placa Reserva" value={formData.placa_reserva} onChange={e => setFormData({...formData, placa_reserva: e.target.value})} />
|
||||
<DarkSelect label="Novo Status" options={statusFrotaOptions} value={formData.status_frota} onChange={v => setFormData({...formData, status_frota: v})} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pb-4">
|
||||
<AutocompleteInput
|
||||
label="Motorista"
|
||||
value={formData.motorista}
|
||||
onChange={v => setFormData({...formData, motorista: v})}
|
||||
options={drivers}
|
||||
displayKey="NOME_FAVORECIDO"
|
||||
valueKey="NOME_FAVORECIDO"
|
||||
placeholder="Buscar motorista..."
|
||||
/>
|
||||
<DarkInput label="Base" value={formData.base} onChange={e => setFormData({...formData, base: e.target.value})} />
|
||||
<DarkInput label="ID Rota" value={formData.id_rota} onChange={e => setFormData({...formData, id_rota: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 pb-4">
|
||||
<DarkInput label="Tipo Placa" value={formData.tipo_placa} onChange={e => setFormData({...formData, tipo_placa: e.target.value})} />
|
||||
<DarkInput label="Categoria" value={formData.categoria} onChange={e => setFormData({...formData, categoria: e.target.value})} />
|
||||
<DarkInput label="Modelo" value={formData.modelo} onChange={e => setFormData({...formData, modelo: e.target.value})} />
|
||||
<DarkInput label="UF" value={formData.uf} onChange={e => setFormData({...formData, uf: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 pb-4">
|
||||
<DarkInput label="Proprietário" value={formData.proprietario} onChange={e => setFormData({...formData, proprietario: e.target.value})} />
|
||||
<DarkInput label="Atuação" value={formData.atuacao} onChange={e => setFormData({...formData, atuacao: e.target.value})} />
|
||||
<DarkInput label="Vecfleet" value={formData.vecfleet} onChange={e => setFormData({...formData, vecfleet: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
<DarkInput label="Manutenção (Info)" value={formData.manutencao} onChange={e => setFormData({...formData, manutencao: e.target.value})} />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 pb-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações / Motivo</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[80px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sticky bottom-0 bg-white dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit">Atualizar Registro</DarkButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
|
||||
const TableDebug = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Mock Data matching the screenshot structure
|
||||
const vehicles = useMemo(() => {
|
||||
// Top specific rows to match screenshot
|
||||
const topRows = [
|
||||
{ id: 1, modelo: 'TEO0G02', placa: 'TEO0G02', status: 'Em uso', motorista: 'Larissa Ribeiro de Souza', combustivel: 'Gasolina', tipo: 'Passeio', marca: 'Fiat', ano: '2020', cidade: 'São Paulo', empresa: 'Empresa A', financiamento: 'Sim', financeira: 'Banco X' },
|
||||
{ id: 2, modelo: 'HFB3A70', placa: 'HFB3A70', status: 'Não informado', motorista: 'Não atribuído', combustivel: 'Etanol', tipo: 'Utilitário', marca: 'VW', ano: '2021', cidade: 'Rio', empresa: 'Empresa B', financiamento: 'Não', financeira: '-' },
|
||||
{ id: 3, modelo: 'LUN1968', placa: 'LUN1968', status: 'Manutenção', motorista: 'Carlos Silva', combustivel: 'Diesel', tipo: 'Caminhão', marca: 'Volvo', ano: '2019', cidade: 'Curitiba', empresa: 'Empresa A', financiamento: 'Sim', financeira: 'Banco Y' },
|
||||
// ... mix of data
|
||||
];
|
||||
|
||||
// Fill rest with randomized variety
|
||||
const fillRows = Array.from({ length: 40 }, (_, i) => ({
|
||||
id: 10 + i,
|
||||
modelo: `FLEX${i}`,
|
||||
placa: `ABC${10 + i}99`,
|
||||
status: ['Em uso', 'Manutenção', 'Vendido'][i % 3], // Varied status
|
||||
motorista: i % 2 === 0 ? 'Motorista Teste' : 'Não atribuído',
|
||||
combustivel: ['Flex', 'Gasolina', 'Diesel'][i % 3],
|
||||
tipo: ['Passeio', 'Utilitário', 'Caminhão'][i % 3],
|
||||
marca: ['Fiat', 'VW', 'Ford', 'Chevrolet'][i % 4],
|
||||
ano: String(2015 + (i % 10)),
|
||||
cidade: ['São Paulo', 'Rio', 'Belo Horizonte'][i % 3],
|
||||
empresa: ['Empresa A', 'Empresa B', 'Empresa C'][i % 3],
|
||||
financiamento: i % 2 === 0 ? 'Sim' : 'Não',
|
||||
financeira: i % 2 === 0 ? `Banco ${['X', 'Y', 'Z'][i % 3]}` : '-'
|
||||
}));
|
||||
|
||||
return [...topRows, ...fillRows];
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{ header: 'MODELO', field: 'modelo', width: '120px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'text-[#eab308] font-bold' }, // Yellow text for Placa
|
||||
{
|
||||
header: 'STATUS',
|
||||
field: 'status',
|
||||
width: '140px',
|
||||
render: (row) => (
|
||||
row.status === 'Em uso'
|
||||
? <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-[#1d4ed8] text-white">Em uso</span>
|
||||
: <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-[#333] text-[#aaa]">Não informado</span>
|
||||
)
|
||||
},
|
||||
{ header: 'MOTORISTA ATUAL', field: 'motorista', width: '200px' },
|
||||
{ header: 'COMBUSTÍVEL', field: 'combustivel', width: '120px' },
|
||||
{ header: 'TIPO', field: 'tipo', width: '100px' },
|
||||
{ header: 'MARCA', field: 'marca', width: '120px' },
|
||||
{ header: 'ANO', field: 'ano', width: '80px' },
|
||||
{ header: 'CIDADE', field: 'cidade', width: '120px' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#111] text-white p-8 font-sans">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-[#eab308] mb-2 flex items-center gap-3">
|
||||
<span className="p-2 bg-[#eab308]/10 rounded-lg">🔧</span>
|
||||
Debug - Tabela Prafrot
|
||||
</h1>
|
||||
<p className="text-slate-400">Teste standalone da tabela Excel-style (Pixel Perfect)</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar placa..."
|
||||
className="w-full bg-[#1a1a1a] border border-[#333] rounded px-10 py-2 text-sm text-slate-200 focus:outline-none focus:border-[#eab308] placeholder:text-slate-600 font-medium"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-[#eab308] text-black font-bold rounded hover:bg-[#ca9a00] transition-colors flex items-center gap-2 text-sm">
|
||||
+ Novo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New ExcelTable Component */}
|
||||
<div className="h-[600px]">
|
||||
<ExcelTable
|
||||
data={vehicles}
|
||||
columns={columns}
|
||||
filterDefs={[
|
||||
{ field: 'empresa', label: 'Empresa', type: 'select' },
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'por placa' },
|
||||
{ field: 'status', label: 'Status', type: 'select' },
|
||||
{ field: 'combustivel', label: 'Combustível', type: 'select' },
|
||||
{ field: 'tipo', label: 'Tipo', type: 'select' },
|
||||
{ field: 'modelo', label: 'Modelo', type: 'text', placeholder: 'por modelo' },
|
||||
{ field: 'financiamento', label: 'Financiamento', type: 'select' },
|
||||
{ field: 'financeira', label: 'Financiador', type: 'text', placeholder: 'financiador' },
|
||||
{ field: 'motorista', label: 'Proprietário', type: 'text', placeholder: 'proprietário' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableDebug;
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,548 +0,0 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useDrivers } from '../hooks/useDrivers';
|
||||
import { useFleetLists } from '../hooks/useFleetLists';
|
||||
import AutocompleteInput from '../components/AutocompleteInput';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import {
|
||||
Plus, Filter, Search, Edit2, Trash2,
|
||||
MapPin, Truck, Calendar, DollarSign, Wrench, CheckCircle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from 'sonner';
|
||||
import { useFeedback } from '../components/FeedbackNotification';
|
||||
|
||||
// --- Components Styled for Dark Theme ---
|
||||
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Inline Status Editor ---
|
||||
const StatusCell = ({ currentStatus, idVehicle, options, onUpdate }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tempValue, setTempValue] = useState(currentStatus);
|
||||
|
||||
useEffect(() => {
|
||||
setTempValue(currentStatus);
|
||||
}, [currentStatus]);
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (tempValue !== currentStatus) {
|
||||
onUpdate(idVehicle, tempValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setTempValue(e.target.value);
|
||||
// Optional: Auto-save immediately on change instead of blur?
|
||||
// User asked: "Clicar fora e enviaria", so blur is better.
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<select
|
||||
autoFocus
|
||||
value={tempValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-orange-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent row selection if applied
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
currentStatus === 'ATIVO' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}
|
||||
>
|
||||
{currentStatus || 'N/A'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
import { prafrotStatisticsService } from '../services/prafrotStatisticsService';
|
||||
|
||||
export default function VehiclesView() {
|
||||
const { vehicles, loading, fetchVehicles, createVehicle, updateVehicle, updateVehicleStatus, deleteVehicle } = useVehicles();
|
||||
const { fetchListsConfig, statusFrotaOptions } = useFleetLists();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingVehicle, setEditingVehicle] = useState(null);
|
||||
const [statusStats, setStatusStats] = useState([]);
|
||||
const [selectedStatusRecords, setSelectedStatusRecords] = useState(null);
|
||||
const [isRecordsModalOpen, setIsRecordsModalOpen] = useState(false);
|
||||
|
||||
// Initial Form State
|
||||
const initialFormState = {
|
||||
ano_fabricacao: '', ano_modelo: '', atuacao: '', base: '', categoria: 'Passeio',
|
||||
chassi: '', cnpj: '', combustivel: 'Gasolina', contrato: '', coordenador: '',
|
||||
cor: '', data_limite: '', dispatcher: '', fabricante: '', fiscal_operacao: '',
|
||||
geotab: 'NÃO', gestor: '', golfleet: 'NÃO', idveiculo_frota: '', modelo: '',
|
||||
placa: '', pooltrack: 'NÃO', primeira_locacao: '', proprietario: '',
|
||||
renavam: '', sascar: 'NÃO', t4s: 'NÃO', tipo_de_placa: '', tipo_frota: '',
|
||||
uf: '', valor_aluguel: '', valor_fipe: '',
|
||||
situacao: 'ATIVO', km_atual: '', observacoes: '', empresa: '', financiamento: '',
|
||||
locadora: '', motorista_atual: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
const { drivers, fetchDrivers } = useDrivers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchVehicles();
|
||||
fetchDrivers();
|
||||
fetchListsConfig();
|
||||
|
||||
// Fetch Status Stats
|
||||
prafrotStatisticsService.getPlacasPorStatus().then(data => {
|
||||
if (Array.isArray(data)) setStatusStats(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getStatusData = (status) => {
|
||||
return statusStats.find(item => {
|
||||
const itemStatus = (item.status || item.status_frota || '');
|
||||
return itemStatus.trim().toLowerCase() === status?.trim().toLowerCase();
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusClick = (status) => {
|
||||
const data = getStatusData(status);
|
||||
if (data && data.registros) {
|
||||
setSelectedStatusRecords({
|
||||
title: status,
|
||||
records: JSON.parse(data.registros).filter(r => r !== null)
|
||||
});
|
||||
setIsRecordsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (vehicle = null) => {
|
||||
if (vehicle) {
|
||||
setEditingVehicle(vehicle);
|
||||
setFormData({
|
||||
...initialFormState,
|
||||
...vehicle, // Fill with vehicle data
|
||||
tipo_placa: vehicle.tipo_de_placa || vehicle.tipo_placa || '', // Handle varied naming
|
||||
data_aquisicao: vehicle.data_aquisicao?.split('T')[0] || '',
|
||||
data_venda: vehicle.data_venda?.split('T')[0] || '',
|
||||
data_limite: vehicle.data_limite?.split('T')[0] || '',
|
||||
primeira_locacao: vehicle.primeira_locacao?.split('T')[0] || '',
|
||||
situacao: vehicle.situacao || vehicle.status_frota || 'ATIVO',
|
||||
});
|
||||
} else {
|
||||
setEditingVehicle(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const { success: notifySuccess, error: notifyError, notifyFields } = useFeedback();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validação de campos obrigatórios
|
||||
const requiredFields = [];
|
||||
if (!formData.placa) requiredFields.push('Placa');
|
||||
if (!formData.modelo) requiredFields.push('Modelo');
|
||||
if (!formData.fabricante) requiredFields.push('Fabricante');
|
||||
|
||||
if (requiredFields.length > 0) {
|
||||
notifyFields(requiredFields);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { ...formData, tipo_de_placa: formData.tipo_placa };
|
||||
|
||||
delete payload.motorista_atual;
|
||||
delete payload.empresa;
|
||||
delete payload.locadora;
|
||||
delete payload.km_atual;
|
||||
delete payload.data_venda;
|
||||
delete payload.valor_venda;
|
||||
delete payload.situacao;
|
||||
|
||||
let success;
|
||||
if (editingVehicle) {
|
||||
success = await updateVehicle(editingVehicle.idveiculo_frota, payload);
|
||||
} else {
|
||||
success = await createVehicle(payload);
|
||||
}
|
||||
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredVehicles = useMemo(() => Array.isArray(vehicles) ? vehicles.filter(v =>
|
||||
v.placa?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
v.modelo?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [], [vehicles, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Actions */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Frota & Ativos</h1>
|
||||
<p className="text-slate-500 text-sm">Gerencie os veículos cadastrados na plataforma.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Veículo
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
onClick={() => handleStatusClick('Veículo Alugado')}
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex items-center justify-between group cursor-pointer hover:border-blue-500/30"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-500/10 rounded-xl text-blue-600 group-hover:bg-blue-500 group-hover:text-white transition-colors">
|
||||
<Truck size={24} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Veículos Alugados</span>
|
||||
<div className="text-3xl font-bold text-slate-800 dark:text-white mt-0.5">
|
||||
{getStatusData('Veículo Alugado')?.total || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleStatusClick('Em Operação')}
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex items-center justify-between group cursor-pointer hover:border-orange-500/30"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-orange-500/10 rounded-xl text-orange-600 group-hover:bg-orange-500 group-hover:text-white transition-colors">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-500 dark:text-slate-400">Em Operação</span>
|
||||
<div className="text-3xl font-bold text-slate-800 dark:text-white mt-0.5">
|
||||
{getStatusData('Em Operação')?.total || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modular Excel Table */}
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredVehicles}
|
||||
loading={loading}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idveiculo_frota', width: '80px' },
|
||||
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '140px' },
|
||||
{ header: ' FABRICANTE', field: 'fabricante', width: '120px' },
|
||||
{ header: 'CORES', field: 'cor', width: '100px' },
|
||||
{ header: 'ANO FAB.', field: 'ano_fabricacao', width: '80px' },
|
||||
{ header: 'ANO MOD.', field: 'ano_modelo', width: '80px' },
|
||||
{ header: 'RENAVAM', field: 'renavam', width: '130px' },
|
||||
{ header: 'CHASSI', field: 'chassi', width: '180px' },
|
||||
{ header: 'CATEGORIA', field: 'categoria', width: '120px' },
|
||||
{ header: 'COMBUSTÍVEL', field: 'combustivel', width: '100px' },
|
||||
{ header: 'TIPO PLACA', field: 'tipo_de_placa', width: '100px' },
|
||||
|
||||
{ header: 'TIPO FROTA', field: 'tipo_frota', width: '120px' },
|
||||
{ header: 'ATUAÇÃO', field: 'atuacao', width: '150px' },
|
||||
{ header: 'BASE', field: 'base', width: '120px' },
|
||||
{ header: 'UF', field: 'uf', width: '60px' },
|
||||
{ header: 'PROPRIETÁRIO', field: 'proprietario', width: '140px' },
|
||||
{ header: 'CNPJ', field: 'cnpj', width: '150px' },
|
||||
{ header: 'CONTRATO', field: 'contrato', width: '120px' },
|
||||
{ header: 'GESTOR', field: 'gestor', width: '140px' },
|
||||
{ header: 'COORDENADOR', field: 'coordenador', width: '140px' },
|
||||
{ header: 'DISPATCHER', field: 'dispatcher', width: '140px' },
|
||||
{ header: 'FISCAL OPER.', field: 'fiscal_operacao', width: '140px' },
|
||||
{ header: 'VALOR FIPE', field: 'valor_fipe', width: '120px' },
|
||||
{ header: 'VALOR ALUGUEL', field: 'valor_aluguel', width: '120px' },
|
||||
{ header: '1ª LOCAÇÃO', field: 'primeira_locacao', width: '110px' },
|
||||
{ header: 'DATA LIMITE', field: 'data_limite', width: '110px' },
|
||||
{ header: 'GEOTAB', field: 'geotab', width: '80px' },
|
||||
{ header: 'SASCAR', field: 'sascar', width: '80px' },
|
||||
{ header: 'GOLFLEET', field: 'golfleet', width: '80px' },
|
||||
{ header: 'POOLTRACK', field: 'pooltrack', width: '80px' },
|
||||
{ header: 'T4S', field: 't4s', width: '80px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text', placeholder: 'Buscar placa...' },
|
||||
{ field: 'modelo', label: 'Modelo', type: 'select' },
|
||||
{ field: 'fabricante', label: 'Fabricante', type: 'select' },
|
||||
{ field: 'base', label: 'Base', type: 'select' },
|
||||
{ field: 'proprietario', label: 'Proprietário', type: 'select' },
|
||||
{ field: 'tipo_frota', label: 'Tipo de Frota', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteVehicle(item.idveiculo_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal - Dark Theme */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] bg-slate-50 dark:bg-[#1c1c1c]">
|
||||
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
{editingVehicle ? `Editando: ${formData.placa}` : 'Cadastro de Novo Veículo'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
Preencha os dados completos do ativo na frota.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-2 max-h-[75vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Seletor de Status Destacado */}
|
||||
{/* Seletor de Status removido conforme solicitacao */}
|
||||
|
||||
|
||||
<Tabs defaultValue="basicos" className="w-full">
|
||||
<TabsList className="bg-slate-100 dark:bg-[#141414] border border-slate-200 dark:border-[#2a2a2a] w-full justify-start p-1 h-auto mb-4">
|
||||
{['basicos', 'operacional', 'financeiro', 'tecnico'].map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
>
|
||||
{tab === 'tecnico' ? 'RASTREADOR' : tab}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="pb-4 min-h-[350px]">
|
||||
<TabsContent value="basicos" className="space-y-4 m-0">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Placa" value={formData.placa} onChange={e => setFormData({...formData, placa: e.target.value})} required />
|
||||
<DarkInput label="Chassi" value={formData.chassi} onChange={e => setFormData({...formData, chassi: e.target.value})} />
|
||||
<DarkInput label="Renavam" value={formData.renavam} onChange={e => setFormData({...formData, renavam: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Modelo" value={formData.modelo} onChange={e => setFormData({...formData, modelo: e.target.value})} />
|
||||
<DarkInput label="Fabricante" value={formData.fabricante} onChange={e => setFormData({...formData, fabricante: e.target.value})} />
|
||||
<DarkInput label="Cor" value={formData.cor} onChange={e => setFormData({...formData, cor: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<DarkInput label="Ano Fab." value={formData.ano_fabricacao} onChange={e => setFormData({...formData, ano_fabricacao: e.target.value})} />
|
||||
<DarkInput label="Ano Mod." value={formData.ano_modelo} onChange={e => setFormData({...formData, ano_modelo: e.target.value})} />
|
||||
<DarkSelect label="Categoria" options={['Utilitário', 'Passeio', 'Pesado']} value={formData.categoria} onChange={v => setFormData({...formData, categoria: v})} />
|
||||
<DarkSelect label="Combustível" options={['Flex', 'Diesel', 'Gasolina', 'Elétrico']} value={formData.combustivel} onChange={v => setFormData({...formData, combustivel: v})} />
|
||||
</div>
|
||||
<DarkInput label="Tipo Placa (Contexto)" value={formData.tipo_placa} onChange={e => setFormData({...formData, tipo_placa: e.target.value})} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="operacional" className="space-y-4 m-0">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DarkInput label="Base" value={formData.base} onChange={e => setFormData({...formData, base: e.target.value})} />
|
||||
<DarkInput label="UF" value={formData.uf} onChange={e => setFormData({...formData, uf: e.target.value})} maxLength={2} />
|
||||
{/* Proprietário comentado nesta aba conforme nova solicitação para aba financeiro */}
|
||||
{/* <DarkSelect label="Proprietário" options={['Localiza', 'Movida', 'Próprio', 'Unidas']} value={formData.proprietario} onChange={v => setFormData({...formData, proprietario: v})} /> */}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Atuação / Operação" value={formData.atuacao} onChange={e => setFormData({...formData, atuacao: e.target.value})} />
|
||||
<DarkInput label="Tipo Frota" value={formData.tipo_frota} onChange={e => setFormData({...formData, tipo_frota: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Gestor" value={formData.gestor} onChange={e => setFormData({...formData, gestor: e.target.value})} />
|
||||
<DarkInput label="Coordenador" value={formData.coordenador} onChange={e => setFormData({...formData, coordenador: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Dispatcher" value={formData.dispatcher} onChange={e => setFormData({...formData, dispatcher: e.target.value})} />
|
||||
<DarkInput label="Fiscal de Operação" value={formData.fiscal_operacao} onChange={e => setFormData({...formData, fiscal_operacao: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Campos motorista e status comentados para escrita e envio ao back conforme solicitado */}
|
||||
{/* <AutocompleteInput
|
||||
label="Motorista Atual"
|
||||
value={formData.motorista_atual}
|
||||
onChange={v => setFormData({...formData, motorista_atual: v})}
|
||||
options={drivers}
|
||||
displayKey="NOME_FAVORECIDO"
|
||||
valueKey="NOME_FAVORECIDO"
|
||||
placeholder="Buscar motorista..."
|
||||
/>
|
||||
<DarkSelect label="Status Veículo" options={['ATIVO', 'INATIVO', 'MANUTENÇÃO', 'VENDIDO', 'SINISTRADO']} value={formData.status_veiculo} onChange={v => setFormData({...formData, status_veiculo: v})} /> */}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="financeiro" className="space-y-4 m-0">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="Valor FIPE" value={formData.valor_fipe} onChange={e => setFormData({...formData, valor_fipe: e.target.value})} />
|
||||
<DarkInput label="Valor Aluguel" value={formData.valor_aluguel} onChange={e => setFormData({...formData, valor_aluguel: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput label="CNPJ Proprietário" value={formData.cnpj} onChange={e => setFormData({...formData, cnpj: e.target.value})} />
|
||||
<DarkInput label="Contrato" value={formData.contrato} onChange={e => setFormData({...formData, contrato: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Empresa e Locadora comentados para escrita e envio ao back */}
|
||||
{/* <DarkInput label="Empresa" value={formData.empresa} onChange={e => setFormData({...formData, empresa: e.target.value})} /> */}
|
||||
<DarkInput label="Proprietário" value={formData.proprietario} onChange={e => setFormData({...formData, proprietario: e.target.value})} />
|
||||
<DarkInput label="Financiamento" value={formData.financiamento} onChange={e => setFormData({...formData, financiamento: e.target.value})} />
|
||||
{/* <DarkInput label="Locadora" value={formData.locadora} onChange={e => setFormData({...formData, locadora: e.target.value})} /> */}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput type="date" label="Data Aquisição" value={formData.data_aquisicao} onChange={e => setFormData({...formData, data_aquisicao: e.target.value})} />
|
||||
<DarkInput type="number" label="Valor Aquisição" value={formData.valor_aquisicao} onChange={e => setFormData({...formData, valor_aquisicao: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Campos comentados para escrita e envio ao back conforme solicitado */}
|
||||
{/* <DarkInput type="date" label="Data Venda" value={formData.data_venda} onChange={e => setFormData({...formData, data_venda: e.target.value})} />
|
||||
<DarkInput type="number" label="Valor Venda" value={formData.valor_venda} onChange={e => setFormData({...formData, valor_venda: e.target.value})} /> */}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DarkInput type="date" label="Primeira Locação" value={formData.primeira_locacao} onChange={e => setFormData({...formData, primeira_locacao: e.target.value})} />
|
||||
<DarkInput type="date" label="Data Limite" value={formData.data_limite} onChange={e => setFormData({...formData, data_limite: e.target.value})} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tecnico" className="space-y-4 m-0">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* KM Atual comentado para escrita e envio ao back */}
|
||||
{/* <DarkInput label="KM Atual" value={formData.km_atual} onChange={e => setFormData({...formData, km_atual: e.target.value})} /> */}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 pt-2">
|
||||
{['geotab', 'sascar', 'golfleet', 'pooltrack', 't4s'].map(tracker => (
|
||||
<DarkSelect key={tracker} label={tracker.toUpperCase()} options={['SIM', 'NÃO']} value={formData[tracker]} onChange={v => setFormData({...formData, [tracker]: v})} />
|
||||
))}
|
||||
</div>
|
||||
<div className="gap-4 pt-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 tracking-wider ml-1">Observações Gerais</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
value={formData.observacoes}
|
||||
onChange={e => setFormData({...formData, observacoes: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="bg-slate-50 dark:bg-[#141414] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit" onClick={handleSubmit}>Salvar Veículo</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Records Detail Modal */}
|
||||
<Dialog open={isRecordsModalOpen} onOpenChange={setIsRecordsModalOpen}>
|
||||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-blue-500/10 rounded-2xl text-blue-600 shadow-inner">
|
||||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
Veículos: {selectedStatusRecords?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Listagem completa dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-blue-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6 bg-white dark:bg-[#1c1c1c]">
|
||||
<div className="h-full w-full rounded-2xl border border-slate-200 dark:border-[#2a2a2a] overflow-hidden shadow-sm">
|
||||
<ExcelTable
|
||||
data={selectedStatusRecords?.records || []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
{ header: 'Status', field: 'status', width: '140px' },
|
||||
{ header: 'Manutenção', field: 'manutencao', width: '120px' },
|
||||
{ header: 'Atuação', field: 'atuacao', width: '140px' },
|
||||
{ header: 'Proprietário', field: 'proprietario', width: '140px' },
|
||||
{ header: 'VecFleet', field: 'vecfleet', width: '140px' },
|
||||
{ header: 'OBS', field: 'obs', width: '250px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'placa', label: 'Placa', type: 'text' },
|
||||
{ field: 'motorista', label: 'Motorista', type: 'text' },
|
||||
{ field: 'base', label: 'Unidade', type: 'select' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-4 border-t border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#1c1c1c]">
|
||||
<DarkButton variant="secondary" onClick={() => setIsRecordsModalOpen(false)}>
|
||||
Fechar Listagem
|
||||
</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useWorkshops } from '../hooks/useWorkshops';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// Reusing styled components locally
|
||||
const DarkInput = ({ label, ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default function WorkshopsView() {
|
||||
const { workshops, loading, fetchWorkshops, createWorkshop, updateWorkshop, deleteWorkshop } = useWorkshops();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
idoficinas_frota: '',
|
||||
cod_estabelecimento: '',
|
||||
cnpj: '',
|
||||
razao_social: '',
|
||||
nome_reduzido: '',
|
||||
tipo_estabelecimento: '',
|
||||
endereco: '',
|
||||
bairro: '',
|
||||
cidade: '',
|
||||
uf: '',
|
||||
fone_comercial: '',
|
||||
descricao_grupo: ''
|
||||
};
|
||||
const [formData, setFormData] = useState(initialFormState);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkshops();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...initialFormState, ...item });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
let success;
|
||||
if (editingItem) {
|
||||
success = await updateWorkshop(editingItem.idoficinas_frota, formData);
|
||||
} else {
|
||||
success = await createWorkshop(formData);
|
||||
}
|
||||
if (success) setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const filteredData = Array.isArray(workshops) ? workshops.filter(item =>
|
||||
item.nome_reduzido?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.razao_social?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.cidade?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.cnpj?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight uppercase">Gestão de Oficinas</h1>
|
||||
<p className="text-slate-500 text-sm">Rede credenciada e prestadores de serviços.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64 transition-all"
|
||||
placeholder="Buscar por nome, CNPJ ou cidade..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Nova Oficina
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(100vh-220px)] w-full max-w-full overflow-hidden min-w-0">
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
loading={loading}
|
||||
rowKey="idoficinas_frota"
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idoficinas_frota', width: '80px' },
|
||||
{ header: 'Cod. Estab.', field: 'cod_estabelecimento', width: '100px' },
|
||||
{ header: 'Nome Reduzido', field: 'nome_reduzido', width: '220px', className: 'font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Razão Social', field: 'razao_social', width: '250px' },
|
||||
{ header: 'CNPJ', field: 'cnpj', width: '150px' },
|
||||
{ header: 'Tipo', field: 'tipo_estabelecimento', width: '180px' },
|
||||
{ header: 'Cidade', field: 'cidade', width: '150px' },
|
||||
{ header: 'UF', field: 'uf', width: '60px' },
|
||||
{ header: 'Bairro', field: 'bairro', width: '150px' },
|
||||
{ header: 'Endereço', field: 'endereco', width: '300px' },
|
||||
{ header: 'Telefone', field: 'fone_comercial', width: '150px' },
|
||||
{ header: 'Grupo', field: 'descricao_grupo', width: '200px' },
|
||||
]}
|
||||
filterDefs={[
|
||||
{ field: 'cidade', label: 'Cidade', type: 'select' },
|
||||
{ field: 'tipo_estabelecimento', label: 'Tipo', type: 'select' },
|
||||
{ field: 'uf', label: 'UF', type: 'select' },
|
||||
{ field: 'descricao_grupo', label: 'Grupo', type: 'select' },
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
onDelete={(item) => deleteWorkshop(item.idoficinas_frota)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-3xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#222]/30">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold flex items-center gap-2">
|
||||
<div className="w-2 h-6 bg-orange-500 rounded-xl"></div>
|
||||
{editingItem ? 'Editar Oficina' : 'Cadastrar Nova Oficina'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||
Preencha os dados cadastrais da oficina conforme os registros do sistema.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-6 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Seção: Identificação */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[11px] font-bold text-orange-600 dark:text-orange-500 uppercase tracking-[0.2em] border-b border-orange-500/20 pb-1">Identificação</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-1">
|
||||
<DarkInput label="Cod. Estabelecimento" value={formData.cod_estabelecimento} onChange={e => setFormData({...formData, cod_estabelecimento: e.target.value})} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<DarkInput label="CNPJ" value={formData.cnpj} onChange={e => setFormData({...formData, cnpj: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DarkInput label="Razão Social" value={formData.razao_social} onChange={e => setFormData({...formData, razao_social: e.target.value})} required />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DarkInput label="Nome Reduzido" value={formData.nome_reduzido} onChange={e => setFormData({...formData, nome_reduzido: e.target.value})} required />
|
||||
<DarkInput label="Tipo Estabelecimento" value={formData.tipo_estabelecimento} onChange={e => setFormData({...formData, tipo_estabelecimento: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção: Localização e Contato */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[11px] font-bold text-orange-600 dark:text-orange-500 uppercase tracking-[0.2em] border-b border-orange-500/20 pb-1">Localização e Contato</h3>
|
||||
<DarkInput label="Endereço" value={formData.endereco} onChange={e => setFormData({...formData, endereco: e.target.value})} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<DarkInput label="Bairro" value={formData.bairro} onChange={e => setFormData({...formData, bairro: e.target.value})} />
|
||||
<DarkInput label="Cidade" value={formData.cidade} onChange={e => setFormData({...formData, cidade: e.target.value})} />
|
||||
<DarkInput label="UF" value={formData.uf} onChange={e => setFormData({...formData, uf: e.target.value})} maxLength={2} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DarkInput label="Telefone Comercial" value={formData.fone_comercial} onChange={e => setFormData({...formData, fone_comercial: e.target.value})} />
|
||||
<DarkInput label="Descrição do Grupo" value={formData.descricao_grupo} onChange={e => setFormData({...formData, descricao_grupo: e.target.value})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-50 dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2 mt-6">
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit" className="min-w-[150px]">Salvar Cadastro</DarkButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -133,3 +133,6 @@ const AdvancedFiltersModal = ({ isOpen, onClose, onApply, options = {}, initialF
|
|||
};
|
||||
|
||||
export default AdvancedFiltersModal;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Calendar, Filter, Download, ArrowRight, MapPin, Truck, User, Search, Loader2 } from 'lucide-react';
|
||||
import { getTripRequests } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const AnalysisPanel = ({ onClose }) => {
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const fetchHistory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allRequests = await getTripRequests();
|
||||
// Filter for archived or closed requests if needed, otherwise show all and filter by date
|
||||
const list = Array.isArray(allRequests) ? allRequests : allRequests?.data || [];
|
||||
setData(list);
|
||||
} catch (error) {
|
||||
toast.error('Erro ao buscar histórico');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const filteredData = data.filter(item => {
|
||||
const matchesSearch =
|
||||
item.nome_completo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.placa_do_cavalo?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Simplistic date filtering (needs actual date field from API to be robust)
|
||||
// For now we show all filtered by search
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: filteredData.length,
|
||||
carreta: filteredData.filter(i => i.tipo_de_veiculo === 'Carreta').length,
|
||||
truck: filteredData.filter(i => i.tipo_de_veiculo === 'Truck').length
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex justify-end bg-black/60 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="w-full max-w-2xl bg-[#0d0d0e] border border-white/5 rounded-[40px] shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{ fontFamily: 'var(--font-main)' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-white/5 bg-gradient-to-r from-emerald-500/10 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-2xl font-black text-white uppercase tracking-tight">Análise de <span className="text-emerald-500">Rotas</span></h2>
|
||||
<button onClick={onClose} className="w-10 h-10 flex items-center justify-center bg-white/5 hover:bg-white/10 rounded-full transition-all">
|
||||
<X size={20} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-zinc-500 text-xs font-bold uppercase tracking-widest">Controle de Fluxo e Auditoria por Período</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-6 bg-white/5 border-b border-white/5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] font-black text-zinc-500 uppercase tracking-widest ml-1">Início</label>
|
||||
<div className="relative">
|
||||
<Calendar size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-black/40 border border-white/5 rounded-xl text-xs text-white focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[9px] font-black text-zinc-500 uppercase tracking-widest ml-1">Fim</label>
|
||||
<div className="relative">
|
||||
<Calendar size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-black/40 border border-white/5 rounded-xl text-xs text-white focus:outline-none focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Pesquisar por motorista ou placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-black/40 border border-white/5 rounded-2xl text-xs text-white focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar space-y-4">
|
||||
{loading ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-4 py-20">
|
||||
<Loader2 className="animate-spin text-emerald-500" size={32} />
|
||||
<span className="text-[10px] font-black text-zinc-700 uppercase tracking-widest">Compilando dados...</span>
|
||||
</div>
|
||||
) : filteredData.length > 0 ? (
|
||||
filteredData.map((item, idx) => (
|
||||
<motion.div
|
||||
key={item.idsolicitacoes}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="p-5 bg-white/[0.02] border border-white/5 rounded-[24px] hover:bg-white/[0.04] transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-lg text-[8px] font-black">#{item.idsolicitacoes}</span>
|
||||
<span className="text-[8px] font-black text-zinc-600 uppercase tracking-tighter">{item.operacao}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-500">{new Date().toLocaleDateString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-500 group-hover:bg-emerald-500/20 group-hover:text-emerald-400 transition-colors">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-zinc-300 uppercase truncate">{item.nome_completo}</div>
|
||||
<div className="text-[8px] font-mono text-zinc-600">{item.cpf}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-500">
|
||||
<Truck size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-zinc-300 uppercase">{item.placa_do_cavalo}</div>
|
||||
<div className="text-[8px] font-black text-zinc-600 uppercase tracking-widest">{item.tipo_de_veiculo}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-[9px] font-bold text-zinc-500 uppercase">
|
||||
<MapPin size={10} className="text-zinc-600" />
|
||||
<span>{item.origem}</span>
|
||||
<ArrowRight size={10} className="text-zinc-800" />
|
||||
<span>{item.destino}</span>
|
||||
</div>
|
||||
<button className="text-[8px] font-black text-emerald-500 hover:text-emerald-400 transition-colors flex items-center gap-1 uppercase tracking-widest">
|
||||
Visualizar <Download size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-zinc-800 italic uppercase font-black text-[9px] tracking-widest">
|
||||
Nenhum registro no período
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Stats */}
|
||||
<div className="p-8 bg-black/40 border-t border-white/5 grid grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-black text-white">{stats.total}</div>
|
||||
<div className="text-[8px] font-black text-zinc-600 uppercase tracking-widest">Total Viagens</div>
|
||||
</div>
|
||||
<div className="text-center border-l border-white/10">
|
||||
<div className="text-lg font-black text-emerald-500">{stats.carreta}</div>
|
||||
<div className="text-[8px] font-black text-zinc-600 uppercase tracking-widest">Carretas</div>
|
||||
</div>
|
||||
<div className="text-center border-l border-white/10">
|
||||
<div className="text-lg font-black text-blue-500">{stats.truck}</div>
|
||||
<div className="text-[8px] font-black text-zinc-600 uppercase tracking-widest">Trucks</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Send, Clock, Calendar, MessageSquare, ShieldCheck, AlertCircle } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { approveTripRequest } from '../services/prafrotService';
|
||||
import { getCurrentModuleUser } from '@/utils/tokenManager';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const AttendanceFormModal = ({ trip, onClose, onRefresh }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const user = getCurrentModuleUser();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
atendimento: user?.nome || user?.name || '',
|
||||
situacao_liberacao: 'LIBERADO',
|
||||
data_liberacao: new Date().toISOString().split('T')[0],
|
||||
hora_liberacao: new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }),
|
||||
observacao: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await approveTripRequest({
|
||||
idsolicitacoes: trip.idsolicitacoes,
|
||||
status: formData.situacao_liberacao,
|
||||
atendimento: formData.atendimento,
|
||||
data_liberacao: formData.data_liberacao,
|
||||
hora_liberacao: formData.hora_liberacao,
|
||||
obs_liberacao: formData.observacao
|
||||
});
|
||||
toast.success('Atendimento realizado com sucesso!');
|
||||
onRefresh();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao realizar atendimento');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = "w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-emerald-500/50 transition-all placeholder:text-zinc-600";
|
||||
const labelStyle = "text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-2 block";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="relative w-full max-w-lg bg-[#141416] border border-white/10 rounded-[32px] shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white leading-none">Formulário de Atendimento</h2>
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mt-1">Solicitação #{trip.idsolicitacoes}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="w-8 h-8 rounded-full bg-white/5 flex items-center justify-center text-zinc-500 hover:text-white transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Atendimento (Usuário)</label>
|
||||
<div className="relative">
|
||||
<ShieldCheck className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
className={`${inputStyle} pl-12`}
|
||||
value={formData.atendimento}
|
||||
onChange={e => setFormData({...formData, atendimento: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Data da Liberação</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" size={16} />
|
||||
<input
|
||||
type="date"
|
||||
className={`${inputStyle} pl-12`}
|
||||
value={formData.data_liberacao}
|
||||
onChange={e => setFormData({...formData, data_liberacao: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Hora da Liberação</label>
|
||||
<div className="relative">
|
||||
<Clock className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600" size={16} />
|
||||
<input
|
||||
type="time"
|
||||
className={`${inputStyle} pl-12`}
|
||||
value={formData.hora_liberacao}
|
||||
onChange={e => setFormData({...formData, hora_liberacao: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Situação da Liberação</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ id: 'LIBERADO', label: 'Liberado', color: 'bg-emerald-500', icon: ShieldCheck },
|
||||
{ id: 'PARALIZADO', label: 'Paralizado', color: 'bg-amber-500', icon: AlertCircle }
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, situacao_liberacao: opt.id})}
|
||||
className={`flex-1 py-3 px-4 rounded-xl border flex items-center justify-center gap-2 transition-all ${
|
||||
formData.situacao_liberacao === opt.id
|
||||
? `border-${opt.id === 'LIBERADO' ? 'emerald' : 'amber'}-500/50 bg-${opt.id === 'LIBERADO' ? 'emerald' : 'amber'}-500/10 text-${opt.id === 'LIBERADO' ? 'emerald' : 'amber'}-500`
|
||||
: 'border-white/5 bg-white/5 text-zinc-500 hover:border-white/10'
|
||||
}`}
|
||||
>
|
||||
<opt.icon size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Observações</label>
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-4 text-zinc-600" size={16} />
|
||||
<textarea
|
||||
className={`${inputStyle} pl-12 min-h-[100px] resize-none`}
|
||||
placeholder="Descreva observações adicionais..."
|
||||
value={formData.observacao}
|
||||
onChange={e => setFormData({...formData, observacao: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white rounded-2xl font-bold text-xs uppercase tracking-[0.2em] transition-all shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }} className="w-5 h-5 border-2 border-white/20 border-t-white rounded-full" />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} /> Confirmar Atendimento
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -70,7 +70,7 @@ const AutocompleteInput = ({
|
|||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-3 pr-8 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-3 pr-8 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
|
|
@ -96,7 +96,7 @@ const AutocompleteInput = ({
|
|||
<li
|
||||
key={index}
|
||||
className={`px-3 py-2 text-sm cursor-pointer flex items-center justify-between hover:bg-slate-100 dark:hover:bg-[#2a2a2a] ${
|
||||
isSelected ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 font-medium' : 'text-slate-700 dark:text-slate-300'
|
||||
isSelected ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400 font-medium' : 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
|
|
@ -118,3 +118,6 @@ const AutocompleteInput = ({
|
|||
};
|
||||
|
||||
export default AutocompleteInput;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -134,12 +134,12 @@ const ExcelTable = ({
|
|||
<div className="absolute inset-0 z-50 bg-white/80 dark:bg-[#1b1b1b]/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="w-12 h-12 border-4 border-slate-200 dark:border-[#252525] border-t-emerald-500 rounded-full animate-spin"></div>
|
||||
<div className="w-12 h-12 border-4 border-slate-200 dark:border-[#252525] border-t-orange-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
|
||||
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-emerald-500 font-bold uppercase tracking-widest text-[10px] animate-pulse">Carregando Dados...</span>
|
||||
<span className="text-orange-500 font-bold uppercase tracking-widest text-[10px] animate-pulse">Carregando Dados...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -165,10 +165,10 @@ const ExcelTable = ({
|
|||
</button>
|
||||
|
||||
{/* Actions Button (Green Highlight) - Commented as requested
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-[#1f2824] hover:bg-emerald-100 dark:hover:bg-[#25302b] border border-emerald-200 dark:border-[#2b3832] rounded text-emerald-600 dark:text-emerald-400 font-semibold transition-colors">
|
||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-50 dark:bg-[#1f2824] hover:bg-orange-100 dark:hover:bg-[#25302b] border border-orange-200 dark:border-[#2b3832] rounded text-orange-600 dark:text-orange-400 font-semibold transition-colors">
|
||||
<span className="uppercase tracking-wide text-[11px]">Ações</span>
|
||||
<span className="bg-emerald-100 dark:bg-[#15201b] text-emerald-600 px-1 rounded text-[9px]">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 inline-block"></span>
|
||||
<span className="bg-orange-100 dark:bg-[#15201b] text-orange-600 px-1 rounded text-[9px]">
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500 inline-block"></span>
|
||||
</span>
|
||||
</button>
|
||||
*/}
|
||||
|
|
@ -248,7 +248,7 @@ const ExcelTable = ({
|
|||
ref={input => { if (input) input.indeterminate = isIndeterminate; }}
|
||||
onChange={handleSelectAll}
|
||||
disabled={!onSelectionChange}
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-emerald-500 checked:border-emerald-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-orange-500 checked:border-orange-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
|
|
@ -270,24 +270,24 @@ const ExcelTable = ({
|
|||
>
|
||||
<div className="flex items-center justify-between h-full w-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`uppercase font-bold tracking-wider text-[11px] ${sortConfig.key === col.field ? 'text-emerald-600 dark:text-emerald-500' : 'text-slate-500 dark:text-[#e0e0e0]'}`}>
|
||||
<span className={`uppercase font-bold tracking-wider text-[11px] ${sortConfig.key === col.field ? 'text-orange-600 dark:text-orange-500' : 'text-slate-500 dark:text-[#e0e0e0]'}`}>
|
||||
{col.header}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sort/Menu Icons */}
|
||||
<div className={`flex flex-col gap-0.5 ${sortConfig.key === col.field ? 'opacity-100' : 'opacity-40 group-hover:opacity-100'}`}>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'asc' ? 'text-emerald-600 dark:text-emerald-500' : 'text-slate-400 dark:text-stone-400'} rotate-180`}>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'asc' ? 'text-orange-600 dark:text-orange-500' : 'text-slate-400 dark:text-stone-400'} rotate-180`}>
|
||||
<path d="M4 4L0 0H8L4 4Z" />
|
||||
</svg>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'desc' ? 'text-emerald-600 dark:text-emerald-500' : 'text-slate-400 dark:text-stone-400'}`}>
|
||||
<svg width="8" height="4" viewBox="0 0 8 4" fill="currentColor" className={`${sortConfig.key === col.field && sortConfig.direction === 'desc' ? 'text-orange-600 dark:text-orange-500' : 'text-slate-400 dark:text-stone-400'}`}>
|
||||
<path d="M4 4L0 0H8L4 4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Resizer Handle */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-[4px] cursor-col-resize hover:bg-emerald-500/50 z-10 translate-x-1/2" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="absolute right-0 top-0 bottom-0 w-[4px] cursor-col-resize hover:bg-orange-500/50 z-10 translate-x-1/2" onClick={(e) => e.stopPropagation()} />
|
||||
</th>
|
||||
))}
|
||||
{/* Spacer for scrollbar */}
|
||||
|
|
@ -319,7 +319,7 @@ const ExcelTable = ({
|
|||
checked={selectedIds.includes(row[rowKey])}
|
||||
onChange={() => handleSelectRow(row[rowKey])}
|
||||
disabled={!onSelectionChange}
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-emerald-500 checked:border-emerald-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
className="appearance-none w-3.5 h-3.5 border-2 border-slate-300 dark:border-slate-600 rounded-sm bg-white dark:bg-[#2a2a2a] checked:bg-orange-500 checked:border-orange-500 transition-all cursor-pointer relative checked:bg-[url('data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M2.5%207L5.5%2010L11.5%204%22%20stroke%3D%22%23FFF%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E')] bg-center bg-no-repeat bg-[length:70%]"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -330,7 +330,7 @@ const ExcelTable = ({
|
|||
<div className="flex items-center justify-center h-full w-full gap-2">
|
||||
<button
|
||||
onClick={() => onEdit && onEdit(row)}
|
||||
className="text-slate-500 dark:text-stone-500 hover:text-emerald-600 dark:hover:text-stone-300 transition-colors p-1"
|
||||
className="text-slate-500 dark:text-stone-500 hover:text-orange-600 dark:hover:text-stone-300 transition-colors p-1"
|
||||
>
|
||||
<Edit2 size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
|
@ -375,7 +375,7 @@ const ExcelTable = ({
|
|||
{/* Left: Totals */}
|
||||
<div className="flex h-full items-center gap-4">
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[120px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-emerald-500 rounded-r-sm"></div>
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-orange-500 rounded-r-sm"></div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Total:</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-stone-200">{processedData.length}</span>
|
||||
|
||||
|
|
@ -383,7 +383,7 @@ const ExcelTable = ({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 h-full relative group min-w-[100px]">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-emerald-500 rounded-r-sm opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<div className="absolute left-0 top-2 bottom-2 w-[3px] bg-orange-500 rounded-r-sm opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Página:</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-stone-200">{currentPage} / {totalPages || 1}</span>
|
||||
</div>
|
||||
|
|
@ -399,7 +399,7 @@ const ExcelTable = ({
|
|||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-emerald-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsLeft size={14} />
|
||||
</button>
|
||||
|
|
@ -408,14 +408,14 @@ const ExcelTable = ({
|
|||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-emerald-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{/* Manual Page Buttons (Simple logic) */}
|
||||
<div className="flex gap-1 mx-1">
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded bg-emerald-500 text-white dark:text-black font-bold text-[11px] transition-colors shadow-lg shadow-emerald-500/10">
|
||||
<button className="w-7 h-7 flex items-center justify-center rounded bg-orange-500 text-white dark:text-black font-bold text-[11px] transition-colors shadow-lg shadow-orange-500/10">
|
||||
{currentPage}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -424,7 +424,7 @@ const ExcelTable = ({
|
|||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-emerald-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
|
|
@ -433,7 +433,7 @@ const ExcelTable = ({
|
|||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-emerald-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-7 h-7 flex items-center justify-center rounded bg-white dark:bg-[#151515] border border-slate-200 dark:border-[#333] hover:border-orange-500 dark:hover:border-[#555] text-slate-500 dark:text-stone-400 group disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronsRight size={14} />
|
||||
</button>
|
||||
|
|
@ -454,3 +454,6 @@ const ExcelTable = ({
|
|||
};
|
||||
|
||||
export default ExcelTable;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@ export const useFeedbackStore = create((set) => ({
|
|||
}));
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle2 className="w-6 h-6 text-emerald-500" />,
|
||||
success: <CheckCircle2 className="w-6 h-6 text-orange-500" />,
|
||||
error: <XCircle className="w-6 h-6 text-rose-500" />,
|
||||
warning: <AlertCircle className="w-6 h-6 text-amber-500" />,
|
||||
info: <Info className="w-6 h-6 text-blue-500" />
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: "border-emerald-500/20 bg-emerald-50/50 dark:bg-emerald-500/10",
|
||||
success: "border-orange-500/20 bg-orange-50/50 dark:bg-orange-500/10",
|
||||
error: "border-rose-500/20 bg-rose-50/50 dark:bg-rose-500/10",
|
||||
warning: "border-amber-500/20 bg-amber-50/50 dark:bg-amber-500/10",
|
||||
info: "border-blue-500/20 bg-blue-50/50 dark:bg-blue-500/10"
|
||||
|
|
@ -52,7 +52,7 @@ export const FeedbackContainer = () => {
|
|||
className={`pointer-events-auto min-w-[320px] max-w-[400px] p-4 rounded-2xl border backdrop-blur-md shadow-2xl flex gap-4 relative overflow-hidden group ${colors[n.type]}`}
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<div className={`absolute -right-4 -top-4 w-24 h-24 blur-3xl opacity-20 group-hover:opacity-40 transition-opacity ${n.type === 'success' ? 'bg-emerald-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}></div>
|
||||
<div className={`absolute -right-4 -top-4 w-24 h-24 blur-3xl opacity-20 group-hover:opacity-40 transition-opacity ${n.type === 'success' ? 'bg-orange-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}></div>
|
||||
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{icons[n.type]}
|
||||
|
|
@ -79,7 +79,7 @@ export const FeedbackContainer = () => {
|
|||
initial={{ scaleX: 1 }}
|
||||
animate={{ scaleX: 0 }}
|
||||
transition={{ duration: Math.min((n.duration || 5000) / 1000, 30), ease: "linear" }}
|
||||
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${n.type === 'success' ? 'bg-emerald-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}
|
||||
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${n.type === 'success' ? 'bg-orange-500' : n.type === 'error' ? 'bg-rose-500' : 'bg-blue-500'}`}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
|
@ -110,3 +110,6 @@ export const useFeedback = () => {
|
|||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ const FinesCardDebug = (initialProps) => {
|
|||
<div className="flex-1 bg-slate-50 dark:bg-slate-900/50 p-6 rounded-xl border border-slate-200 dark:border-white/10 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider">Debug Controls</h4>
|
||||
<Button variant="ghost" size="icon" onClick={() => setState(prev => ({ ...prev, isLoading: !prev.isLoading }))} className={state.isLoading ? "text-emerald-500" : "text-slate-500 hover:text-slate-900 dark:hover:text-slate-300"}>
|
||||
<Button variant="ghost" size="icon" onClick={() => setState(prev => ({ ...prev, isLoading: !prev.isLoading }))} className={state.isLoading ? "text-orange-500" : "text-slate-500 hover:text-slate-900 dark:hover:text-slate-300"}>
|
||||
<RefreshCcw className={state.isLoading ? "animate-spin" : ""} size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -137,7 +137,7 @@ const FinesCardDebug = (initialProps) => {
|
|||
type="checkbox"
|
||||
checked={state.hasData}
|
||||
onChange={toggleData}
|
||||
className="w-5 h-5 rounded border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 text-emerald-500 focus:ring-emerald-500 focus:ring-offset-white dark:focus:ring-offset-slate-900"
|
||||
className="w-5 h-5 rounded border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 text-orange-500 focus:ring-orange-500 focus:ring-offset-white dark:focus:ring-offset-slate-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +154,7 @@ const FinesCardDebug = (initialProps) => {
|
|||
step="100"
|
||||
value={state.previousValue}
|
||||
onChange={updatePrevValue}
|
||||
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500"
|
||||
className="w-full h-2 bg-slate-200 dark:bg-slate-700 rounded-lg appearance-none cursor-pointer accent-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -169,3 +169,6 @@ const FinesCardDebug = (initialProps) => {
|
|||
};
|
||||
|
||||
export default FinesCardDebug;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export const FinesCard = ({
|
|||
const isReduction = percentage < 0;
|
||||
const isIncrease = percentage > 0;
|
||||
|
||||
const trendColor = isReduction ? 'text-emerald-500' : isIncrease ? 'text-rose-500' : 'text-slate-500';
|
||||
const badgeColor = isReduction ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' : isIncrease ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' : 'bg-slate-500/10 text-slate-500 border-slate-500/20';
|
||||
const trendColor = isReduction ? 'text-orange-500' : isIncrease ? 'text-rose-500' : 'text-slate-500';
|
||||
const badgeColor = isReduction ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' : isIncrease ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' : 'bg-slate-500/10 text-slate-500 border-slate-500/20';
|
||||
const TrendIcon = isReduction ? TrendingDown : isIncrease ? TrendingUp : Minus;
|
||||
|
||||
const isEmpty = !data || data.length === 0 || currentValue === 0;
|
||||
|
|
@ -84,7 +84,7 @@ export const FinesCard = ({
|
|||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={cn("text-2xl font-bold tracking-tight leading-none", currentValue > 0 ? "text-slate-900 dark:text-white" : "text-emerald-600 dark:text-emerald-500")}>
|
||||
<div className={cn("text-2xl font-bold tracking-tight leading-none", currentValue > 0 ? "text-slate-900 dark:text-white" : "text-orange-600 dark:text-orange-500")}>
|
||||
{formatCurrency(currentValue)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium mt-1">{currentCount} registros</div>
|
||||
|
|
@ -124,7 +124,7 @@ export const FinesCard = ({
|
|||
className={cn(
|
||||
"flex-1 py-1.5 rounded-md text-[10px] uppercase font-bold tracking-wider transition-all",
|
||||
period === p
|
||||
? "bg-white dark:bg-emerald-500 text-emerald-600 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-0"
|
||||
? "bg-white dark:bg-orange-500 text-orange-600 dark:text-white shadow-sm ring-1 ring-black/5 dark:ring-0"
|
||||
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
|
|
@ -262,3 +262,6 @@ export const FinesCard = ({
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
export { FinesCard } from './FinesCard';
|
||||
export { FinesCard as default } from './FinesCard';
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard, Package, DollarSign, Truck,
|
||||
MapPin, Calendar, FileText, Hash, CreditCard,
|
||||
Scale, Box, AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const renderValue = (val, type = 'text') => {
|
||||
if (val === null || val === undefined || val === '') return '---';
|
||||
if (type === 'money') {
|
||||
return `R$ ${parseFloat(val).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
if (type === 'date') {
|
||||
return new Date(val).toLocaleDateString('pt-BR');
|
||||
}
|
||||
if (type === 'datetime') {
|
||||
return new Date(val).toLocaleString('pt-BR');
|
||||
}
|
||||
if (type === 'number') {
|
||||
return parseFloat(val).toLocaleString('pt-BR', { maximumFractionDigits: 4 });
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
const SectionHeader = ({ icon: Icon, title }) => (
|
||||
<div className="flex items-center gap-2 mb-4 pb-2 border-b border-white/5">
|
||||
<div className="p-1.5 bg-emerald-500/10 rounded-lg text-emerald-500">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-emerald-500">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Field = ({ label, value, type = 'text', fullWidth = false }) => (
|
||||
<div className={`flex flex-col gap-1 p-3 bg-white/5 rounded-xl border border-white/5 ${fullWidth ? 'col-span-full' : ''}`}>
|
||||
<span className="text-[9px] font-bold text-zinc-500 uppercase tracking-widest truncate">{label}</span>
|
||||
<span className={`text-xs font-medium text-zinc-200 break-all ${type === 'money' ? 'font-mono text-emerald-400' : ''}`}>
|
||||
{renderValue(value, type)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const NfeDataDisplay = ({ data }) => {
|
||||
const [activeTab, setActiveTab] = useState('geral');
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// Normaliza o objeto NFe se estiver aninhado ou na raiz
|
||||
const nfe = data.nfe || data;
|
||||
const itens = data.itens || [];
|
||||
const financeiro = data.financeiro || []; // Faturas?
|
||||
const pagamentos = data.pagamentos || [];
|
||||
const transportadora = data.transportadora || {};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'geral', label: 'Visão Geral', icon: LayoutDashboard },
|
||||
{ id: 'itens', label: 'Itens & Produtos', icon: Package, count: itens.length },
|
||||
{ id: 'financeiro', label: 'Financeiro', icon: DollarSign },
|
||||
{ id: 'transporte', label: 'Transporte', icon: Truck },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-[#141416] text-white pt-6 relative z-10">
|
||||
{/* Tabs */}
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-2 p-4 bg-black/20 border-b border-white/5 overflow-x-auto md:overflow-hidden [&::-webkit-scrollbar]:hidden" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all whitespace-nowrap border ${
|
||||
activeTab === tab.id
|
||||
? 'bg-zinc-800 text-emerald-500 border-white/10 shadow-lg'
|
||||
: 'border-transparent text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={14} />
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-black/40 rounded text-[9px] text-zinc-400">{tab.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{activeTab === 'geral' && (
|
||||
<div className="space-y-6">
|
||||
{/* Identificação */}
|
||||
<div>
|
||||
<SectionHeader icon={FileText} title="Identificação da Nota" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Número" value={nfe.numero} fullWidth />
|
||||
<Field label="Série" value={nfe.serie} />
|
||||
<Field label="Modelo" value={nfe.modelo} />
|
||||
<Field label="Natureza da Operação" value={nfe.natureza_operacao} fullWidth />
|
||||
<Field label="Chave de Acesso" value={nfe.chave} fullWidth />
|
||||
<Field label="Ambiente" value={nfe.ambiente} />
|
||||
<Field label="Finalidade" value={nfe.finalidade_nf} />
|
||||
<Field label="Tipo NF" value={nfe.tipo_nf} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datas */}
|
||||
<div>
|
||||
<SectionHeader icon={Calendar} title="Datas e Horários" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Emissão" value={nfe.data_emissao} type="datetime" />
|
||||
<Field label="Saída/Entrada" value={nfe.data_saida} type="datetime" />
|
||||
<Field label="Importado em" value={nfe.criado_em} type="datetime" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participantes */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<SectionHeader icon={MapPin} title="Emitente" />
|
||||
<div className="space-y-3">
|
||||
<Field label="Nome / Razão Social" value={nfe.emitente_nome} />
|
||||
<Field label="CNPJ / CPF" value={nfe.emitente_cnpj} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SectionHeader icon={MapPin} title="Destinatário" />
|
||||
<div className="space-y-3">
|
||||
<Field label="Nome / Razão Social" value={nfe.destinatario_nome} />
|
||||
<Field label="CNPJ / CPF" value={nfe.destinatario_doc} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totais */}
|
||||
<div>
|
||||
<SectionHeader icon={DollarSign} title="Valores Totais" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Valor Produtos" value={nfe.valor_produtos} type="money" />
|
||||
<Field label="Valor Total NF" value={nfe.valor_total} type="money" />
|
||||
<Field label="Valor Frete" value={nfe.valor_frete} type="money" />
|
||||
<Field label="Valor Seguro" value={nfe.valor_seguro} type="money" />
|
||||
<Field label="Valor Desconto" value={nfe.valor_desconto} type="money" />
|
||||
<Field label="Outras Despesas" value={nfe.valor_outras_despesas || null} type="money" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impostos Totais */}
|
||||
<div>
|
||||
<SectionHeader icon={Scale} title="Totais de Impostos" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Base ICMS" value={nfe.base_icms} type="money" />
|
||||
<Field label="Valor ICMS" value={nfe.valor_icms} type="money" />
|
||||
<Field label="Base ICMS ST" value={nfe.base_icms_st} type="money" />
|
||||
<Field label="Valor ICMS ST" value={nfe.valor_icms_st} type="money" />
|
||||
<Field label="Valor IPI" value={nfe.valor_ipi} type="money" />
|
||||
<Field label="Valor PIS" value={nfe.valor_pis} type="money" />
|
||||
<Field label="Valor COFINS" value={nfe.valor_cofins} type="money" />
|
||||
<Field label="Valor Aprox. Trib." value={null} type="money" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'itens' && (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-2xl border border-white/5">
|
||||
<table className="w-full text-left border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-black/20 border-b border-white/5">
|
||||
{['#', 'Cód.', 'Descrição', 'NCM', 'CST', 'CFOP', 'Unid.', 'Qtde.', 'V. Unit.', 'V. Total', 'BC ICMS', 'V. ICMS', 'V. IPI', 'V. PIS', 'V. COFINS'].map(h => (
|
||||
<th key={h} className="px-4 py-3 text-[9px] font-bold text-zinc-500 uppercase tracking-widest">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 bg-white/5">
|
||||
{itens.map((item, idx) => (
|
||||
<tr key={idx} className="hover:bg-white/5 transition-colors">
|
||||
<td className="px-4 py-3 text-[10px] text-zinc-500">{idx + 1}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-400">{item.codigo}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-medium text-white max-w-[200px] truncate" title={item.descricao}>{item.descricao}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-400">{item.ncm || '---'}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-400">{item.cst || '---'}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-400">{item.cfop || '---'}</td>
|
||||
<td className="px-4 py-3 text-[10px] text-zinc-400">{item.unidade || '---'}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-bold text-white">{renderValue(item.quantidade, 'number')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-300">{renderValue(item.valor_unitario, 'money')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-emerald-400 font-bold">{renderValue(item.valor_total, 'money')}</td>
|
||||
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-500">{renderValue(item.base_icms, 'money')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-500">{renderValue(item.valor_icms, 'money')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-500">{renderValue(item.valor_ipi, 'money')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-500">{renderValue(item.valor_pis, 'money')}</td>
|
||||
<td className="px-4 py-3 text-[10px] font-mono text-zinc-500">{renderValue(item.valor_cofins, 'money')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{itens.length === 0 && (
|
||||
<div className="p-10 text-center text-zinc-500 text-xs uppercase tracking-widest italic border border-dashed border-white/5 rounded-2xl">
|
||||
Nenhum item registrado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'financeiro' && (
|
||||
<div className="space-y-6">
|
||||
{/* Pagamentos */}
|
||||
<div>
|
||||
<SectionHeader icon={CreditCard} title="Pagamentos" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{pagamentos.map((pag, idx) => (
|
||||
<div key={idx} className="p-4 bg-white/5 border border-white/5 rounded-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Pagamento #{idx + 1}</span>
|
||||
<span className="text-[10px] font-mono text-zinc-600">{pag.forma || '---'}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-emerald-400 font-mono">
|
||||
{renderValue(pag.valor, 'money')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pagamentos.length === 0 && (
|
||||
<div className="col-span-full p-6 text-center text-zinc-500 text-xs italic">
|
||||
Nenhum pagamento registrado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Faturas / Duplicatas (se houver no JSON futuro) */}
|
||||
<div>
|
||||
<SectionHeader icon={FileText} title="Faturas / Duplicatas" />
|
||||
<div className="p-6 text-center text-zinc-500 text-xs italic border border-dashed border-white/5 rounded-2xl">
|
||||
Dados de fatura não disponíveis na visualização simplificada.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transporte' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<SectionHeader icon={Truck} title="Transportadora" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Field label="Nome / Razão Social" value={transportadora.transportadora_nome} />
|
||||
<Field label="CNPJ" value={transportadora.transportadora_cnpj} />
|
||||
<Field label="Placa Veículo" value={transportadora.placa} />
|
||||
<Field label="UF Veículo" value={transportadora.uf} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionHeader icon={Box} title="Volumes e Pesos" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Field label="Quantidade" value={transportadora.quantidade_volumes} />
|
||||
<Field label="Espécie" value={null} />
|
||||
<Field label="Marca" value={null} />
|
||||
<Field label="Numeração" value={null} />
|
||||
<Field label="Peso Bruto" value={transportadora.peso_bruto} type="number" />
|
||||
<Field label="Peso Líquido" value={transportadora.peso_liquido} type="number" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, Download, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getNfe } from '../services/prafrotService';
|
||||
|
||||
export const NfeViewerModal = ({ chave, onClose }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Consideramos que o backend retorna o HTML da DANFE
|
||||
// Se mudar para PDF, ajustaremos o iframe
|
||||
const nfeUrl = `${window.location.protocol}//${window.location.host}/api/nfe/${chave}`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/90 backdrop-blur-md"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
width: isFullscreen ? '100%' : '90%',
|
||||
height: isFullscreen ? '100%' : '90%'
|
||||
}}
|
||||
className={`relative bg-white rounded-[32px] overflow-hidden flex flex-col transition-all duration-300 ${isFullscreen ? 'rounded-none' : ''}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-[#141416] p-4 flex items-center justify-between border-b border-white/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-emerald-500/10 p-2 rounded-xl">
|
||||
<span className="text-emerald-500 text-xs font-bold uppercase tracking-widest">DANFE Online</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-zinc-500 hidden md:block">{chave}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="w-10 h-10 bg-white/5 hover:bg-white/10 rounded-full flex items-center justify-center text-zinc-400 hover:text-white transition-all"
|
||||
title={isFullscreen ? "Sair da Tela Cheia" : "Tela Cheia"}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(nfeUrl, '_blank')}
|
||||
className="w-10 h-10 bg-white/5 hover:bg-white/10 rounded-full flex items-center justify-center text-zinc-400 hover:text-white transition-all"
|
||||
title="Baixar / Imprimir"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 bg-rose-500/10 hover:bg-rose-500/20 rounded-full flex items-center justify-center text-rose-500 transition-all ml-2"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Viewer */}
|
||||
<div className="flex-1 bg-zinc-100 relative overflow-hidden">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 z-10">
|
||||
<Loader2 className="animate-spin text-emerald-500 mb-4" size={40} />
|
||||
<span className="text-sm font-medium text-zinc-600">Carregando DANFE...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
src={nfeUrl}
|
||||
className="w-full h-full border-0"
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setError("Erro ao carregar a nota fiscal.");
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-100 z-20 p-8 text-center">
|
||||
<div className="w-16 h-16 bg-rose-500/10 text-rose-500 rounded-full flex items-center justify-center mb-4">
|
||||
<X size={32} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-zinc-900 mb-2">Ops! Algo deu errado</h3>
|
||||
<p className="text-zinc-500 max-w-sm">{error}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-6 px-8 py-3 bg-[#141416] text-white rounded-2xl font-bold uppercase text-[10px] tracking-widest"
|
||||
>
|
||||
Fechar Visualizador
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Plus, Trash2, Loader2, Settings, ShieldCheck } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getOperations, createOperation, deleteOperation } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const OperationsManagerModal = ({ onClose }) => {
|
||||
const [operations, setOperations] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newOp, setNewOp] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getOperations();
|
||||
setOperations(Array.isArray(data) ? data : data?.data || []);
|
||||
} catch (error) {
|
||||
toast.error('Erro ao carregar operações');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newOp.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createOperation(newOp);
|
||||
toast.success('Operação criada');
|
||||
setNewOp('');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao criar');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Excluir operação?')) return;
|
||||
try {
|
||||
await deleteOperation(id);
|
||||
toast.success('Operação excluída');
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="relative w-full max-w-md bg-[#161618] border border-white/10 rounded-[32px] shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={18} className="text-emerald-400" />
|
||||
<h2 className="text-lg font-bold text-white">Gestão de Operações</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Form */}
|
||||
<form onSubmit={handleCreate} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nova Operação (ex: AMBEV)"
|
||||
className="flex-1 bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-xs font-bold focus:outline-none focus:border-emerald-500/30 transition-all"
|
||||
value={newOp}
|
||||
onChange={e => setNewOp(e.target.value.toUpperCase())}
|
||||
/>
|
||||
<button
|
||||
disabled={submitting}
|
||||
className="px-4 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-white font-bold text-[10px] uppercase tracking-widest transition-all disabled:opacity-50"
|
||||
>
|
||||
{submitting ? <Loader2 className="animate-spin" size={16} /> : <Plus size={16} />}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-[300px] overflow-y-auto custom-scrollbar space-y-2 pr-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10"><Loader2 className="animate-spin text-zinc-700" size={32} /></div>
|
||||
) : operations.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-600 font-bold uppercase text-[10px] tracking-widest italic">Nenhuma operação cadastrada</div>
|
||||
) : (
|
||||
operations.map(op => (
|
||||
<div key={op.idoperacoes_solicitacao} className="flex items-center justify-between p-4 bg-white/5 border border-white/5 rounded-2xl group hover:border-white/10 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-emerald-400"><ShieldCheck size={16} /></div>
|
||||
<span className="text-xs font-bold text-zinc-300">{op.operacao}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(op.idoperacoes_solicitacao)}
|
||||
className="p-2 text-zinc-600 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
border: 1px solid var(--pfs-border);
|
||||
}
|
||||
|
||||
/* Subtle Top Glow - Emerald */
|
||||
/* Subtle Top Glow - orange */
|
||||
.pfs-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #10b981, transparent);
|
||||
background: linear-gradient(90deg, transparent, #f97316, transparent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
|
|
@ -97,8 +97,8 @@
|
|||
|
||||
.pfs-toggle-btn:hover {
|
||||
background: var(--pfs-bg-search);
|
||||
color: #10b981;
|
||||
border-color: #10b981;
|
||||
color: #f97316;
|
||||
border-color: #f97316;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
|
@ -128,9 +128,9 @@
|
|||
}
|
||||
|
||||
.pfs-search-input:focus {
|
||||
border-color: #10b981;
|
||||
border-color: #f97316;
|
||||
background: var(--pfs-bg);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
/* Navigation Content */
|
||||
|
|
@ -175,10 +175,10 @@
|
|||
}
|
||||
|
||||
.pfs-link.active {
|
||||
background: #10b981 !important;
|
||||
background: #f97316 !important;
|
||||
color: var(--pfs-text-active) !important;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.25);
|
||||
box-shadow: 0 4px 15px rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
.pfs-icon {
|
||||
|
|
@ -250,7 +250,7 @@
|
|||
}
|
||||
|
||||
.pfs-sublink.active {
|
||||
color: #10b981;
|
||||
color: #f97316;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
|
@ -261,7 +261,7 @@
|
|||
|
||||
.pfs-sublink.active .pfs-icon {
|
||||
opacity: 1;
|
||||
color: #10b981;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
/* Footer Section */
|
||||
|
|
@ -289,14 +289,14 @@
|
|||
.pfs-brand-logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #10b981;
|
||||
background: #f97316;
|
||||
color: #141414;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.2);
|
||||
box-shadow: 0 0 15px rgba(249, 115, 22, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +314,7 @@
|
|||
}
|
||||
|
||||
.pfs-brand-name span {
|
||||
color: #10b981;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.pfs-app-sub {
|
||||
|
|
@ -385,7 +385,7 @@
|
|||
}
|
||||
|
||||
.pfs-legal-item svg, .pfs-version svg {
|
||||
color: #10b981;
|
||||
color: #f97316;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
|
@ -397,5 +397,8 @@
|
|||
|
||||
.pfs-lock-icon {
|
||||
margin-left: auto;
|
||||
color: #10b981 !important;
|
||||
color: #f97316 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ChevronDown,
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Search,
|
||||
ChevronDown,
|
||||
LayoutDashboard,
|
||||
Car,
|
||||
Users,
|
||||
|
|
@ -21,12 +21,12 @@ import {
|
|||
Award,
|
||||
GitBranch,
|
||||
Lock,
|
||||
Settings,
|
||||
Mail
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import logoOestePan from '@/assets/Img/Util/Oeste_Pan/Logo Oeste Pan.png';
|
||||
import './PrafrotSidebar.css';
|
||||
|
||||
const MENU_ITEMS = [
|
||||
|
|
@ -34,17 +34,17 @@ const MENU_ITEMS = [
|
|||
id: 'dashboard',
|
||||
label: 'Estatísticas',
|
||||
icon: LayoutDashboard,
|
||||
path: '/plataforma/prafrot/estatisticas'
|
||||
path: '/plataforma/oest-pan/estatisticas'
|
||||
},
|
||||
{
|
||||
id: 'cadastros',
|
||||
label: 'Cadastros',
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{ id: 'c-veiculos', label: 'Veículos', path: '/plataforma/prafrot/veiculos', icon: Car },
|
||||
{ id: 'c-dispatcher', label: 'Dispatcher', path: '/plataforma/prafrot/dispatcher', icon: ClipboardList },
|
||||
// { id: 'c-motoristas', label: 'Motoristas', path: '/plataforma/prafrot/motoristas', icon: Users, disabled: true, disabledReason: 'Funcionalidade em manutenção' },
|
||||
{ id: 'c-oficinas', label: 'Oficinas', path: '/plataforma/prafrot/oficinas', icon: Store }
|
||||
{ id: 'c-veiculos', label: 'Veículos', path: '/plataforma/oest-pan/veiculos', icon: Car },
|
||||
{ id: 'c-dispatcher', label: 'Dispatcher', path: '/plataforma/oest-pan/dispatcher', icon: ClipboardList },
|
||||
// { id: 'c-motoristas', label: 'Motoristas', path: '/plataforma/oest-pan/motoristas', icon: Users, disabled: true, disabledReason: 'Funcionalidade em manutenção' },
|
||||
{ id: 'c-oficinas', label: 'Oficinas', path: '/plataforma/oest-pan/oficinas', icon: Store }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -52,20 +52,17 @@ const MENU_ITEMS = [
|
|||
label: 'Gerência',
|
||||
icon: Activity,
|
||||
children: [
|
||||
{ id: 'g-solicitacoes', label: 'Solicitações', path: '/plataforma/prafrot/solicitacoes', icon: ShieldAlert },
|
||||
{ id: 'g-monitoramento', label: 'Monitoramento', path: '/plataforma/prafrot/monitoramento', icon: Radio },
|
||||
{ id: 'g-status', label: 'Status Frota', path: '/plataforma/prafrot/status', icon: Activity },
|
||||
{ id: 'g-manutencao', label: 'Manutenção', path: '/plataforma/prafrot/manutencao', icon: Wrench },
|
||||
{ id: 'g-pendencias-financeiro', label: 'Pendências', path: '/plataforma/prafrot/pendencias-financeiro', icon: ShieldAlert },
|
||||
{ id: 'g-sinistros', label: 'Sinistros', path: '/plataforma/prafrot/sinistros', icon: AlertTriangle },
|
||||
{ id: 'g-mensagens', label: 'Processador de XML', path: '/plataforma/prafrot/mensagens', icon: Mail }
|
||||
{ id: 'g-monitoramento', label: 'Monitoramento', path: '/plataforma/oest-pan/monitoramento', icon: Radio },
|
||||
{ id: 'g-status', label: 'Status Frota', path: '/plataforma/oest-pan/status', icon: Activity },
|
||||
{ id: 'g-manutencao', label: 'Manutenção', path: '/plataforma/oest-pan/manutencao', icon: Wrench },
|
||||
{ id: 'g-sinistros', label: 'Sinistros', path: '/plataforma/oest-pan/sinistros', icon: AlertTriangle }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
label: 'Configurações',
|
||||
icon: Settings,
|
||||
path: '/plataforma/prafrot/configuracoes'
|
||||
path: '/plataforma/oest-pan/configuracoes'
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -78,12 +75,12 @@ const MenuItem = React.memo(({ item, isSub = false, isCollapsed, expandedItems,
|
|||
const isLocked = item.disabled;
|
||||
|
||||
// Filter sub-items if searching
|
||||
const subItems = item.children?.filter(child =>
|
||||
const subItems = item.children?.filter(child =>
|
||||
!searchTerm || child.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
<div
|
||||
className={cn(
|
||||
isSub ? "pfs-sublink" : "pfs-link",
|
||||
isActive && !hasChildren && "active",
|
||||
|
|
@ -97,11 +94,11 @@ const MenuItem = React.memo(({ item, isSub = false, isCollapsed, expandedItems,
|
|||
>
|
||||
<Icon size={isSub ? 16 : 20} className="pfs-icon" />
|
||||
{(!isCollapsed || isSub) && <span className="pfs-label">{item.label}</span>}
|
||||
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("pfs-chevron", isExpanded && "expanded")}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("pfs-chevron", isExpanded && "expanded")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -121,17 +118,17 @@ const MenuItem = React.memo(({ item, isSub = false, isCollapsed, expandedItems,
|
|||
|
||||
<AnimatePresence>
|
||||
{hasChildren && isExpanded && !isCollapsed && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="pfs-submenu"
|
||||
>
|
||||
{subItems?.map(child => (
|
||||
<MenuItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
isSub
|
||||
<MenuItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
isSub
|
||||
isCollapsed={isCollapsed}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpand={toggleExpand}
|
||||
|
|
@ -148,7 +145,7 @@ const MenuItem = React.memo(({ item, isSub = false, isCollapsed, expandedItems,
|
|||
|
||||
MenuItem.displayName = 'MenuItem';
|
||||
|
||||
export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
||||
export const OestPanSidebar = ({ isCollapsed, onToggle }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedItems, setExpandedItems] = useState({ cadastros: true, gerencia: true });
|
||||
const location = useLocation();
|
||||
|
|
@ -162,69 +159,20 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
}, []);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
// 1. Identificação de Setores (Alinhada com integra_user_prafrot via context)
|
||||
const userSetores = user?.setores || [];
|
||||
if (!searchTerm) return MENU_ITEMS;
|
||||
|
||||
// Tenta obter especificamente do usuário prafrot caso o contexto global divirja
|
||||
let allSetores = [...userSetores];
|
||||
try {
|
||||
const prafrotUserJson = localStorage.getItem('integra_user_prafrot');
|
||||
if (prafrotUserJson) {
|
||||
const prafrotUser = JSON.parse(prafrotUserJson);
|
||||
if (prafrotUser.setores) {
|
||||
allSetores = [...new Set([...allSetores, ...prafrotUser.setores])];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('PrafrotSidebar: Erro ao ler integra_user_prafrot');
|
||||
}
|
||||
|
||||
const isFinanceiro = allSetores.includes('Financeiro');
|
||||
const isMonitoramento = allSetores.includes('Monitoramento');
|
||||
|
||||
let baseItems = MENU_ITEMS;
|
||||
|
||||
// Lógica de Filtro
|
||||
if (isFinanceiro) {
|
||||
// Usuário Financeiro (mesmo que tenha Monitoramento): Vê apenas Pendências e Configurações
|
||||
baseItems = MENU_ITEMS.filter(item => {
|
||||
if (item.id === 'config') return true;
|
||||
if (item.id === 'gerencia') {
|
||||
return item.children?.some(c => c.id === 'g-pendencias-financeiro');
|
||||
}
|
||||
return false;
|
||||
}).map(item => {
|
||||
if (item.id === 'gerencia') {
|
||||
return { ...item, children: item.children.filter(c => c.id === 'g-pendencias-financeiro') };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
} else if (isMonitoramento) {
|
||||
// Usuário apenas Monitoramento: Oculta Pendências Financeiras
|
||||
baseItems = MENU_ITEMS.map(item => {
|
||||
if (item.id === 'gerencia') {
|
||||
return { ...item, children: item.children.filter(c => c.id !== 'g-pendencias-financeiro') };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
// Caso não tenha nenhum dos dois especificamente, mostra tudo (fallback admin)
|
||||
|
||||
// 2. Filtro de Busca
|
||||
if (!searchTerm) return baseItems;
|
||||
|
||||
return baseItems.filter(item => {
|
||||
return MENU_ITEMS.filter(item => {
|
||||
const matchParent = item.label.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchChildren = item.children?.some(child =>
|
||||
const matchChildren = item.children?.some(child =>
|
||||
child.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
return matchParent || matchChildren;
|
||||
});
|
||||
}, [searchTerm, user]);
|
||||
}, [searchTerm]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout('prafrot');
|
||||
window.location.href = '/plataforma/prafrot/login';
|
||||
logout('auth_oestepan');
|
||||
window.location.href = '/plataforma/oest-pan/login';
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -238,9 +186,9 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
<div className="pfs-search-wrapper">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--pfs-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
className="pfs-search-input"
|
||||
<input
|
||||
type="text"
|
||||
className="pfs-search-input"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
|
|
@ -250,9 +198,9 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
|
||||
<nav className="pfs-nav-content custom-scrollbar">
|
||||
{filteredItems.map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isCollapsed={isCollapsed}
|
||||
expandedItems={expandedItems}
|
||||
toggleExpand={toggleExpand}
|
||||
|
|
@ -264,12 +212,10 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
|
||||
<footer className="pfs-footer">
|
||||
<div className="pfs-brand">
|
||||
<div className="pfs-brand-logo">
|
||||
PF
|
||||
</div>
|
||||
<img src={logoOestePan} alt="OP" className="w-12 h-12 object-contain" />
|
||||
{!isCollapsed && (
|
||||
<div className="pfs-brand-info">
|
||||
<span className="pfs-brand-name">PRA<span>FROTA</span></span>
|
||||
<span className="pfs-brand-name">Oeste <span>Pan</span></span>
|
||||
<span className="pfs-app-sub">Fleet Management</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -278,8 +224,8 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
<div className="pfs-user-section">
|
||||
<div className={cn("pfs-user-card", isCollapsed && "justify-center")}>
|
||||
<Avatar className="h-8 w-8 border border-[var(--pfs-border)]">
|
||||
<AvatarImage src={`https://ui-avatars.com/api/?name=${user?.name || 'User'}&background=10b981&color=141414`} />
|
||||
<AvatarFallback className="bg-emerald-500 text-zinc-950 font-bold text-xs">
|
||||
<AvatarImage src={`https://ui-avatars.com/api/?name=${user?.name || 'User'}&background=f97316&color=141414`} />
|
||||
<AvatarFallback className="bg-orange-500 text-zinc-950 font-bold text-xs">
|
||||
{(user?.name || 'U').charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
|
@ -298,3 +244,6 @@ export const PrafrotSidebar = ({ isCollapsed, onToggle }) => {
|
|||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,436 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { X, User, Truck, MapPin, Package, Hash, Clock, ShieldCheck, Trash2, Send, CheckCircle2, FileText, Loader2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { updateTripStatus, deleteTripRequest, getNfe, closeTripRequest } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { TripRequestForm } from './TripRequestForm';
|
||||
import { AttendanceFormModal } from './AttendanceFormModal';
|
||||
import { NfeDataDisplay } from './NfeDataDisplay';
|
||||
|
||||
const formatCPF = (cpf) => {
|
||||
if (!cpf) return '---';
|
||||
const cleanCPF = String(cpf).replace(/\D/g, '');
|
||||
if (cleanCPF.length !== 11) return cpf;
|
||||
return cleanCPF.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||
};
|
||||
|
||||
const renderValue = (val) => {
|
||||
if (!val) return '---';
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return val.numero || val.chave || val.id || JSON.stringify(val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
export const TripDetailsModal = ({ trip, onClose, onRefresh }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [activeSubTab, setActiveSubTab] = useState('geral');
|
||||
const [nfeData, setNfeData] = useState(null);
|
||||
const [loadingNfe, setLoadingNfe] = useState(false);
|
||||
const [showAttendanceForm, setShowAttendanceForm] = useState(false);
|
||||
|
||||
const fetchNfeData = async () => {
|
||||
if (nfeData) return;
|
||||
|
||||
// 1. Tenta usar dados já presentes no objeto trip
|
||||
if (trip.nfe && typeof trip.nfe === 'object' && !Array.isArray(trip.nfe)) {
|
||||
setNfeData(trip.nfe);
|
||||
return;
|
||||
}
|
||||
if (trip.nota_fiscal && typeof trip.nota_fiscal === 'object' && !Array.isArray(trip.nota_fiscal)) {
|
||||
setNfeData(trip.nota_fiscal);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Busca pela chave/CTE se for string
|
||||
const keyToFetch = trip.cte || (typeof trip.nfe === 'string' ? trip.nfe : null) || (typeof trip.nota_fiscal === 'string' ? trip.nota_fiscal : null);
|
||||
|
||||
if (!keyToFetch) return;
|
||||
|
||||
setLoadingNfe(true);
|
||||
try {
|
||||
const data = await getNfe(keyToFetch);
|
||||
setNfeData(data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar NFe:', error);
|
||||
toast.error('Não foi possível carregar os dados detalhados.');
|
||||
} finally {
|
||||
setLoadingNfe(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'nfe') {
|
||||
fetchNfeData();
|
||||
}
|
||||
}, [activeSubTab]);
|
||||
|
||||
if (!trip) return null;
|
||||
|
||||
const handleStatusUpdate = async (newStatus) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (newStatus === 'FECHADO' || newStatus === 'FINALIZADO') {
|
||||
await closeTripRequest(trip.idsolicitacoes);
|
||||
toast.success('Solicitação finalizada');
|
||||
} else {
|
||||
await updateTripStatus(trip.idsolicitacoes, newStatus);
|
||||
toast.success(`Status atualizado para ${newStatus}`);
|
||||
}
|
||||
onRefresh();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Deseja realmente excluir esta solicitação?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteTripRequest(trip.idsolicitacoes);
|
||||
toast.success('Solicitação excluída');
|
||||
onRefresh();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir solicitação');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionStyle = "space-y-4 pb-6 border-b border-white/5 last:border-0 pt-6 first:pt-0";
|
||||
const itemLabelStyle = "text-[11px] font-medium text-zinc-500 uppercase tracking-widest block mb-1";
|
||||
const itemValueStyle = "text-sm font-medium text-white";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-6 overflow-hidden">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-4xl bg-[#141416] border border-white/10 rounded-[40px] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
style={{ fontFamily: 'var(--font-main)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 pb-4 flex items-center justify-between border-b border-white/5 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">
|
||||
{isEditing ? 'Editar Solicitação' : 'Detalhes da Solicitação'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500 text-xs font-bold uppercase tracking-tighter"># {trip.idsolicitacoes}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[8px] font-bold uppercase tracking-widest ${
|
||||
(trip.status || 'PENDENTE') === 'PENDENTE' ? 'bg-amber-500/20 text-amber-400' :
|
||||
trip.status === 'LIBERADO' ? 'bg-emerald-500/20 text-emerald-400' :
|
||||
'bg-zinc-800 text-zinc-400'
|
||||
}`}>
|
||||
{trip.status || 'PENDENTE'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-10 h-10 bg-white/5 hover:bg-white/10 rounded-full flex items-center justify-center text-zinc-400 hover:text-white transition-all border border-white/5"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher - Only in View Mode */}
|
||||
{!isEditing && (
|
||||
<div className="flex px-8 py-2 border-b border-white/5 bg-[#1a1a1c]/50 overflow-x-auto [&::-webkit-scrollbar]:hidden relative z-20 flex-shrink-0" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{[
|
||||
{ id: 'geral', label: 'Geral', icon: <Hash size={14} /> },
|
||||
{ id: 'nfe', label: 'Dados da NFe', icon: <FileText size={14} /> }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-6 py-4 text-[10px] font-bold uppercase tracking-widest transition-all relative ${
|
||||
activeSubTab === tab.id ? 'text-emerald-500' : 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
{activeSubTab === tab.id && (
|
||||
<motion.div
|
||||
layoutId="activeSubTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-emerald-500"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isEditing ? (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<TripRequestForm
|
||||
mode="edit"
|
||||
initialData={trip}
|
||||
onSuccess={() => {
|
||||
onRefresh();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeSubTab === 'geral' ? (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
|
||||
{/* Highlight Header */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-[28px] p-6 flex flex-col justify-center">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-[10px] font-medium text-emerald-600 uppercase tracking-widest">CTE / Documento</span>
|
||||
<span className="px-2 py-1 bg-emerald-500/20 rounded-lg text-[8px] font-medium uppercase text-emerald-400 tracking-widest">{trip.operacao}</span>
|
||||
</div>
|
||||
<div className="text-3xl md:text-4xl font-medium text-white tracking-tight font-mono break-all leading-tight">
|
||||
{renderValue(trip.cte || trip.nota_fiscal)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1c1c1c] border border-white/5 rounded-[28px] p-6 flex flex-col justify-center">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-widest">Data de Criação</span>
|
||||
<div className={`px-2 py-1 rounded-full text-[8px] font-medium uppercase tracking-widest ${
|
||||
(trip.status || 'PENDENTE') === 'PENDENTE' ? 'bg-amber-500/20 text-amber-400' : 'bg-emerald-500/20 text-emerald-400'
|
||||
}`}>
|
||||
{trip.status || 'PENDENTE'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-medium text-zinc-300">
|
||||
{new Date(trip.created_at || new Date()).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Motorista Highlight & CPF Card */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-2 p-6 bg-[#1c1c1c] border border-white/5 rounded-[32px] flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-zinc-800 flex items-center justify-center text-zinc-500">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] font-medium text-zinc-500 uppercase tracking-widest block mb-1">Identificação do Motorista</span>
|
||||
<div className="text-xl md:text-2xl font-medium text-white uppercase leading-none">{trip.nome_completo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-[#1c1c1c] border border-emerald-500/20 rounded-[32px] flex flex-col justify-center">
|
||||
<span className="text-[11px] font-medium text-emerald-600 uppercase tracking-widest block mb-1">CPF</span>
|
||||
<div className="text-xl font-medium text-white font-mono">{formatCPF(trip.cpf)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Detalhes */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Veículo */}
|
||||
<div className="p-6 bg-zinc-900/40 border border-white/5 rounded-[32px] space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||
<Truck size={16} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-blue-500 uppercase tracking-widest">Veículo</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Cavalo</label>
|
||||
<div className="text-lg font-medium text-white uppercase">{trip.placa_do_cavalo}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Carreta</label>
|
||||
<div className="text-lg font-medium text-zinc-400 uppercase">{trip.placa_da_carreta || '---'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Modelo</label>
|
||||
<div className="text-sm font-medium text-zinc-300 uppercase">{trip.tipo_de_veiculo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rota */}
|
||||
<div className="p-6 bg-zinc-900/40 border border-white/5 rounded-[32px] space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/10 flex items-center justify-center text-amber-500">
|
||||
<MapPin size={16} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-amber-500 uppercase tracking-widest">Rota</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative pl-4 border-l-2 border-zinc-800">
|
||||
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-zinc-600" />
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block">Origem</label>
|
||||
<div className="text-sm font-medium text-white uppercase">{trip.origem}</div>
|
||||
</div>
|
||||
<div className="relative pl-4 border-l-2 border-zinc-800">
|
||||
<div className="absolute -left-[5px] top-1.5 w-2 h-2 rounded-full bg-emerald-500" />
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block">Destino</label>
|
||||
<div className="text-sm font-medium text-white uppercase">{trip.destino}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dados de Liberação - Only if Released or Finished */}
|
||||
{(trip.status === 'LIBERADO' || trip.status === 'FINALIZADO') && (
|
||||
<div className="p-6 bg-emerald-500/5 border border-emerald-500/20 rounded-[32px] space-y-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/10 flex items-center justify-center text-emerald-500">
|
||||
<ShieldCheck size={16} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-emerald-500 uppercase tracking-widest">Dados de Liberação</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Liberado por</label>
|
||||
<div className="text-sm font-medium text-white truncate">{trip.liberado_por || '---'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Data/Hora</label>
|
||||
<div className="text-sm font-medium text-zinc-300">
|
||||
{trip.data_hora_liberacao ? new Date(trip.data_hora_liberacao).toLocaleString('pt-BR') : '---'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{trip.obs_liberacao && (
|
||||
<div>
|
||||
<label className="text-[9px] font-normal text-zinc-600 uppercase tracking-widest block mb-1">Observações da Liberação</label>
|
||||
<div className="text-sm font-medium text-emerald-400 italic">
|
||||
"{trip.obs_liberacao}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Footer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-zinc-900/40 border border-white/5 rounded-2xl flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-medium text-white">{trip.quantidade_palet || 0}</span>
|
||||
<span className="text-[8px] font-normal text-zinc-600 uppercase tracking-widest">Palets</span>
|
||||
</div>
|
||||
<div className="p-4 bg-zinc-900/40 border border-white/5 rounded-2xl flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-medium text-white">{trip.quantidade_chapatex || 0}</span>
|
||||
<span className="text-[8px] font-normal text-zinc-600 uppercase tracking-widest">Chapatex</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-[#1a1a1c] relative h-full">
|
||||
{loadingNfe ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4 flex-1">
|
||||
<Loader2 className="animate-spin text-emerald-500" size={32} />
|
||||
<span className="text-xs font-medium text-zinc-500 uppercase tracking-widest">Buscando dados da NFe...</span>
|
||||
</div>
|
||||
) : nfeData ? (
|
||||
<NfeDataDisplay data={nfeData} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4 text-center px-10">
|
||||
<div className="w-16 h-16 bg-zinc-900 border border-white/10 rounded-full flex items-center justify-center text-zinc-600 mb-4">
|
||||
<FileText size={32} />
|
||||
</div>
|
||||
<h3 className="text-white font-bold uppercase tracking-widest text-xs">Dados não encontrados</h3>
|
||||
<p className="text-zinc-500 text-[10px] max-w-xs uppercase leading-relaxed font-medium">
|
||||
Não há dados de NFe detalhados para o documento informado ou o serviço não retornou informações.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-8 pt-4 bg-[#1a1a1c] border-t border-white/5 flex items-center justify-between flex-shrink-0">
|
||||
{/* Botão de excluir comentado conforme solicitado */}
|
||||
{/*
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading || isEditing}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all disabled:opacity-30"
|
||||
>
|
||||
<Trash2 size={16} /> Excluir
|
||||
</button>
|
||||
*/}
|
||||
<div />
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
{(trip.status || 'PENDENTE') === 'PENDENTE' && (
|
||||
<button
|
||||
onClick={() => setShowAttendanceForm(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-emerald-600/20"
|
||||
>
|
||||
Atender Cliente
|
||||
</button>
|
||||
)}
|
||||
{trip.status === 'LIBERADO' && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate('FINALIZADO')}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-8 py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-emerald-600/20"
|
||||
>
|
||||
<CheckCircle2 size={16} /> Finalizar Solicitação
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAttendanceForm && (
|
||||
<AttendanceFormModal
|
||||
trip={trip}
|
||||
onClose={() => setShowAttendanceForm(false)}
|
||||
onRefresh={() => {
|
||||
onRefresh();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,363 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Send, FileText, User, Truck, MapPin, Package, Hash, Loader2 } from 'lucide-react';
|
||||
import { submitTripRequest, updateTripRequest, getNfe } from '../services/prafrotService';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* TripRequestForm Component
|
||||
* Used in the public DriverPortalView and internal modals.
|
||||
*/
|
||||
export const TripRequestForm = ({ operation, onSuccess, mode = 'create', initialData = null }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
nome_completo: initialData?.nome_completo || '',
|
||||
cpf: initialData?.cpf || '',
|
||||
tipo_de_veiculo: initialData?.tipo_de_veiculo || 'Carreta',
|
||||
placa_do_cavalo: initialData?.placa_do_cavalo || '',
|
||||
placa_da_carreta: initialData?.placa_da_carreta || '',
|
||||
cte: initialData?.cte || '',
|
||||
no_cte: initialData?.cte === 'NÃO POSSUI',
|
||||
quantidade_palet: initialData?.quantidade_palet || '',
|
||||
quantidade_chapatex: initialData?.quantidade_chapatex || '',
|
||||
nota_fiscal: initialData?.nota_fiscal || '',
|
||||
no_nf: initialData?.nota_fiscal === 'NÃO POSSUI',
|
||||
origem: initialData?.origem || '',
|
||||
destino: initialData?.destino || '',
|
||||
operacao: initialData?.operacao || operation?.id || ''
|
||||
});
|
||||
|
||||
const handleCTeBlur = async () => {
|
||||
if (!formData.cte || formData.no_cte || formData.cte.length < 20) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const nfeData = await getNfe(formData.cte);
|
||||
if (nfeData) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
nome_completo: nfeData.motorista || nfeData.nome_motorista || nfeData.nome_completo || prev.nome_completo,
|
||||
cpf: nfeData.cpf_motorista || nfeData.cpf || nfeData.cpf_condutor || prev.cpf,
|
||||
origem: nfeData.origem || nfeData.cidade_origem || nfeData.municipio_origem || nfeData.origem_cidade || prev.origem,
|
||||
destino: nfeData.destino || nfeData.cidade_destino || nfeData.municipio_destino || nfeData.destino_cidade || prev.destino,
|
||||
quantidade_palet: nfeData.quantidade_palet || nfeData.palets || nfeData.qtd_palet || prev.quantidade_palet,
|
||||
quantidade_chapatex: nfeData.quantidade_chapatex || nfeData.chapatex || nfeData.qtd_chapatex || prev.quantidade_chapatex,
|
||||
// Prioridade para a CHAVE (44 dígitos) para permitir visualização da DANFE
|
||||
nota_fiscal: nfeData.chave ||
|
||||
(typeof nfeData.nfe === 'string' && nfeData.nfe.length > 20 ? nfeData.nfe : null) ||
|
||||
(typeof nfeData.nota_fiscal === 'string' && nfeData.nota_fiscal.length > 20 ? nfeData.nota_fiscal : null) ||
|
||||
(typeof nfeData.nfe === 'object' ? nfeData.nfe?.numero : nfeData.nfe) ||
|
||||
(typeof nfeData.nota_fiscal === 'object' ? nfeData.nota_fiscal?.numero : nfeData.nota_fiscal) ||
|
||||
nfeData.numero_nf || prev.nota_fiscal,
|
||||
placa_do_cavalo: nfeData.placa_cavalo || nfeData.placa || prev.placa_do_cavalo,
|
||||
placa_da_carreta: nfeData.placa_carreta || nfeData.placa_reboque || prev.placa_da_carreta
|
||||
}));
|
||||
toast.info('Dados preenchidos automaticamente via CTe!');
|
||||
} else {
|
||||
toast.warning('CTe encontrado, mas sem dados de NFe vinculados.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar NFe:', error);
|
||||
// Silently fail or minimal toast if it's just auto-fill
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCPFChange = (e) => {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
if (value.length > 11) value = value.slice(0, 11);
|
||||
|
||||
let formattedValue = value;
|
||||
if (value.length > 9) {
|
||||
formattedValue = value.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||
} else if (value.length > 6) {
|
||||
formattedValue = value.replace(/(\d{3})(\d{3})(\d{0,3})/, '$1.$2.$3');
|
||||
} else if (value.length > 3) {
|
||||
formattedValue = value.replace(/(\d{3})(\d{0,3})/, '$1.$2');
|
||||
}
|
||||
|
||||
setFormData({ ...formData, cpf: formattedValue });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare payload - alignment with backend expected fields
|
||||
const payload = {
|
||||
...formData,
|
||||
operacao: initialData?.operacao || operation?.label || operation?.id || formData.operacao,
|
||||
cte: formData.no_cte ? 'NÃO POSSUI' : formData.cte,
|
||||
nota_fiscal: formData.no_nf ? 'NÃO POSSUI' : formData.nota_fiscal
|
||||
};
|
||||
|
||||
// Validation
|
||||
if (!payload.nome_completo || !payload.cpf || !payload.placa_do_cavalo) {
|
||||
toast.error('Por favor, preencha os campos obrigatórios (Nome, CPF e Placa).');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'edit' && initialData?.idsolicitacoes) {
|
||||
await updateTripRequest(initialData.idsolicitacoes, payload);
|
||||
toast.success('Solicitação atualizada com sucesso!');
|
||||
} else {
|
||||
await submitTripRequest(payload);
|
||||
toast.success('Solicitação enviada com sucesso!');
|
||||
}
|
||||
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar solicitação:', error);
|
||||
toast.error(error.message || 'Erro ao processar solicitação. Tente novamente.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle = "w-full pl-12 pr-4 py-4 bg-zinc-900/50 border border-zinc-800 rounded-2xl focus:outline-none focus:border-emerald-500/50 focus:ring-4 focus:ring-emerald-500/5 transition-all text-sm font-medium placeholder:text-zinc-600";
|
||||
const labelStyle = "text-[10px] font-bold text-zinc-500 uppercase tracking-widest ml-1 mb-2 block";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 pb-10" style={{ fontFamily: 'var(--font-main)' }}>
|
||||
{/* Seção 1: Motorista */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Nome Completo</label>
|
||||
<div className="relative group">
|
||||
<User size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="Nome do motorista"
|
||||
value={formData.nome_completo}
|
||||
onChange={e => setFormData({...formData, nome_completo: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>CPF</label>
|
||||
<div className="relative group">
|
||||
<Hash size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="000.000.000-00"
|
||||
value={formData.cpf}
|
||||
onChange={handleCPFChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção 2: Veículo */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
<div className="space-y-4">
|
||||
<label className={labelStyle}>Tipo de Veículo</label>
|
||||
<div className="flex gap-3">
|
||||
{['Carreta', 'Truck'].map(tipo => (
|
||||
<button
|
||||
key={tipo}
|
||||
type="button"
|
||||
onClick={() => setFormData({...formData, tipo_de_veiculo: tipo})}
|
||||
className={`flex-1 py-4 rounded-[20px] border font-black text-[10px] uppercase tracking-[0.2em] transition-all ${
|
||||
formData.tipo_de_veiculo === tipo
|
||||
? 'bg-emerald-500 text-[#0a0a0b] border-emerald-500 shadow-lg shadow-emerald-500/20'
|
||||
: 'bg-zinc-900/30 border-zinc-800 text-zinc-600 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{tipo}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Placa do Cavalo</label>
|
||||
<div className="relative group">
|
||||
<Truck size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="ABC-1234"
|
||||
value={formData.placa_do_cavalo}
|
||||
onChange={e => setFormData({...formData, placa_do_cavalo: e.target.value.toUpperCase()})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Placa da Carreta</label>
|
||||
<div className="relative group">
|
||||
<Truck size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="XYZ-5678"
|
||||
value={formData.placa_da_carreta}
|
||||
onChange={e => setFormData({...formData, placa_da_carreta: e.target.value.toUpperCase()})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção 3: Documentação */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>CTe</label>
|
||||
<div className="relative group">
|
||||
<FileText size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={`${inputStyle} ${formData.no_cte ? 'opacity-30 pointer-events-none' : ''}`}
|
||||
placeholder="Número do CTe"
|
||||
value={formData.cte}
|
||||
onChange={e => setFormData({...formData, cte: e.target.value})}
|
||||
onBlur={handleCTeBlur}
|
||||
disabled={formData.no_cte}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 mt-1 cursor-pointer group w-fit">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer appearance-none w-5 h-5 border-2 border-zinc-800 rounded-lg checked:bg-emerald-500 checked:border-emerald-500 transition-all cursor-pointer"
|
||||
checked={formData.no_cte}
|
||||
onChange={e => setFormData({...formData, no_cte: e.target.checked})}
|
||||
/>
|
||||
<CheckIcon className="absolute w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100 left-[3px] pointer-events-none transition-opacity" />
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-zinc-600 group-hover:text-zinc-400 uppercase tracking-widest transition-colors">Não possuo CTe</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Qtd. Palet</label>
|
||||
<div className="relative group">
|
||||
<Package size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputStyle} px-3 pl-12`}
|
||||
placeholder="0"
|
||||
value={formData.quantidade_palet}
|
||||
onChange={e => setFormData({...formData, quantidade_palet: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Qtd. Chapatex</label>
|
||||
<div className="relative group">
|
||||
<Package size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="number"
|
||||
className={`${inputStyle} px-3 pl-12`}
|
||||
placeholder="0"
|
||||
value={formData.quantidade_chapatex}
|
||||
onChange={e => setFormData({...formData, quantidade_chapatex: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Nota Fiscal (Sequência)</label>
|
||||
<div className="relative group">
|
||||
<FileText size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={`${inputStyle} ${formData.no_nf ? 'opacity-30 pointer-events-none' : ''}`}
|
||||
placeholder="Ex: 1 a 10 ou 1 a 10 / 22 a 25"
|
||||
value={formData.nota_fiscal}
|
||||
onChange={e => setFormData({...formData, nota_fiscal: e.target.value})}
|
||||
disabled={formData.no_nf}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 mt-1 cursor-pointer group w-fit">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer appearance-none w-5 h-5 border-2 border-zinc-800 rounded-lg checked:bg-emerald-500 checked:border-emerald-500 transition-all cursor-pointer"
|
||||
checked={formData.no_nf}
|
||||
onChange={e => setFormData({...formData, no_nf: e.target.checked})}
|
||||
/>
|
||||
<CheckIcon className="absolute w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100 left-[3px] pointer-events-none transition-opacity" />
|
||||
</div>
|
||||
<span className="text-[10px] font-black text-zinc-600 group-hover:text-zinc-400 uppercase tracking-widest transition-colors">Não possuo NF</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seção 4: Rota */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Origem</label>
|
||||
<div className="relative group">
|
||||
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="Cidade de Origem"
|
||||
value={formData.origem}
|
||||
onChange={e => setFormData({...formData, origem: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={labelStyle}>Destino</label>
|
||||
<div className="relative group">
|
||||
<MapPin size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyle}
|
||||
placeholder="Cidade de Destino"
|
||||
value={formData.destino}
|
||||
onChange={e => setFormData({...formData, destino: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-5 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-[#0a0a0b] font-black rounded-[32px] shadow-2xl shadow-emerald-500/20 active:scale-95 transition-all text-[11px] uppercase tracking-[0.3em] flex items-center justify-center gap-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
) : (
|
||||
<>
|
||||
<Send size={18} /> Solicitar Liberação
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckIcon = ({ className }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -71,3 +71,6 @@ export const useAvailability = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -73,3 +73,6 @@ export const useClaims = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,3 +26,6 @@ export const useDispatcher = create((set) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,3 +22,6 @@ export const useDrivers = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -105,3 +105,6 @@ export const useFleetLists = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const notify = (type, title, message) => useFeedbackStore.getState().notify(type
|
|||
|
||||
export const useMaintenance = create((set, get) => ({
|
||||
maintenances: [],
|
||||
abertoFechadoData: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
|
|
@ -18,26 +17,14 @@ export const useMaintenance = create((set, get) => ({
|
|||
if (type === 'total') {
|
||||
const data = await prafrotService.getMaintenance();
|
||||
list = Array.isArray(data) ? data : (data.data || []);
|
||||
set({ abertoFechadoData: null });
|
||||
} else if (type === 'aberta' || type === 'fechada' || type === 'aberto_fechado') {
|
||||
} else {
|
||||
const data = await prafrotService.getAbertoFechado();
|
||||
// Concatena abertas e fechadas para manter o estado global se necessário,
|
||||
// mas a tela de manutenção gerencia o estado `abertoFechadoData` via hook.
|
||||
// No entanto, para padronizar as ações (save/update), vamos manter o fetchMaintenances funcional.
|
||||
const abertas = data.abertas || data.aberto || data.abertos || [];
|
||||
const fechadas = data.fechadas || data.fechado || data.fechados || [];
|
||||
|
||||
// Deduplicação por ID para evitar itens duplicados na listagem
|
||||
const merged = [...abertas, ...fechadas];
|
||||
const uniqueMap = new Map();
|
||||
merged.forEach(item => {
|
||||
const id = item.idmanutencao_frota || item.id;
|
||||
if (id) uniqueMap.set(id, item);
|
||||
});
|
||||
|
||||
list = Array.from(uniqueMap.values());
|
||||
set({ abertoFechadoData: data });
|
||||
} else {
|
||||
// Assume it's a specific status filter
|
||||
const data = await prafrotService.getMaintenance(type);
|
||||
list = Array.isArray(data) ? data : (data.data || []);
|
||||
list = [...abertas, ...fechadas];
|
||||
}
|
||||
|
||||
const normalized = list.map(m => ({ ...m, previsao_entrega: m.previsao_entrega ?? m.previcao_entrega }));
|
||||
|
|
@ -70,7 +57,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
try {
|
||||
await prafrotService.createMaintenance(payload, files);
|
||||
notify('success', 'Cadastro Concluído', 'Manutenção registrada com sucesso no sistema.');
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -86,7 +73,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
try {
|
||||
await prafrotService.updateMaintenance(id, payload, files);
|
||||
notify('success', 'Atualização!', 'Dados da manutenção atualizados.');
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -103,7 +90,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
await prafrotService.updateMaintenanceBatch(ids, status);
|
||||
|
||||
notify('success', 'Lote Atualizado', `${ids.length} manutenções foram atualizadas.`);
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -130,7 +117,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
try {
|
||||
await prafrotService.deleteMaintenance(id);
|
||||
notify('success', 'Removido', 'Registro de manutenção excluído.');
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -146,7 +133,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
try {
|
||||
await prafrotService.fecharManutencao(id);
|
||||
notify('success', 'Manutenção Fechada', 'Manutenção fechada com sucesso.');
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -162,7 +149,7 @@ export const useMaintenance = create((set, get) => ({
|
|||
try {
|
||||
await prafrotService.abrirManutencao(id);
|
||||
notify('success', 'Manutenção Aberta', 'Manutenção aberta com sucesso.');
|
||||
// get().fetchMaintenances(); // Desativado auto-fetch para evitar poluição de dados
|
||||
get().fetchMaintenances();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -180,20 +167,8 @@ export const useMaintenance = create((set, get) => ({
|
|||
// Atualiza também a lista principal 'maintenances' para refletir as mudanças
|
||||
const abertas = data.abertas || data.aberto || data.abertos || [];
|
||||
const fechadas = data.fechadas || data.fechado || data.fechados || [];
|
||||
|
||||
// Deduplicação por ID
|
||||
const merged = [...abertas, ...fechadas];
|
||||
const uniqueMap = new Map();
|
||||
merged.forEach(item => {
|
||||
const id = item.idmanutencao_frota || item.id;
|
||||
if (id) uniqueMap.set(id, item);
|
||||
});
|
||||
|
||||
const normalized = Array.from(uniqueMap.values()).map(m => ({
|
||||
...m,
|
||||
previsao_entrega: m.previsao_entrega ?? m.previcao_entrega
|
||||
}));
|
||||
set({ maintenances: normalized, abertoFechadoData: data });
|
||||
const normalized = [...abertas, ...fechadas].map(m => ({ ...m, previsao_entrega: m.previsao_entrega ?? m.previcao_entrega }));
|
||||
set({ maintenances: normalized });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const friendlyMsg = extractFriendlyMessage(error);
|
||||
|
|
@ -282,3 +257,6 @@ export const useMaintenance = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,6 @@ export const useMoki = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,6 @@ export const useMonitoring = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -90,3 +90,6 @@ export const usePrafrotStatistics = () => {
|
|||
refresh: fetchStatistics
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -101,3 +101,6 @@ export const useStatus = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -87,3 +87,6 @@ export const useVehicles = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,6 @@ export const useWorkshops = create((set, get) => ({
|
|||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDocumentMetadata } from '@/hooks/useDocumentMetadata';
|
||||
import { PrafrotSidebar } from '../components/PrafrotSidebar';
|
||||
import { OestPanSidebar } from '../components/PrafrotSidebar';
|
||||
import { FeedbackContainer } from '../components/FeedbackNotification';
|
||||
|
||||
export const PrafrotLayout = () => {
|
||||
useDocumentMetadata('Prafrota', 'prafrot');
|
||||
export const OestPanLayout = () => {
|
||||
useDocumentMetadata('Oeste Pan', 'oest-pan');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
|
||||
|
|
@ -25,12 +25,12 @@ export const PrafrotLayout = () => {
|
|||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex min-h-screen selection:bg-emerald-500/30 theme-frota",
|
||||
"flex min-h-screen font-inter selection:bg-orange-500/30 theme-frota",
|
||||
isDarkMode ? "dark bg-[#141414] text-slate-100" : "bg-slate-50 text-slate-900"
|
||||
)} style={{ fontFamily: 'var(--font-main)' }}>
|
||||
)}>
|
||||
<FeedbackContainer />
|
||||
{/* New Sidebar Component */}
|
||||
<PrafrotSidebar
|
||||
<OestPanSidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggle={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
/>
|
||||
|
|
@ -50,7 +50,7 @@ export const PrafrotLayout = () => {
|
|||
{/* <div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className="text-slate-400 hover:text-emerald-500 transition-colors p-2 rounded-xl hover:bg-emerald-500/10"
|
||||
className="text-slate-400 hover:text-orange-500 transition-colors p-2 rounded-xl hover:bg-orange-500/10"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
|
@ -87,3 +87,6 @@ export const PrafrotLayout = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { PrafrotLayout } from './layout/PrafrotLayout';
|
||||
import { OestPanLayout } from './layout/PrafrotLayout';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
// Lazy loading views
|
||||
|
|
@ -17,59 +17,30 @@ const LoginView = lazy(() => import('./views/LoginView'));
|
|||
const StatisticsView = lazy(() => import('./views/StatisticsView'));
|
||||
const DispatcherView = lazy(() => import('./views/DispatcherView'));
|
||||
const ConfigView = lazy(() => import('./views/ConfigView'));
|
||||
const SolicitacoesView = lazy(() => import('./views/SolicitacoesView'));
|
||||
const FinancePendenciesView = lazy(() => import('./views/FinancePendenciesView'));
|
||||
const MessagesView = lazy(() => import('./views/MessagesView'));
|
||||
|
||||
// Loading component matching Prafrot theme
|
||||
const PrafrotLoader = () => (
|
||||
const OestPanLoader = () => (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#141414]">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-emerald-500 rounded-2xl flex items-center justify-center shadow-lg shadow-emerald-500/20 animate-bounce">
|
||||
<div className="w-16 h-16 bg-orange-500 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-500/20 animate-bounce">
|
||||
<Zap size={32} className="text-[#1c1c1c]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="absolute -inset-4 border-2 border-emerald-500/20 border-t-emerald-500 rounded-full animate-spin" />
|
||||
<div className="absolute -inset-4 border-2 border-orange-500/20 border-t-orange-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-emerald-500 text-[10px] font-bold uppercase tracking-[0.3em] animate-pulse">Prafrot System</span>
|
||||
<span className="text-orange-500 text-[10px] font-bold uppercase tracking-[0.3em] animate-pulse">Oeste Pan System</span>
|
||||
<span className="text-slate-600 text-[8px] font-bold uppercase tracking-widest">Carregando...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
|
||||
export const PrafrotRoutes = () => {
|
||||
const { user } = useAuthContext();
|
||||
|
||||
// Lógica de detecção de setor para redirecionamento inteligente
|
||||
const isFinanceiro = (() => {
|
||||
try {
|
||||
const userSetores = user?.setores || [];
|
||||
let allSetores = [...userSetores];
|
||||
const prafrotUserJson = localStorage.getItem('integra_user_prafrot');
|
||||
if (prafrotUserJson) {
|
||||
const prafrotUser = JSON.parse(prafrotUserJson);
|
||||
if (prafrotUser.setores) {
|
||||
allSetores = [...new Set([...allSetores, ...prafrotUser.setores])];
|
||||
}
|
||||
}
|
||||
return allSetores.includes('Financeiro');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const defaultPath = isFinanceiro
|
||||
? "/plataforma/prafrot/pendencias-financeiro"
|
||||
: "/plataforma/prafrot/estatisticas";
|
||||
|
||||
export const OestPanRoutes = () => {
|
||||
return (
|
||||
<Suspense fallback={<PrafrotLoader />}>
|
||||
<Suspense fallback={<OestPanLoader />}>
|
||||
<Routes>
|
||||
<Route element={<PrafrotLayout />}>
|
||||
<Route element={<OestPanLayout />}>
|
||||
<Route path="veiculos" element={<VehiclesView />} />
|
||||
<Route path="manutencao" element={<MaintenanceView />} />
|
||||
<Route path="disponibilidade" element={<AvailabilityView />} />
|
||||
|
|
@ -82,12 +53,9 @@ export const PrafrotRoutes = () => {
|
|||
<Route path="estatisticas" element={<StatisticsView />} />
|
||||
<Route path="dispatcher" element={<DispatcherView />} />
|
||||
<Route path="configuracoes" element={<ConfigView />} />
|
||||
<Route path="solicitacoes" element={<SolicitacoesView />} />
|
||||
<Route path="pendencias-financeiro" element={<FinancePendenciesView />} />
|
||||
<Route path="mensagens" element={<MessagesView />} />
|
||||
|
||||
<Route index element={<Navigate to={defaultPath} replace />} />
|
||||
<Route path="*" element={<Navigate to={defaultPath} replace />} />
|
||||
<Route index element={<Navigate to="/plataforma/oest-pan/estatisticas" replace />} />
|
||||
<Route path="*" element={<Navigate to="/plataforma/oest-pan/estatisticas" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
|
@ -96,3 +64,6 @@ export const PrafrotRoutes = () => {
|
|||
|
||||
// Export LoginView separately for use in App.jsx
|
||||
export { LoginView };
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ export const dispatcherService = {
|
|||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,9 @@ export const prafrotService = {
|
|||
}),
|
||||
|
||||
// --- Manutenção ---
|
||||
getMaintenance: (status) => handleRequest({
|
||||
getMaintenance: () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const url = status
|
||||
? `${ENDPOINTS.MAINTENANCE}/apresentar?status=${encodeURIComponent(status)}`
|
||||
: `${ENDPOINTS.MAINTENANCE}/apresentar`;
|
||||
const { data } = await api.get(url);
|
||||
const { data } = await api.get(`${ENDPOINTS.MAINTENANCE}/apresentar`);
|
||||
return data;
|
||||
}
|
||||
}),
|
||||
|
|
@ -409,128 +406,5 @@ export const prafrotService = {
|
|||
})
|
||||
};
|
||||
|
||||
// --- Trip Request (Sabrina) Services ---
|
||||
export const submitTripRequest = (data) => handleRequest({
|
||||
apiFn: () => api.post('/solicitacoes', data)
|
||||
});
|
||||
|
||||
export const updateTripRequest = (id, data) => handleRequest({
|
||||
apiFn: () => api.put(`/solicitacao/${id}`, data)
|
||||
});
|
||||
|
||||
export const deleteTripRequest = (id) => handleRequest({
|
||||
apiFn: () => api.put(`/cadastro_frota/delete/${id}`)
|
||||
});
|
||||
|
||||
export const updateTripStatus = (id, status, extraData = {}) => handleRequest({
|
||||
apiFn: () => api.put(`/solicitacao/edicao_status/${id}`, { status, ...extraData })
|
||||
});
|
||||
|
||||
export const getTripRequests = () => handleRequest({
|
||||
apiFn: () => api.get('/solicitacao/apresentar')
|
||||
});
|
||||
|
||||
export const closeTripRequest = (id) => handleRequest({
|
||||
apiFn: () => api.get(`/solicitacao/fechado/${id}`)
|
||||
});
|
||||
|
||||
export const approveTripRequest = (data) => handleRequest({
|
||||
apiFn: () => api.post('/solicitacoes/aprovar', data)
|
||||
});
|
||||
|
||||
export const createOperation = (operacao) => handleRequest({
|
||||
apiFn: () => api.post('/operacoes', { operacao })
|
||||
});
|
||||
|
||||
export const getOperations = () => handleRequest({
|
||||
apiFn: () => api.get('/operacoes/apresentar')
|
||||
});
|
||||
|
||||
export const deleteOperation = (id) => handleRequest({
|
||||
apiFn: () => api.put(`/cadastro_frota/delete/${id}`)
|
||||
});
|
||||
|
||||
export const archiveTripRequest = (id) => handleRequest({
|
||||
apiFn: () => api.put(`/operacoes/arquivado/${id}`)
|
||||
});
|
||||
|
||||
export const getNfe = (chave) => handleRequest({
|
||||
apiFn: () => api.get(`/nfe/${chave}`)
|
||||
});
|
||||
|
||||
export const getNfes = () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/nfe');
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Email XML Processing Services ---
|
||||
|
||||
/**
|
||||
* Salva as credenciais de e-mail para processamento de XML.
|
||||
* @param {string} email
|
||||
* @param {string} senha
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export const saveEmailCredentials = (email, senha) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post('/email_xml/credenciais', { email, senha });
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtém a lista de credenciais de e-mail cadastradas.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export const getEmailCredentials = () => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.get('/email_xml/credenciais/apresentar');
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Atualiza uma credencial de e-mail existente.
|
||||
* @param {number} id
|
||||
* @param {string} email
|
||||
* @param {string} senha
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export const updateEmailCredentials = (id, email, senha) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.put(`/email_xml/credenciais/${id}`, { email, senha });
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Inicia o processamento de e-mails para extração de XML.
|
||||
* @param {Object} data { desde_data, assunto_filtro, salvar_xmls }
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export const processEmails = (payload) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const { data } = await api.post('/email_xml/processar', payload);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Valida um arquivo XML enviado.
|
||||
* @param {File} file
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export const validateXML = (file) => handleRequest({
|
||||
apiFn: async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await api.post('/email_xml/validar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,3 +75,6 @@ export const prafrotStatisticsService = {
|
|||
apiFn: () => api.get(ENDPOINTS.QUANTITATIVO_MANUTENCAO)
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import api from '@/services/api';
|
||||
|
||||
/**
|
||||
* Envia uma nova solicitação de liberação de viagem (Portal do Motorista v2.0).
|
||||
* Endpoint: POST /solicitacoes
|
||||
*/
|
||||
export const submitTripRequest = async (data) => {
|
||||
try {
|
||||
const response = await api.post('/solicitacoes', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao enviar solicitação de viagem');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Edita uma solicitação de viagem existente.
|
||||
* Endpoint: PUT /solicitacao/<idsolicitacoes>
|
||||
*/
|
||||
export const updateTripRequest = async (id, data) => {
|
||||
try {
|
||||
const response = await api.put(`/solicitacao/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao editar solicitação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exclui (deleta) uma solicitação de viagem.
|
||||
* Endpoint: PUT /cadastro_frota/delete/<idsolicitacoes>
|
||||
*/
|
||||
export const deleteTripRequest = async (id) => {
|
||||
try {
|
||||
const response = await api.put(`/cadastro_frota/delete/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao excluir solicitação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Atualiza apenas o status de uma solicitação.
|
||||
* Endpoint: PUT /solicitacao/edicao_status/<idsolicitacoes>
|
||||
*/
|
||||
export const updateTripStatus = async (id, status) => {
|
||||
try {
|
||||
const response = await api.put(`/solicitacao/edicao_status/${id}`, { status });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao atualizar status da solicitação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca todas as solicitações de viagem.
|
||||
* Endpoint: GET /solicitacao/apresentar
|
||||
*/
|
||||
export const getTripRequests = async () => {
|
||||
try {
|
||||
const response = await api.get('/solicitacao/apresentar');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao buscar solicitações');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fecha uma solicitação de viagem.
|
||||
* Endpoint: GET /solicitacao/fechado
|
||||
*/
|
||||
export const closeTripRequest = async () => {
|
||||
try {
|
||||
const response = await api.get('/solicitacao/fechado');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao fechar solicitação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cria uma nova operação.
|
||||
* Endpoint: POST /operacoes
|
||||
*/
|
||||
export const createOperation = async (operacao) => {
|
||||
try {
|
||||
const response = await api.post('/operacoes', { operacao });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao criar operação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Busca todas as operações configuradas.
|
||||
* Endpoint: GET /operacoes/apresentar
|
||||
*/
|
||||
export const getOperations = async () => {
|
||||
try {
|
||||
const response = await api.get('/operacoes/apresentar');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao buscar operações');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exclui (deleta) uma operação.
|
||||
* Endpoint: PUT /cadastro_frota/delete/<idoperacoes_solicitacao>
|
||||
*/
|
||||
export const deleteOperation = async (id) => {
|
||||
try {
|
||||
const response = await api.put(`/cadastro_frota/delete/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao excluir operação');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Arquiva uma solicitação de viagem.
|
||||
* Endpoint: PUT /cadastro_frota/arquivado/<id>
|
||||
*/
|
||||
export const archiveTripRequest = async (id) => {
|
||||
try {
|
||||
const response = await api.put(`/operacoes/arquivado/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao arquivar solicitação');
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Busca dados da NFe pela chave.
|
||||
* Endpoint: GET /nfe/<chave>
|
||||
*/
|
||||
export const getNfe = async (chave) => {
|
||||
try {
|
||||
const response = await api.get(`/nfe/${chave}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.message || 'Erro ao buscar NFe');
|
||||
}
|
||||
};
|
||||
|
|
@ -124,3 +124,6 @@ export const extractFriendlyMessage = (error) => {
|
|||
// Caso contrário, usa a tradução baseada no status
|
||||
return getFriendlyErrorMessage(error);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -25,7 +25,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -38,7 +38,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -113,14 +113,14 @@ export default function AvailabilityView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Disponibilidade e Agenda</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Disponibilidade e Agenda</h1>
|
||||
<p className="text-slate-500 text-sm">Visualização de disponibilidade da frota.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -138,11 +138,11 @@ export default function AvailabilityView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'iddisponibilidade_frota', width: '80px' },
|
||||
{ header: 'VEÍCULO ID', field: 'idveiculo_frota', width: '100px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '120px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '120px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'DATA', field: 'disponibilidade', width: '180px', render: (row) => row.disponibilidade?.split('T')[0] },
|
||||
{ header: 'STATUS', field: 'status_disponibilidade', width: '150px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status_disponibilidade === 'Disponível' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
row.status_disponibilidade === 'Disponível' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
}`}>
|
||||
{row.status_disponibilidade}
|
||||
|
|
@ -168,16 +168,16 @@ export default function AvailabilityView() {
|
|||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-4 max-h-[75vh] overflow-y-auto custom-scrollbar">
|
||||
{formData.iddisponibilidade_frota && (
|
||||
<div className="bg-emerald-500/5 p-3 rounded-xl border border-emerald-500/10 mb-2">
|
||||
<p className="text-[10px] uppercase font-bold text-emerald-500/60 tracking-widest">ID do Registro</p>
|
||||
<p className="text-lg font-bold text-emerald-500">{formData.iddisponibilidade_frota}</p>
|
||||
<div className="bg-orange-500/5 p-3 rounded-xl border border-orange-500/10 mb-2">
|
||||
<p className="text-[10px] uppercase font-bold text-orange-500/60 tracking-widest">ID do Registro</p>
|
||||
<p className="text-lg font-bold text-orange-500">{formData.iddisponibilidade_frota}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Placa (Pesquisar)</label>
|
||||
<input
|
||||
list="veiculos-list"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
value={formData.placa}
|
||||
onChange={handlePlateChange}
|
||||
placeholder="Digite ou selecione a placa..."
|
||||
|
|
@ -206,3 +206,6 @@ export default function AvailabilityView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -27,7 +27,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -40,7 +40,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -132,14 +132,14 @@ export default function ClaimsView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Sinistros e Devoluções</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Sinistros e Devoluções</h1>
|
||||
<p className="text-slate-500 text-sm">Gestão de incidentes e movimentações de frota.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar registro..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -157,7 +157,7 @@ export default function ClaimsView() {
|
|||
loading={loading}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idsinistro_devolucao_frota', width: '80px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS', field: 'status', width: '120px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status === 'Sinistro' ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' :
|
||||
|
|
@ -238,7 +238,7 @@ export default function ClaimsView() {
|
|||
<TabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
>
|
||||
{tab}
|
||||
</TabsTrigger>
|
||||
|
|
@ -295,7 +295,7 @@ export default function ClaimsView() {
|
|||
<div className="gap-1.5 flex flex-col">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
|
|
@ -375,3 +375,6 @@ export default function ClaimsView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { prafrotService, saveEmailCredentials, getEmailCredentials, updateEmailCredentials } from '../services/prafrotService';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search, Settings, Save, X, Edit, Trash2, Mail, Key, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Search, Settings, Save, X, Edit, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
|
@ -14,7 +12,7 @@ const SidebarItem = ({ active, label, onClick }) => (
|
|||
onClick={onClick}
|
||||
className={`w-full text-left px-4 py-3 text-sm font-medium transition-colors border-l-2 ${
|
||||
active
|
||||
? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-500 border-emerald-500'
|
||||
? 'bg-orange-50 dark:bg-orange-500/10 text-orange-600 dark:text-orange-500 border-orange-500'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#2a2a2a] border-transparent'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -25,7 +23,7 @@ const SidebarItem = ({ active, label, onClick }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white",
|
||||
danger: "bg-red-500/10 hover:bg-red-500/20 text-red-600 dark:text-red-500 border border-red-500/20"
|
||||
|
|
@ -42,7 +40,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-2">
|
||||
{label && <label className="text-[11px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1 block">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2.5 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2.5 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -60,109 +58,6 @@ export default function ConfigView() {
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({});
|
||||
const { user } = useAuthContext();
|
||||
|
||||
// Credentials State
|
||||
const [credentialsList, setCredentialsList] = useState([]);
|
||||
const [isSavingCreds, setIsSavingCreds] = useState(false);
|
||||
|
||||
// Load Credentials when route is selected
|
||||
useEffect(() => {
|
||||
if (selectedRoute === 'credentials') {
|
||||
const fetchCreds = async () => {
|
||||
try {
|
||||
const data = await getEmailCredentials();
|
||||
// Ensure data is array
|
||||
const list = Array.isArray(data) ? data : (data.results || []);
|
||||
setCredentialsList(list);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Erro ao carregar credenciais");
|
||||
}
|
||||
};
|
||||
fetchCreds();
|
||||
}
|
||||
}, [selectedRoute]);
|
||||
|
||||
const handleSaveCredentialItem = async (e) => {
|
||||
e.preventDefault();
|
||||
const email = formData.email;
|
||||
const senha = formData.senha;
|
||||
|
||||
if (!email || !senha) {
|
||||
toast.error("Preencha e-mail e senha");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingCreds(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
const id = editingItem.id || editingItem.idemail_credenciais;
|
||||
if (!id) {
|
||||
toast.error("ID da credencial não encontrado.");
|
||||
return;
|
||||
}
|
||||
await updateEmailCredentials(id, email, senha);
|
||||
toast.success("Credencial atualizada!");
|
||||
} else {
|
||||
await saveEmailCredentials(email, senha);
|
||||
toast.success("Credencial criada!");
|
||||
}
|
||||
|
||||
// Refresh
|
||||
const data = await getEmailCredentials();
|
||||
const list = Array.isArray(data) ? data : (data.results || []);
|
||||
setCredentialsList(list);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Erro ao salvar credencial");
|
||||
} finally {
|
||||
setIsSavingCreds(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConfigOptions = useMemo(() => {
|
||||
// 1. Identificação de Setores (Alinhada com integra_user_prafrot via context)
|
||||
const userSetores = user?.setores || [];
|
||||
|
||||
// Tenta obter especificamente do usuário prafrot caso o contexto global divirja
|
||||
let allSetores = [...userSetores];
|
||||
try {
|
||||
const prafrotUserJson = localStorage.getItem('integra_user_prafrot');
|
||||
if (prafrotUserJson) {
|
||||
const prafrotUser = JSON.parse(prafrotUserJson);
|
||||
if (prafrotUser.setores) {
|
||||
allSetores = [...new Set([...allSetores, ...prafrotUser.setores])];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('ConfigView: Erro ao ler integra_user_prafrot');
|
||||
}
|
||||
|
||||
const isFinanceiro = allSetores.includes('Financeiro');
|
||||
const isMonitoramento = allSetores.includes('Monitoramento');
|
||||
|
||||
// Se ambos ou se não tiver informação de setor, mostra tudo
|
||||
if ((isFinanceiro && isMonitoramento) || allSetores.length === 0) {
|
||||
return configOptions;
|
||||
}
|
||||
|
||||
const filtered = {};
|
||||
Object.entries(configOptions).forEach(([key, opt]) => {
|
||||
const label = opt.label?.toLowerCase() || '';
|
||||
const isValidacao = label.includes('validação') || key.toLowerCase().includes('validacao');
|
||||
|
||||
if (isFinanceiro) {
|
||||
// Financeiro só vê validação
|
||||
if (isValidacao) filtered[key] = opt;
|
||||
} else if (isMonitoramento) {
|
||||
// Monitoramento vê tudo exceto validação
|
||||
if (!isValidacao) filtered[key] = opt;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [configOptions, user]);
|
||||
|
||||
// Fetch Config Options on Mount
|
||||
useEffect(() => {
|
||||
|
|
@ -186,8 +81,6 @@ export default function ConfigView() {
|
|||
// Fetch Items when Selected Route changes
|
||||
useEffect(() => {
|
||||
if (!selectedRoute) return;
|
||||
// Don't fetch generic items if we are in credentials mode
|
||||
if (selectedRoute === 'credentials') return;
|
||||
|
||||
const loadItems = async () => {
|
||||
setLoadingItems(true);
|
||||
|
|
@ -241,18 +134,6 @@ export default function ConfigView() {
|
|||
|
||||
// Actions
|
||||
const handleOpenModal = (item = null) => {
|
||||
if (selectedRoute === 'credentials') {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ email: item.email, senha: '' }); // Password usually empty or placeholder
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({ email: '', senha: '' });
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...item });
|
||||
|
|
@ -346,7 +227,7 @@ export default function ConfigView() {
|
|||
}
|
||||
};
|
||||
|
||||
const selectedLabel = selectedRoute === 'credentials' ? 'Credenciais' : (filteredConfigOptions[selectedRoute]?.label || selectedRoute);
|
||||
const selectedLabel = configOptions[selectedRoute]?.label || selectedRoute;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 dark:bg-[#0f0f0f] overflow-hidden">
|
||||
|
|
@ -354,19 +235,13 @@ export default function ConfigView() {
|
|||
<div className="w-64 bg-white dark:bg-[#141414] border-r border-slate-200 dark:border-[#2a2a2a] flex flex-col">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-[#2a2a2a]">
|
||||
<h2 className="text-lg font-bold text-slate-800 dark:text-white flex items-center gap-2">
|
||||
<Settings className="text-emerald-500" size={20} />
|
||||
<Settings className="text-orange-500" size={20} />
|
||||
Configurações
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">Gerencie as listas do sistema</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-2 custom-scrollbar">
|
||||
<SidebarItem
|
||||
active={selectedRoute === 'credentials'}
|
||||
label="Credenciais de Acesso"
|
||||
onClick={() => setSelectedRoute('credentials')}
|
||||
/>
|
||||
<div className="h-px bg-slate-200 dark:bg-[#2a2a2a] mx-4 my-2" />
|
||||
{Object.entries(filteredConfigOptions).map(([key, opt]) => (
|
||||
{Object.entries(configOptions).map(([key, opt]) => (
|
||||
<SidebarItem
|
||||
key={key}
|
||||
active={selectedRoute === opt.rota}
|
||||
|
|
@ -382,42 +257,33 @@ export default function ConfigView() {
|
|||
{/* Header */}
|
||||
<div className="bg-white dark:bg-[#141414] border-b border-slate-200 dark:border-[#2a2a2a] px-8 py-5 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">{selectedLabel}</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">{selectedLabel}</h1>
|
||||
<p className="text-slate-500 text-sm">Gerenciamento de opções para {selectedLabel?.toLowerCase()}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedRoute !== 'credentials' && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-slate-50 dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-emerald-500 w-64"
|
||||
placeholder="Pesquisar..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Item
|
||||
</DarkButton>
|
||||
</>
|
||||
)}
|
||||
{selectedRoute === 'credentials' && (
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Nova Credencial
|
||||
</DarkButton>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-slate-50 dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-64"
|
||||
placeholder="Pesquisar..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DarkButton onClick={() => handleOpenModal()}>
|
||||
<Plus size={18} /> Novo Item
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table or Content */}
|
||||
{/* Table */}
|
||||
<div className="flex-1 p-8 overflow-hidden">
|
||||
<div className="h-full bg-white dark:bg-[#141414] rounded-xl shadow-sm border border-slate-200 dark:border-[#2a2a2a] overflow-hidden flex flex-col">
|
||||
{loadingItems ? (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500">
|
||||
Carregando...
|
||||
</div>
|
||||
) : (selectedRoute !== 'credentials' && (
|
||||
) : (
|
||||
<ExcelTable
|
||||
data={filteredItems}
|
||||
columns={columns}
|
||||
|
|
@ -431,21 +297,6 @@ export default function ConfigView() {
|
|||
return idKey ? row[idKey] : Math.random();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedRoute === 'credentials' && (
|
||||
<div className="flex bg-white dark:bg-[#1a1a1a] rounded-2xl w-full h-full p-4 overflow-hidden">
|
||||
<ExcelTable
|
||||
data={credentialsList}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idemail_credenciais', width: '80px' },
|
||||
{ header: 'E-mail Monitorado', field: 'email', width: '300px' },
|
||||
{ header: 'Data Criação', field: 'criado_em', width: '150px', render: (row) => row.criado_em ? new Date(row.criado_em).toLocaleDateString() : '---' }
|
||||
]}
|
||||
onEdit={handleOpenModal}
|
||||
rowKey={(row) => row.id || row.idemail_credenciais}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -456,48 +307,13 @@ export default function ConfigView() {
|
|||
<DialogContent className="max-w-2xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200">
|
||||
<DialogHeader className="border-b border-slate-200 dark:border-[#2a2a2a] pb-4">
|
||||
<DialogTitle className="text-slate-800 dark:text-white">
|
||||
{selectedRoute === 'credentials'
|
||||
? (editingItem ? 'Editar Credencial' : 'Nova Credencial')
|
||||
: (editingItem ? `Editar ${selectedLabel}` : `Novo Item em ${selectedLabel}`)
|
||||
}
|
||||
{editingItem ? `Editar ${selectedLabel}` : `Novo Item em ${selectedLabel}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={selectedRoute === 'credentials' ? handleSaveCredentialItem : handleSave} className="py-4 grid grid-cols-1 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
{selectedRoute === 'credentials' ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">E-mail Monitorado</label>
|
||||
<div className="relative group">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => setFormData({...formData, email: e.target.value})}
|
||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl text-sm font-medium focus:outline-none focus:border-emerald-500 transition-all text-slate-800 dark:text-slate-200"
|
||||
placeholder="exemplo@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1">Senha de Aplicativo</label>
|
||||
<div className="relative group">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
||||
<input
|
||||
type="password"
|
||||
value={formData.senha || ''}
|
||||
onChange={(e) => setFormData({...formData, senha: e.target.value})}
|
||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl text-sm font-medium focus:outline-none focus:border-emerald-500 transition-all text-slate-800 dark:text-slate-200"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 pl-1">* Utilize uma senha de aplicativo gerada pelo provedor de e-mail por segurança.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Dynamic Form Generation for Other Configs */}
|
||||
{Object.keys(formData).map(key => {
|
||||
<form onSubmit={handleSave} className="py-4 grid grid-cols-1 gap-6 max-h-[60vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Dynamic Form Generation */}
|
||||
{Object.keys(formData).map(key => {
|
||||
// Hide ID fields completely from form
|
||||
const isId = key.toLowerCase().startsWith('id') || key === 'created_at' || key === 'updated_at';
|
||||
if (isId) return null;
|
||||
|
|
@ -530,14 +346,12 @@ export default function ConfigView() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<DialogFooter className="border-t border-slate-200 dark:border-[#2a2a2a] pt-4">
|
||||
<DarkButton variant="ghost" type="button" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit" onClick={selectedRoute === 'credentials' ? handleSaveCredentialItem : handleSave}>
|
||||
{isSavingCreds ? <Loader2 className="animate-spin" size={16} /> : <Save size={16} />} Salvar
|
||||
<DarkButton variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton onClick={handleSave}>
|
||||
<Save size={16} /> Salvar
|
||||
</DarkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -545,3 +359,6 @@ export default function ConfigView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const StatCard = ({ title, value, subtext, icon: Icon, color, trend }) => (
|
|||
</div>
|
||||
{trend && (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded flex items-center gap-1 ${trend > 0 ? 'text-emerald-500 bg-emerald-500/10' : 'text-red-500 bg-red-500/10'}`}>
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded flex items-center gap-1 ${trend > 0 ? 'text-orange-500 bg-orange-500/10' : 'text-red-500 bg-red-500/10'}`}>
|
||||
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{Math.abs(trend)}%
|
||||
</span>
|
||||
|
|
@ -37,7 +37,7 @@ export default function DashboardView() {
|
|||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Visão Geral</h1>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">Visão Geral</h1>
|
||||
<p className="text-slate-500 text-sm">Monitoramento em tempo real da operação.</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ export default function DashboardView() {
|
|||
value="94.2%"
|
||||
subtext="Meta: 95%"
|
||||
icon={CheckCircle2}
|
||||
color="bg-emerald-500"
|
||||
color="bg-orange-500"
|
||||
trend={1.8}
|
||||
/>
|
||||
<StatCard
|
||||
|
|
@ -89,7 +89,7 @@ export default function DashboardView() {
|
|||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: 'SP - Capital (SRJ10)', val: 450, tot: 1240, col: 'bg-blue-600' },
|
||||
{ label: 'RJ - Rio de Janeiro (GIG)', val: 320, tot: 1240, col: 'bg-emerald-500' },
|
||||
{ label: 'RJ - Rio de Janeiro (GIG)', val: 320, tot: 1240, col: 'bg-orange-500' },
|
||||
{ label: 'MG - Belo Horizonte', val: 210, tot: 1240, col: 'bg-yellow-500' },
|
||||
{ label: 'Outras Bases', val: 260, tot: 1240, col: 'bg-slate-600' },
|
||||
].map((item, i) => (
|
||||
|
|
@ -134,3 +134,6 @@ export default function DashboardView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const DispatcherView = () => {
|
|||
return (
|
||||
<div className="h-full flex flex-col p-4 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-medium tracking-tight text-white">Dispatcher</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">Dispatcher</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Visualização de dados do Dispatcher.</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -54,3 +54,6 @@ const DispatcherView = () => {
|
|||
};
|
||||
|
||||
export default DispatcherView;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Truck, ArrowLeft, Send, CheckCircle2, ChevronRight, Phone, ShieldCheck, Mail, Loader2 } from 'lucide-react';
|
||||
import { useDocumentMetadata } from '@/hooks/useDocumentMetadata';
|
||||
import { TripRequestForm } from '../components/TripRequestForm';
|
||||
import { getOperations } from '../services/prafrotService';
|
||||
|
||||
export const DriverPortalView = () => {
|
||||
useDocumentMetadata('Portal do Motorista', 'prafrot');
|
||||
const [step, setStep] = useState('selection'); // selection, form, success
|
||||
const [selectedOp, setSelectedOp] = useState(null);
|
||||
const [operations, setOperations] = useState([]);
|
||||
const [loadingOps, setLoadingOps] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOps = async () => {
|
||||
try {
|
||||
const data = await getOperations();
|
||||
// Backend returns list of { idoperacoes_solicitacao, operacao }
|
||||
const formatted = (Array.isArray(data) ? data : data?.data || []).map(op => ({
|
||||
id: op.idoperacoes_solicitacao,
|
||||
label: op.operacao,
|
||||
icon: Truck
|
||||
}));
|
||||
setOperations(formatted.length > 0 ? formatted : [
|
||||
{ id: 'ASVD', label: 'ASVD', icon: Truck },
|
||||
{ id: 'CDI', label: 'CDI', icon: Truck },
|
||||
{ id: 'ORTOBOM', label: 'ORTOBOM RJ', icon: Truck },
|
||||
{ id: 'IMPERIO', label: 'IMPERIO', icon: Truck },
|
||||
{ id: 'ITAIPAVA', label: 'ITAIPAVA', icon: Truck }
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar operações:', error);
|
||||
} finally {
|
||||
setLoadingOps(false);
|
||||
}
|
||||
};
|
||||
fetchOps();
|
||||
}, []);
|
||||
|
||||
const handleOpSelect = (op) => {
|
||||
setSelectedOp(op);
|
||||
setStep('form');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setStep('success');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setSelectedOp(null);
|
||||
setStep('selection');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0b] text-white font-sans selection:bg-emerald-500/30 selection:text-emerald-200 overflow-x-hidden" style={{ fontFamily: 'var(--font-main)' }}>
|
||||
{/* Premium Background */}
|
||||
<div className="fixed inset-0 z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-emerald-600/10 blur-[120px] rounded-full animate-pulse" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-blue-600/10 blur-[120px] rounded-full animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 brightness-100 contrast-150 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-md md:max-w-5xl mx-auto px-5 py-10 md:py-16">
|
||||
{/* Header */}
|
||||
<header className="text-center mb-10 space-y-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 text-[9px] font-bold uppercase tracking-[0.2em] mb-2"
|
||||
>
|
||||
<ShieldCheck size={12} /> Prafrot System
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-3xl md:text-5xl font-medium tracking-tight leading-none"
|
||||
>
|
||||
SOLICITAÇÃO DE <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-emerald-600">ROTA</span>
|
||||
</motion.h1>
|
||||
<p className="text-zinc-500 text-xs md:text-sm font-medium uppercase tracking-widest opacity-80">Portal do Motorista</p>
|
||||
</header>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 'selection' && (
|
||||
<motion.div
|
||||
key="selection"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="text-sm md:text-base font-medium text-emerald-500 uppercase tracking-[0.2em]">Selecione a Operação</h2>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(16, 185, 129, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex flex-col gap-3 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{loadingOps ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="animate-spin text-emerald-500" size={32} />
|
||||
</div>
|
||||
) : (
|
||||
operations.map((op, idx) => (
|
||||
<motion.button
|
||||
key={op.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
onClick={() => handleOpSelect(op)}
|
||||
className="group relative flex items-center justify-between p-5 bg-zinc-900/80 hover:bg-zinc-800 border border-zinc-800/50 hover:border-emerald-500/50 rounded-[28px] transition-all duration-300 active:scale-[0.97]"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 bg-zinc-800/50 group-hover:bg-emerald-500/20 rounded-2xl flex items-center justify-center transition-colors">
|
||||
<op.icon size={20} className="text-zinc-500 group-hover:text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-base font-black text-zinc-300 group-hover:text-white uppercase tracking-tight">{op.label}</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-800/50 flex items-center justify-center group-hover:bg-emerald-500/20 transition-colors">
|
||||
<ChevronRight size={18} className="text-zinc-600 group-hover:text-emerald-400" />
|
||||
</div>
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 'form' && (
|
||||
<motion.div
|
||||
key="form"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
>
|
||||
<div className="flex flex-col gap-4 mb-8">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 text-zinc-600 hover:text-white text-[10px] font-black uppercase tracking-widest transition-colors w-fit"
|
||||
>
|
||||
<ArrowLeft size={14} /> Voltar
|
||||
</button>
|
||||
<div className="flex items-center justify-between p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-[24px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-emerald-500/20 rounded-xl flex items-center justify-center">
|
||||
<Truck size={16} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-xs font-black text-emerald-400 uppercase tracking-widest">{selectedOp?.label}</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-black text-emerald-500/50 uppercase tracking-widest">Passo 02</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TripRequestForm
|
||||
operation={selectedOp}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<motion.div
|
||||
key="success"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-16 bg-zinc-900/50 border border-zinc-800/50 rounded-[40px] px-8"
|
||||
>
|
||||
<div className="w-20 h-20 bg-emerald-500/20 rounded-3xl flex items-center justify-center mx-auto mb-8 shadow-2xl shadow-emerald-500/20 -rotate-6">
|
||||
<CheckCircle2 size={40} className="text-emerald-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black mb-3 leading-tight">SOLICITAÇÃO<br /><span className="text-emerald-500">ENVIADA!</span></h2>
|
||||
<p className="text-zinc-500 text-xs font-bold leading-relaxed mb-10 max-w-[240px] mx-auto uppercase tracking-widest opacity-80">
|
||||
Sua liberação está sendo processada pela nossa equipe.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
className="w-full py-5 bg-zinc-800 hover:bg-zinc-700 text-white font-black rounded-3xl text-[10px] uppercase tracking-[0.3em] transition-all active:scale-95 shadow-xl shadow-black/20"
|
||||
>
|
||||
Novo Registro
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Footer info */}
|
||||
{/* <footer className="mt-16 pt-10 border-t border-zinc-900/50 text-center">
|
||||
<p className="text-zinc-600 text-[9px] font-black uppercase tracking-[0.3em] mb-6 opacity-50">Central de Atendimento</p>
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<a href="#" className="flex items-center gap-3 text-zinc-500 hover:text-emerald-400 transition-colors text-xs font-black tracking-tight">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center border border-zinc-800"><Phone size={14} /></div>
|
||||
0800 123 4567
|
||||
</a>
|
||||
<a href="#" className="flex items-center gap-3 text-zinc-500 hover:text-emerald-400 transition-colors text-xs font-black tracking-tight">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center border border-zinc-800"><Mail size={14} /></div>
|
||||
suporte@pralog.com.br
|
||||
</a>
|
||||
</div>
|
||||
</footer> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,7 +11,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -20,7 +20,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -52,13 +52,13 @@ export default function DriversView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Motoristas</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Motoristas</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar motorista..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -71,7 +71,7 @@ export default function DriversView() {
|
|||
<ExcelTable
|
||||
data={filteredData}
|
||||
columns={[
|
||||
{ header: 'NOME', field: 'NOME_FAVORECIDO', width: '300px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'NOME', field: 'NOME_FAVORECIDO', width: '300px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'CPF/CNPJ', field: 'CPF_CNPJ_FAVORECIDO', width: '180px' },
|
||||
{ header: 'TELEFONE', field: 'TELEFONE', width: '150px' },
|
||||
{ header: 'ENDEREÇO', field: 'ENDERECO', width: '250px' },
|
||||
|
|
@ -87,3 +87,6 @@ export default function DriversView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,714 +0,0 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { useMaintenance } from '../hooks/useMaintenance';
|
||||
import { useFleetLists } from '../hooks/useFleetLists';
|
||||
const SmartTable = React.lazy(() => import('@/features/dev-tools/components/SmartTable')); // Universal Connector
|
||||
import { Search, Wrench, CheckCircle, AlertTriangle, FileText, Edit2, History } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LoadingOverlay } from '../../../components/shared/LoadingOverlay';
|
||||
import { prafrotService } from '../services/prafrotService';
|
||||
|
||||
// --- Styled Components & Helpers ---
|
||||
const StatusTag = ({ status }) => {
|
||||
const styles = {
|
||||
'Pendente Aprovação': 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
|
||||
'Pendente Pagamento': 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
'Aprovado': 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
'default': 'bg-slate-700/30 text-slate-400 border-slate-600/30'
|
||||
};
|
||||
const currentStyle = styles[status] || styles['default'];
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase border whitespace-nowrap ${currentStyle}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Reusing Styled Components ---
|
||||
const DarkInput = ({ label, readOnly, className = '', ...props }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
readOnly={readOnly}
|
||||
className={`w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 ${readOnly ? 'cursor-not-allowed opacity-80 bg-slate-100 dark:bg-[#1a1a1a]' : ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange, disabled }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white",
|
||||
danger: "bg-rose-600 hover:bg-rose-500 text-white shadow-rose-500/10"
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${baseClass} ${variants[variant]} ${className}`} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const formatCurrency = (v) => {
|
||||
if (v == null || v === '') return 'R$ 0,00';
|
||||
const n = typeof v === 'string' ? parseCurrency(v) : Number(v);
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n || 0);
|
||||
};
|
||||
|
||||
const parseCurrency = (s) => {
|
||||
if (s == null || s === '') return '';
|
||||
let t = String(s).trim().replace(/\s/g, '').replace(/R\$\s?/gi, '');
|
||||
if (!t) return '';
|
||||
if (t.includes(',')) {
|
||||
t = t.replace(/\./g, '').replace(',', '.');
|
||||
} else if (t.includes('.')) {
|
||||
const lastDot = t.lastIndexOf('.');
|
||||
const afterDot = t.slice(lastDot + 1);
|
||||
const isLikelyUsDecimal = afterDot.length === 2 && /^\d\d$/.test(afterDot) && (t.match(/\./g) || []).length === 1;
|
||||
if (!isLikelyUsDecimal) {
|
||||
t = t.replace(/\./g, '');
|
||||
}
|
||||
}
|
||||
const n = parseFloat(t);
|
||||
return isNaN(n) ? '' : n;
|
||||
};
|
||||
|
||||
const isInternalFileUrl = (url) => {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const DetailSection = ({ title, children }) => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-bold text-emerald-600 dark:text-emerald-500 uppercase tracking-[0.2em] border-b border-emerald-500/10 pb-2">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
function FileLink({ label, url }) {
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4 flex flex-col gap-1 transition-all hover:border-emerald-500/30 group">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider font-mono block mb-0.5">{label}</label>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 underline font-bold text-sm inline-flex items-center gap-1.5"
|
||||
>
|
||||
<FileText size={14} className="group-hover:scale-110 transition-transform" />
|
||||
Abrir documento
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinancePendenciesView() {
|
||||
const {
|
||||
maintenances,
|
||||
loading,
|
||||
fetchMaintenances,
|
||||
updateMaintenance,
|
||||
updateMaintenanceBatch
|
||||
} = useMaintenance();
|
||||
|
||||
const { fetchListsConfig, validacaoOptions } = useFleetLists();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('pendente-aprovacao');
|
||||
const [selectedMaintenance, setSelectedMaintenance] = useState(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [obsFinanceiro, setObsFinanceiro] = useState('');
|
||||
const [validacaoFinanceiro, setValidacaoFinanceiro] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// No modo financeiro, começamos buscando apenas o que está pendente de aprovação
|
||||
const backendStatus = activeTab === 'pendente-aprovacao' ? 'Pendente Aprovação'
|
||||
: activeTab === 'pendente-pagamento' ? 'Pendente Pagamento'
|
||||
: 'Aprovado';
|
||||
fetchMaintenances(backendStatus);
|
||||
fetchListsConfig();
|
||||
}, [fetchMaintenances, fetchListsConfig, activeTab]);
|
||||
|
||||
const filteredMaintenances = useMemo(() => {
|
||||
// Camada de segurança (Safety Filter) para garantir que statuses não se misturem
|
||||
const backendStatus = activeTab === 'pendente-aprovacao' ? 'Pendente Aprovação'
|
||||
: activeTab === 'pendente-pagamento' ? 'Pendente Pagamento'
|
||||
: 'Aprovado';
|
||||
|
||||
return maintenances.filter(item => {
|
||||
// Filtro de Status (Segurança)
|
||||
if ((item.status || '').trim() !== backendStatus) return false;
|
||||
|
||||
// Filtro de Busca
|
||||
return (
|
||||
item.placa?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.oficina?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
String(item.idmanutencao_frota || item.id || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
});
|
||||
}, [maintenances, searchTerm, activeTab]);
|
||||
|
||||
const handleOpenDetail = async (m) => {
|
||||
// Busca o item completo do array para garantir que temos todos os campos antes de editar
|
||||
const fullItem = (maintenances || []).find(item => (item.idmanutencao_frota || item.id) === (m.idmanutencao_frota || m.id)) || m;
|
||||
setSelectedMaintenance(fullItem);
|
||||
setObsFinanceiro(fullItem.obs_financeiro || '');
|
||||
setValidacaoFinanceiro(fullItem.validacao_financeiro || '');
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!selectedMaintenance) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Para evitar perda de dados, enviamos o objeto completo mesclado com as novas informações
|
||||
const payload = {
|
||||
...selectedMaintenance,
|
||||
status: 'Aprovado',
|
||||
validacao_financeiro: validacaoFinanceiro,
|
||||
obs_financeiro: obsFinanceiro
|
||||
};
|
||||
|
||||
// Removemos IDs nulos ou campos de controle se necessário, mas o principal é o objeto base estar lá
|
||||
await updateMaintenance(selectedMaintenance.idmanutencao_frota || selectedMaintenance.id, payload);
|
||||
|
||||
setIsDetailOpen(false);
|
||||
// Recarrega a aba atual para o item sumir da lista
|
||||
const backendStatus = activeTab === 'pendente-aprovacao' ? 'Pendente Aprovação'
|
||||
: activeTab === 'pendente-pagamento' ? 'Pendente Pagamento'
|
||||
: 'Aprovado';
|
||||
fetchMaintenances(backendStatus);
|
||||
} catch (error) {
|
||||
console.error('Erro ao aprovar:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReturn = async () => {
|
||||
if (!selectedMaintenance || !obsFinanceiro) {
|
||||
alert('Por favor, descreva o motivo do retorno na observação financeira.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Determina o status de destino baseado no status atual
|
||||
const currentStatus = (selectedMaintenance.status || '').trim();
|
||||
const targetStatus = currentStatus === 'Pendente Pagamento' ? 'Pendente Aprovação' : 'Pendente Pagamento';
|
||||
|
||||
const payload = {
|
||||
...selectedMaintenance,
|
||||
status: targetStatus,
|
||||
obs_financeiro: obsFinanceiro
|
||||
};
|
||||
|
||||
await updateMaintenance(selectedMaintenance.idmanutencao_frota || selectedMaintenance.id, payload);
|
||||
|
||||
setIsDetailOpen(false);
|
||||
// Recarrega a aba atual para o item sumir da lista
|
||||
const backendStatus = activeTab === 'pendente-aprovacao' ? 'Pendente Aprovação'
|
||||
: activeTab === 'pendente-pagamento' ? 'Pendente Pagamento'
|
||||
: 'Aprovado';
|
||||
fetchMaintenances(backendStatus);
|
||||
} catch (error) {
|
||||
console.error('Erro ao retornar:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ header: 'ID', field: 'idmanutencao_frota', width: '80px' },
|
||||
{ header: 'ATENDIMENTO', field: 'atendimento', width: '100px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA RESERVA', field: 'placa_reserva', width: '100px' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '110px' },
|
||||
{ header: 'OFICINA', field: 'oficina', width: '160px' },
|
||||
{ header: 'BASE FROTA', field: 'base_frota', width: '100px' },
|
||||
{ header: 'CIDADE', field: 'cidade', width: '120px' },
|
||||
{ header: 'UF', field: 'uf', width: '60px' },
|
||||
{ header: 'PROPRIETÁRIO', field: 'proprietario', width: '110px' },
|
||||
{ header: 'RESPONSÁVEL', field: 'responsavel', width: '120px' },
|
||||
{ header: 'MOTIVO ATEND.', field: 'motivo_atendimento', width: '120px' },
|
||||
{ header: 'STATUS', field: 'status', width: '140px', render: (row) => <StatusTag status={row.status} /> },
|
||||
{ header: 'MANUTENÇÃO', field: 'manutencao', width: '90px' },
|
||||
{ header: 'STATUS FROTA', field: 'status_frota_veiculo', width: '120px' },
|
||||
{ header: 'DATA SOLIC.', field: 'data_solicitacao', width: '100px' },
|
||||
{ header: 'DATA AGEND.', field: 'data_agendamento', width: '100px' },
|
||||
{ header: 'DATA PARADA', field: 'data_parada_veiculo', width: '100px' },
|
||||
{ header: 'PREV. ENTREGA', field: 'previsao_entrega', width: '100px' },
|
||||
{ header: 'DATA FINAL.', field: 'data_finalizacao', width: '100px' },
|
||||
{ header: 'DATA RETIRADA', field: 'data_retirada', width: '100px' },
|
||||
{ header: 'ORÇ. INICIAL', field: 'orcamento_inicial', width: '110px', className: 'font-mono text-emerald-600 dark:text-emerald-400', render: (row) => formatCurrency(row.orcamento_inicial) },
|
||||
{ header: 'ORÇ. FINAL', field: 'orcamento_final', width: '110px', className: 'font-mono text-emerald-600 dark:text-emerald-400', render: (row) => formatCurrency(row.orcamento_final) },
|
||||
{ header: 'DIF. ORÇ.', field: 'dif_orcamento', width: '100px', className: 'font-mono', render: (row) => formatCurrency(row.dif_orcamento) },
|
||||
{ header: 'COND. PAG.', field: 'condicao_pagamento', width: '120px', render: (row) => (
|
||||
<span>
|
||||
{row.condicao_pagamento}
|
||||
{row.condicao_pagamento === 'Parcelado' && row.qtd_parcelas_condicao_pag && (
|
||||
<span className="ml-1 text-emerald-500 font-bold">({row.qtd_parcelas_condicao_pag}x)</span>
|
||||
)}
|
||||
</span>
|
||||
)},
|
||||
{ header: 'VALID. FINANC.', field: 'validacao_financeiro', width: '110px' },
|
||||
{ header: 'RESP. APROV.', field: 'resp_aprovacao', width: '110px' },
|
||||
{ header: 'SLA OFICINA', field: 'sla_oficina', width: '100px' },
|
||||
{ header: 'SLA PÓS-OF.', field: 'sla_pos_oficina', width: '100px' },
|
||||
{ header: 'OBS', field: 'obs', width: '180px' },
|
||||
{ header: 'END. PRESTADOR', field: 'endereco_prestador', width: '180px' },
|
||||
{ header: 'PDF ORÇAMENTO', field: 'pdf_orcamento_link', width: '100px', render: (row) => (row.pdf_orcamento_link || row.pdf_orcamento) ? <a href={row.pdf_orcamento_link || row.pdf_orcamento} target="_blank" rel="noreferrer" className="underline text-blue-500 font-bold">Abrir</a> : '-' },
|
||||
{ header: 'NOTA FISCAL', field: 'nota_fiscal', width: '100px', render: (row) => row.nota_fiscal ? <a href={row.nota_fiscal} target="_blank" rel="noreferrer" className="underline text-blue-500 font-bold">Abrir</a> : '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 min-h-screen text-slate-700 dark:text-slate-200 transition-colors">
|
||||
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium text-white uppercase tracking-tighter flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500 rounded-xl shadow-lg shadow-emerald-500/20">
|
||||
<History size={24} className="text-white" />
|
||||
</div>
|
||||
Pendências
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium mt-1">Gestão de validação e pagamentos de manutenção</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||||
<input
|
||||
className="w-full bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2.5 rounded-xl text-sm focus:outline-none focus:border-emerald-500 transition-all shadow-sm"
|
||||
placeholder="Buscar por ID, placa ou oficina..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-100 dark:bg-[#141414] border border-slate-200 dark:border-[#2a2a2a] w-full justify-start p-1 h-auto mb-6 rounded-xl">
|
||||
<TabsTrigger value="pendente-aprovacao" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 text-[10px] uppercase font-bold px-6 py-2.5 rounded-lg transition-all">
|
||||
Pendente Aprovação
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pendente-pagamento" className="data-[state=active]:bg-amber-600 data-[state=active]:text-white text-slate-500 text-[10px] uppercase font-bold px-6 py-2.5 rounded-lg transition-all">
|
||||
Pendente Pagamento
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="validado" className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-slate-500 text-[10px] uppercase font-bold px-6 py-2.5 rounded-lg transition-all">
|
||||
Validados
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-none">
|
||||
<React.Suspense fallback={<div className="p-12 text-center"><LoadingOverlay isLoading={true} variant="minimal" /></div>}>
|
||||
<SmartTable
|
||||
data={filteredMaintenances}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
onRowClick={handleOpenDetail}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||
<DialogContent className="w-[95vw] max-w-4xl h-[75vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none text-slate-700 dark:text-slate-200">
|
||||
<LoadingOverlay isLoading={isSubmitting} message="Processando..." variant="minimal" />
|
||||
<DialogHeader className="p-6 border-b border-slate-100 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-emerald-500/10 rounded-2xl text-emerald-500 shadow-inner">
|
||||
<Wrench size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-bold text-slate-900 dark:text-white uppercase tracking-tight">
|
||||
Validação #{selectedMaintenance?.idmanutencao_frota}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Veículo: <span className="text-emerald-500 font-bold">{selectedMaintenance?.placa}</span> | Oficina: {selectedMaintenance?.oficina}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="detalhes" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#0f0f0f] shrink-0">
|
||||
<TabsList className="bg-transparent border-0 w-fit p-0 h-auto gap-6">
|
||||
<TabsTrigger
|
||||
value="detalhes"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:text-emerald-600 dark:data-[state=active]:text-emerald-400 data-[state=active]:border-emerald-600 dark:data-[state=active]:border-emerald-400 text-slate-500 border-b-2 border-transparent rounded-none px-0 py-4 text-[10px] uppercase font-bold tracking-wider"
|
||||
>
|
||||
Dados Gerais
|
||||
</TabsTrigger>
|
||||
{(selectedMaintenance?.pdf_orcamento || selectedMaintenance?.nota_fiscal) && (
|
||||
<TabsTrigger
|
||||
value="documentos"
|
||||
className="data-[state=active]:bg-transparent data-[state=active]:text-emerald-600 dark:data-[state=active]:text-emerald-400 data-[state=active]:border-emerald-600 dark:data-[state=active]:border-emerald-400 text-slate-500 border-b-2 border-transparent rounded-none px-0 py-4 text-[10px] uppercase font-bold tracking-wider flex items-center gap-1.5"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Documentos
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="detalhes" className="flex-1 overflow-y-auto custom-scrollbar m-0 p-6 space-y-8 bg-white dark:bg-[#151515]">
|
||||
<DetailSection title="Identificação e veículo">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">ID Manutenção</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.idmanutencao_frota ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Atendimento</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.atendimento ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Placa</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{selectedMaintenance?.placa ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Placa Reserva</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.placa_reserva ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Modelo</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.modelo ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Proprietário</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.proprietario ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Base Frota</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.base_frota ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Manutenção (Situação)</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.manutencao ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Status e oficina">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Status</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.status ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Status Frota Veículo</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.status_frota_veiculo ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Motivo Atendimento</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.motivo_atendimento ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Oficina</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.oficina ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Localização">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Endereço Prestador</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.endereco_prestador ?? '-'}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Cidade</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.cidade ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">UF</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.uf ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Responsáveis e financeiro">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Responsável</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.responsavel ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Resp. Aprovação</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.resp_aprovacao ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Validação Financeiro</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.validacao_financeiro ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Cond. Pagamento</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">
|
||||
{selectedMaintenance?.condicao_pagamento ?? '-'}
|
||||
{selectedMaintenance?.condicao_pagamento === 'Parcelado' && selectedMaintenance?.qtd_parcelas_condicao_pag && (
|
||||
<span className="ml-1 text-emerald-500">({selectedMaintenance?.qtd_parcelas_condicao_pag}x)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Orçamentos">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Orçamento Inicial</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance?.orcamento_inicial)}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Orçamento Final</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance?.orcamento_final)}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Dif. Orçamento</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance?.dif_orcamento)}</p>
|
||||
</div>
|
||||
{selectedMaintenance?.condicao_pagamento === 'Parcelado' && selectedMaintenance?.qtd_parcelas_condicao_pag && (
|
||||
<div className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-emerald-600 dark:text-emerald-400 tracking-wider">Valor da Parcela</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400 mt-1 font-mono">
|
||||
{(() => {
|
||||
const total = parseCurrency(selectedMaintenance?.orcamento_final);
|
||||
const parcelas = parseInt(selectedMaintenance?.qtd_parcelas_condicao_pag, 10);
|
||||
if (total && parcelas > 0) return formatCurrency(total / parcelas);
|
||||
return 'R$ 0,00';
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FileLink label="PDF Orçamento" url={selectedMaintenance?.pdf_orcamento_link || selectedMaintenance?.pdf_orcamento} />
|
||||
<FileLink label="Nota Fiscal" url={selectedMaintenance?.nota_fiscal} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Datas">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Data Solicitação</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.data_solicitacao ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Data Agendamento</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.data_agendamento ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Data Parada</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.data_parada_veiculo ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Previsão Entrega</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.previsao_entrega ?? selectedMaintenance?.previcao_entrega ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Data Finalização</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.data_finalizacao ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Data Retirada</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.data_retirada ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* <DetailSection title="SLA">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">SLA Oficina</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.sla_oficina ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">SLA Pós-Oficina</label>
|
||||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">{selectedMaintenance?.sla_pos_oficina ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection> */}
|
||||
|
||||
<DetailSection title="Descrição e observações">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Descrição da Manutenção</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance?.manutencao ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Observações do Monitoramento</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance?.obs ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Observações da Manutenção</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance?.obs_manutencao ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
|
||||
<DetailSection title="Validação e Retorno">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DarkSelect
|
||||
label="Validação Financeiro"
|
||||
options={validacaoOptions}
|
||||
value={validacaoFinanceiro}
|
||||
onChange={setValidacaoFinanceiro}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider ml-1">Observação do Monitoramento</label>
|
||||
<div className="w-full bg-slate-50 dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl px-3 py-2 text-sm text-slate-500 min-h-[60px]">
|
||||
{selectedMaintenance?.obs || 'Sem observações'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observação Financeira (Obrigatório para retorno)</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl px-4 py-3 text-sm text-slate-800 dark:text-white focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-600 min-h-[100px]"
|
||||
placeholder="Caso precise retornar, descreva o motivo aqui..."
|
||||
value={obsFinanceiro}
|
||||
onChange={(e) => setObsFinanceiro(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentos" className="flex-1 overflow-hidden flex flex-col m-0 pt-2 p-6 bg-white dark:bg-[#151515]">
|
||||
{selectedMaintenance && (selectedMaintenance.pdf_orcamento || selectedMaintenance.nota_fiscal) && (
|
||||
<Tabs defaultValue={selectedMaintenance.pdf_orcamento ? "pdf" : "nota"} className="w-full h-full flex flex-col">
|
||||
<TabsList className="bg-slate-100/50 dark:bg-emerald-500/5 border border-slate-200 dark:border-emerald-500/20 w-fit p-1 h-auto mb-4 shrink-0 self-center md:self-start">
|
||||
{selectedMaintenance.pdf_orcamento && (
|
||||
<TabsTrigger
|
||||
value="pdf"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
>
|
||||
PDF Orçamento
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{selectedMaintenance.nota_fiscal && (
|
||||
<TabsTrigger
|
||||
value="nota"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
>
|
||||
Nota Fiscal
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pdf" className="flex-1 overflow-hidden m-0">
|
||||
<div className="w-full h-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-2xl overflow-hidden flex flex-col shadow-sm">
|
||||
<div className="p-3 border-b border-slate-200 dark:border-[#333] flex items-center justify-between bg-white dark:bg-[#1a1a1a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-emerald-500" />
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Visualização: PDF Orçamento</span>
|
||||
</div>
|
||||
<a href={selectedMaintenance.pdf_orcamento} target="_blank" rel="noreferrer" className="text-[10px] uppercase font-bold text-blue-500 hover:underline">Abrir em nova aba</a>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-100 dark:bg-[#0a0a0a]">
|
||||
{isInternalFileUrl(selectedMaintenance.pdf_orcamento) ? (
|
||||
<iframe src={selectedMaintenance.pdf_orcamento} title="PDF Orçamento" className="w-full h-full border-0" />
|
||||
) : (
|
||||
<div className="p-8 flex flex-col items-center justify-center h-full text-center space-y-4">
|
||||
<div className="p-6 bg-blue-500/10 rounded-full text-blue-500"><Search size={48} /></div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-slate-800 dark:text-white">Documento Externo</h4>
|
||||
<p className="text-xs text-slate-500 max-w-xs mx-auto">Este arquivo está hospedado em um servidor externo e não pode ser visualizado diretamente aqui por segurança.</p>
|
||||
<a
|
||||
href={selectedMaintenance.pdf_orcamento}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 mt-4"
|
||||
>
|
||||
Acessar Documento
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="nota" className="flex-1 overflow-hidden m-0">
|
||||
<div className="w-full h-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-2xl overflow-hidden flex flex-col shadow-sm">
|
||||
<div className="p-3 border-b border-slate-200 dark:border-[#333] flex items-center justify-between bg-white dark:bg-[#1a1a1a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-emerald-500" />
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Visualização: Nota Fiscal</span>
|
||||
</div>
|
||||
<a href={selectedMaintenance.nota_fiscal} target="_blank" rel="noreferrer" className="text-[10px] uppercase font-bold text-blue-500 hover:underline">Abrir em nova aba</a>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-100 dark:bg-[#0a0a0a]">
|
||||
{isInternalFileUrl(selectedMaintenance.nota_fiscal) ? (
|
||||
<iframe src={selectedMaintenance.nota_fiscal} title="Nota Fiscal" className="w-full h-full border-0" />
|
||||
) : (
|
||||
<div className="p-8 flex flex-col items-center justify-center h-full text-center space-y-4">
|
||||
<div className="p-6 bg-blue-500/10 rounded-full text-blue-500"><Search size={48} /></div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-bold text-slate-800 dark:text-white">Documento Externo</h4>
|
||||
<p className="text-xs text-slate-500 max-w-xs mx-auto">Este arquivo está hospedado em um servidor externo e não pode ser visualizado diretamente aqui por segurança.</p>
|
||||
<a
|
||||
href={selectedMaintenance.nota_fiscal}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 mt-4"
|
||||
>
|
||||
Acessar Documento
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="p-6 border-t border-slate-100 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#0f0f0f] flex flex-wrap gap-3 sm:justify-between">
|
||||
<DarkButton variant="ghost" onClick={() => setIsDetailOpen(false)}>Cancelar</DarkButton>
|
||||
<div className="flex gap-3">
|
||||
<DarkButton variant="secondary" onClick={handleReturn} className="border-rose-500/30 text-rose-500 hover:bg-rose-500/10">
|
||||
<AlertTriangle size={16} />
|
||||
Retornar para Análise
|
||||
</DarkButton>
|
||||
<DarkButton variant="primary" onClick={handleApprove}>
|
||||
<CheckCircle size={16} />
|
||||
Aprovado (Validar)
|
||||
</DarkButton>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,118 +2,111 @@ import React, { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Lock, Mail, Truck } from 'lucide-react';
|
||||
import { ArrowRight, Lock, Mail } from 'lucide-react';
|
||||
import { useDocumentMetadata } from '@/hooks/useDocumentMetadata';
|
||||
import logoOestePan from '@/assets/Img/Util/Oeste_Pan/Logo Oeste Pan.png';
|
||||
|
||||
export default function LoginView() {
|
||||
useDocumentMetadata('Login | Prafrota', 'prafrot');
|
||||
useDocumentMetadata('Login | Oeste Pan', 'oest-pan');
|
||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||
const { login, loading, error } = useAuthContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
// Simulate login for prafrot environment
|
||||
const success = await login(formData, 'prafrot');
|
||||
// Note: 'prafrot' might need to be added to authorized environments in useAuth or mocked
|
||||
// Login for auth_oestepan environment
|
||||
const success = await login(formData, 'auth_oestepan');
|
||||
if (success) {
|
||||
navigate('/plataforma/prafrot/estatisticas');
|
||||
navigate('/plataforma/oest-pan/estatisticas');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#141414] flex items-center justify-center p-4" style={{ fontFamily: 'var(--font-main)' }}>
|
||||
<div className="w-full max-w-5xl h-[600px] flex shadow-2xl rounded-3xl overflow-hidden border border-[#2a2a2a]">
|
||||
|
||||
|
||||
{/* Visual Side */}
|
||||
<div className="hidden md:flex flex-1 bg-[#1c1c1c] relative items-center justify-center p-12">
|
||||
<div className="absolute inset-0 bg-emerald-500/5 mix-blend-overlay" />
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://images.unsplash.com/photo-1592838064575-70ed431fb924?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-10" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-24 h-24 bg-emerald-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl shadow-emerald-500/20"
|
||||
>
|
||||
<Truck size={40} className="text-[#141414]" strokeWidth={2.5} />
|
||||
</motion.div>
|
||||
<h1 className="text-4xl font-medium text-white tracking-tighter mb-2">PRA <span className="text-emerald-500">FROTA</span></h1>
|
||||
<p className="text-slate-500 font-medium tracking-widest uppercase text-xs">Gestão Inteligente de Ativos</p>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-orange-500/5 mix-blend-overlay" />
|
||||
<div className="absolute top-0 left-0 w-full h-full bg-[url('https://images.unsplash.com/photo-1592838064575-70ed431fb924?q=80&w=2070&auto=format&fit=crop')] bg-cover bg-center opacity-10" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<img src={logoOestePan} alt="Oeste Pan Logo" className="w-64 h-auto drop-shadow-2xl" />
|
||||
</motion.div>
|
||||
<h1 className="text-4xl font-bold text-white tracking-tighter mb-2">Oeste <span className="text-orange-500">Pan</span></h1>
|
||||
<p className="text-slate-500 font-medium tracking-widest uppercase text-xs">Gestão Inteligente de Ativos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Side */}
|
||||
<div className="flex-1 bg-[#18181b] flex items-center justify-center p-10">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="text-center md:text-left">
|
||||
<h2 className="text-2xl font-medium text-white mb-2">Acesso ao Monitoramento</h2>
|
||||
<p className="text-slate-400 text-sm">Entre com suas credenciais de gestor.</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
<div className="text-center md:text-left">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Acesso ao Monitoramento</h2>
|
||||
<p className="text-slate-400 text-sm">Entre com suas credenciais de gestor.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="gestor@prafrota.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({...formData, email: e.target.value})}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="gestor@Oeste_Pan.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-500 uppercase ml-1">Senha</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={18} />
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={e => setFormData({...formData, password: e.target.value})}
|
||||
className="w-full bg-[#27272a] border border-[#3f3f46] text-slate-200 text-sm rounded-xl px-11 py-3 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-600"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-bold rounded-lg text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 text-red-500 text-xs font-bold rounded-lg text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-emerald-500 hover:bg-emerald-400 text-[#141414] font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-emerald-500/10 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? 'Acessando...' : <>Acessar Painel <ArrowRight size={18} /></>}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-400 text-[#141414] font-bold py-3.5 rounded-xl transition-all shadow-lg shadow-orange-500/10 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? 'Acessando...' : <>Acessar Painel <ArrowRight size={18} /></>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="pt-4 flex flex-col items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/plataforma/solicitacao-viagem')}
|
||||
className="px-6 py-2 bg-[#27272a] hover:bg-[#3f3f46] border border-[#3f3f46] rounded-full text-[9px] font-bold text-slate-400 hover:text-emerald-400 uppercase tracking-[0.2em] transition-all"
|
||||
>
|
||||
<span className="flex items-center gap-2">🚀 Abrir Solicitações</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="text-center">
|
||||
<span className="text-xs text-slate-600 font-medium">© 2026 Prafrot System</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="text-center">
|
||||
<span className="text-xs text-slate-600 font-medium">© 2024 Oeste Pan System v2.0</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ import { useMaintenance } from '../hooks/useMaintenance';
|
|||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useWorkshops } from '../hooks/useWorkshops';
|
||||
import { useFleetLists } from '../hooks/useFleetLists';
|
||||
const ItemDetailPanel = React.lazy(() => import('@/components/shared/ItemDetailPanel').then(module => ({ default: module.ItemDetailPanel })));
|
||||
const SmartTable = React.lazy(() => import('@/features/dev-tools/components/SmartTable')); // Universal Connector
|
||||
import AutocompleteInput from '../components/AutocompleteInput';
|
||||
import { Plus, Search, Edit2, Trash2, Wrench, CheckCircle, Truck, Lock, Unlock, FileText, Database } from 'lucide-react';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { Plus, Search, Edit2, Trash2, Wrench, CheckCircle, Truck, Lock, Unlock, FileText } from 'lucide-react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription
|
||||
} from "@/components/ui/dialog";
|
||||
|
|
@ -76,21 +75,19 @@ const DarkInput = ({ label, readOnly, className = '', ...props }) => (
|
|||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
readOnly={readOnly}
|
||||
className={`w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 ${readOnly ? 'cursor-not-allowed opacity-80 bg-slate-100 dark:bg-[#1a1a1a]' : ''} ${className}`}
|
||||
className={`w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 ${readOnly ? 'cursor-not-allowed opacity-80 bg-slate-100 dark:bg-[#1a1a1a]' : ''} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DarkSelect = ({ label, options, value, onChange, disabled, className = '', ...props }) => (
|
||||
const DarkSelect = ({ label, options, value, onChange }) => (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed bg-slate-100 dark:bg-[#1a1a1a]' : ''} ${className}`}
|
||||
{...props}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -101,9 +98,9 @@ const DarkSelect = ({ label, options, value, onChange, disabled, className = '',
|
|||
);
|
||||
|
||||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-semibold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -126,7 +123,7 @@ const CurrencyInput = ({ label, value, onChange, ...props }) => {
|
|||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
placeholder="R$ 0,00"
|
||||
value={display}
|
||||
onFocus={() => { setFocused(true); setLocal(isEmpty ? '' : formatCurrencyForInput(value)); }}
|
||||
|
|
@ -169,7 +166,7 @@ const MaintenanceStatusCell = ({ currentStatus, id, options, onUpdate }) => {
|
|||
value={tempValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-emerald-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-orange-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
|
|
@ -185,8 +182,8 @@ const MaintenanceStatusCell = ({ currentStatus, id, options, onUpdate }) => {
|
|||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded cursor-pointer hover:opacity-80 transition-opacity text-[9px] font-bold uppercase tracking-wider border ${
|
||||
currentStatus?.includes('Pendente') ? 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20' :
|
||||
currentStatus === 'Aprovado' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
currentStatus === 'Concluído' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
currentStatus === 'Aprovado' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
currentStatus === 'Concluído' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-slate-700/30 text-slate-400 border-slate-600/30'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -230,7 +227,7 @@ const isInternalFileUrl = (url) => {
|
|||
|
||||
const DetailSection = ({ title, children }) => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-bold text-emerald-600 dark:text-emerald-500 uppercase tracking-[0.2em] border-b border-emerald-500/10 pb-2">{title}</h3>
|
||||
<h3 className="text-xs font-bold text-orange-600 dark:text-orange-500 uppercase tracking-[0.2em] border-b border-orange-500/10 pb-2">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -240,14 +237,14 @@ function FileLink({ label, url }) {
|
|||
const isInternal = isInternalFileUrl(url);
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4 flex flex-col gap-1 transition-all hover:border-emerald-500/30 group">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4 flex flex-col gap-1 transition-all hover:border-orange-500/30 group">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider font-mono block mb-0.5">{label}</label>
|
||||
{isInternal ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 underline font-semibold text-sm inline-flex items-center gap-1.5"
|
||||
className="text-blue-500 hover:text-blue-600 underline font-bold text-sm inline-flex items-center gap-1.5"
|
||||
>
|
||||
<FileText size={14} className="group-hover:scale-110 transition-transform" />
|
||||
Abrir documento
|
||||
|
|
@ -257,7 +254,7 @@ function FileLink({ label, url }) {
|
|||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 underline font-semibold text-xs break-all line-clamp-2"
|
||||
className="text-blue-500 hover:text-blue-600 underline font-bold text-xs break-all line-clamp-2"
|
||||
title={url}
|
||||
>
|
||||
{url}
|
||||
|
|
@ -289,7 +286,6 @@ export default function MaintenanceView() {
|
|||
fecharManutencao,
|
||||
abrirManutencao,
|
||||
getAbertoFechado,
|
||||
abertoFechadoData,
|
||||
getHistoricoCompleto,
|
||||
getHistoricoDetalhado,
|
||||
getHistoricoEstatisticas,
|
||||
|
|
@ -301,7 +297,6 @@ export default function MaintenanceView() {
|
|||
const { fetchListsConfig, statusManutencaoOptions, motivoAtendimentoOptions, responsaveisOptions, validacaoOptions, aprovacaoOptions } = useFleetLists();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('total'); // 'total', 'fechada', 'aberta'
|
||||
const [viewMode, setViewMode] = useState('maintenance'); // 'maintenance', 'payment_pending'
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [statusStats, setStatusStats] = useState([]);
|
||||
|
|
@ -318,13 +313,14 @@ export default function MaintenanceView() {
|
|||
});
|
||||
const [loadingHistorico, setLoadingHistorico] = useState(false);
|
||||
const [topMaintenances, setTopMaintenances] = useState([]);
|
||||
const [abertoFechadoData, setAbertoFechadoData] = useState(null);
|
||||
|
||||
const initialFormState = {
|
||||
ano_entrada: '', ano_saida: '', atendimento: '', base_frota: '', cidade: '',
|
||||
condicao_pagamento: '', data_agendamento: '', data_finalizacao: '', data_parada_veiculo: '',
|
||||
data_retirada: '', data_solicitacao: '', dif_orcamento: '', endereco_prestador: '',
|
||||
idmanutencao_frota: '', manutencao: '', mes_entrada: '', mes_saida: '', modelo: '',
|
||||
motivo_atendimento: '', obs: '', obs_financeiro: '', obs_manutencao: '', oficina: '', orcamento_final: '', orcamento_inicial: '',
|
||||
motivo_atendimento: '', obs: '', oficina: '', orcamento_final: '', orcamento_inicial: '',
|
||||
pdf_orcamento: '', nota_fiscal: '', placa: '', placa_reserva: '', previsao_entrega: '', proprietario: '',
|
||||
resp_aprovacao: '', responsavel: '',
|
||||
status: 'Pendente', uf: '', validacao_financeiro: '', qtd_parcelas_condicao_pag: ''
|
||||
|
|
@ -356,28 +352,24 @@ export default function MaintenanceView() {
|
|||
}, [isModalOpen, formData.orcamento_inicial, formData.orcamento_final]);
|
||||
|
||||
|
||||
// Carrega dados iniciais
|
||||
useEffect(() => {
|
||||
// Se estiver no modo manutenções, usamos o filtro de status (aberta/fechada/total)
|
||||
if (viewMode === 'maintenance') {
|
||||
if (statusFilter === 'total') {
|
||||
fetchMaintenances('total');
|
||||
} else {
|
||||
getAbertoFechado();
|
||||
}
|
||||
// Carrega a rota inicial baseada no filtro
|
||||
if (statusFilter === 'total') {
|
||||
fetchMaintenances('total');
|
||||
} else {
|
||||
// Modo Pendências Pagamento: usa filtro nativo do backend
|
||||
fetchMaintenances('Pendente Pagamento');
|
||||
getAbertoFechado();
|
||||
}
|
||||
|
||||
fetchVehicles();
|
||||
fetchWorkshops();
|
||||
fetchListsConfig();
|
||||
|
||||
// Fetch Status Stats
|
||||
prafrotStatisticsService.getPlacasPorStatus().then(data => {
|
||||
if (Array.isArray(data)) setStatusStats(data);
|
||||
});
|
||||
|
||||
// Top manutenções (visão geral, cards na tela)
|
||||
getHistoricoTop().then(data => {
|
||||
const lista = Array.isArray(data) ? data : (data?.data || []);
|
||||
const comPosicao = lista.map((item, index) => ({
|
||||
|
|
@ -385,8 +377,34 @@ export default function MaintenanceView() {
|
|||
posicao: index + 1,
|
||||
}));
|
||||
setTopMaintenances(comPosicao);
|
||||
}).catch((error) => {
|
||||
console.error('Erro ao carregar top manutenções:', error);
|
||||
});
|
||||
}, [statusFilter, viewMode]);
|
||||
}, [statusFilter]); // Dependência adicionada para recarregar ao trocar o filtro
|
||||
|
||||
// Carrega dados de aberto/fechado quando o filtro não é "total"
|
||||
useEffect(() => {
|
||||
if (statusFilter === 'total') {
|
||||
setAbertoFechadoData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getAbertoFechado();
|
||||
if (!isCancelled) {
|
||||
setAbertoFechadoData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dados de aberto/fechado:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [statusFilter, getAbertoFechado]);
|
||||
|
||||
const getStatusData = (status) => {
|
||||
return statusStats.find(item => {
|
||||
|
|
@ -477,11 +495,6 @@ export default function MaintenanceView() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (formData.status === 'Aprovado' && (!formData.validacao_financeiro || formData.validacao_financeiro.trim() === '')) {
|
||||
notifyMaintenance('warning', 'Validação Necessária', 'Necessidade da validação do financeiro');
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpeza do payload: remove strings vazias que podem causar erro no backend
|
||||
const payload = {};
|
||||
Object.keys(formData).forEach(key => {
|
||||
|
|
@ -563,50 +576,23 @@ export default function MaintenanceView() {
|
|||
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
|
||||
const handleStatusUpdate = async (id, newStatus, extraPayload = null) => {
|
||||
const handleStatusUpdate = async (id, newStatus) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (extraPayload) {
|
||||
// Validação para status Aprovado
|
||||
if (newStatus === 'Aprovado' && (!extraPayload.validacao_financeiro || extraPayload.validacao_financeiro.trim() === '')) {
|
||||
notifyMaintenance('warning', 'Validação Necessária', 'Necessidade da validação do financeiro');
|
||||
return;
|
||||
}
|
||||
// Se temos um payload extra (como observações), usamos a atualização padrão
|
||||
// para garantir que todos os campos sejam salvos, não apenas o status
|
||||
await updateMaintenance(id, { ...extraPayload, status: newStatus });
|
||||
await refreshMaintenances(statusFilter);
|
||||
} else if (selectedIds.includes(id)) {
|
||||
// Update em massa (apenas status)
|
||||
// Na atualização em massa, verificamos todos os itens selecionados
|
||||
if (newStatus === 'Aprovado') {
|
||||
const itemsToUpdate = maintenances.filter(m => selectedIds.includes(m.idmanutencao_frota));
|
||||
const invalidItems = itemsToUpdate.filter(m => !m.validacao_financeiro || m.validacao_financeiro.trim() === '');
|
||||
|
||||
if (invalidItems.length > 0) {
|
||||
notifyMaintenance('warning', 'Validação Necessária', `Os seguintes itens precisam de validação financeira antes de serem aprovados:\n${invalidItems.map(m => m.placa || m.idmanutencao_frota).join(', ')}\n\nNecessidade da validação do financeiro`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Se o item que está sendo editado faz parte da seleção, aplica em massa
|
||||
if (selectedIds.includes(id)) {
|
||||
// Confirmação simples
|
||||
const confirmUpdate = window.confirm(`Você selecionou ${selectedIds.length} itens. Deseja atualizar o status de TODOS eles para "${newStatus}"?`);
|
||||
|
||||
if (confirmUpdate) {
|
||||
const success = await updateMaintenanceBatch(selectedIds, newStatus);
|
||||
if (success) {
|
||||
setSelectedIds([]);
|
||||
setSelectedIds([]); // Limpa a seleção
|
||||
await refreshMaintenances(statusFilter);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update individual simples (apenas status)
|
||||
if (newStatus === 'Aprovado') {
|
||||
const item = maintenances.find(m => m.idmanutencao_frota === id);
|
||||
if (item && (!item.validacao_financeiro || item.validacao_financeiro.trim() === '')) {
|
||||
notifyMaintenance('warning', 'Validação Necessária', 'Necessidade da validação do financeiro');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Single Update (using batch route as requested)
|
||||
await updateMaintenanceBatch([id], newStatus);
|
||||
await refreshMaintenances(statusFilter);
|
||||
}
|
||||
|
|
@ -623,10 +609,8 @@ export default function MaintenanceView() {
|
|||
// - total: usa a rota padrão /manutencao_frota/apresentar
|
||||
// - fechada/aberta: usa /manutencao_frota/aberto_fechado/apresentar (grupos separados)
|
||||
let baseList = maintenances;
|
||||
if (viewMode === 'payment_pending') {
|
||||
// Safety Filter: Garante que apenas Pendente Pagamento apareça neste modo
|
||||
baseList = maintenances.filter(m => (m.status || '').trim() === 'Pendente Pagamento');
|
||||
} else if (statusFilter !== 'total' && abertoFechadoData) {
|
||||
|
||||
if (statusFilter !== 'total' && abertoFechadoData) {
|
||||
const lower = {};
|
||||
Object.keys(abertoFechadoData || {}).forEach((key) => {
|
||||
lower[key.toLowerCase()] = abertoFechadoData[key];
|
||||
|
|
@ -657,7 +641,7 @@ export default function MaintenanceView() {
|
|||
);
|
||||
|
||||
return filtered;
|
||||
}, [maintenances, searchTerm, statusFilter, abertoFechadoData, viewMode]);
|
||||
}, [maintenances, searchTerm, statusFilter, abertoFechadoData]);
|
||||
|
||||
// Get status options for open/close
|
||||
const getOpenStatus = () => {
|
||||
|
|
@ -825,14 +809,14 @@ export default function MaintenanceView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Manutenção de Frota</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Manutenção de Frota</h1>
|
||||
<p className="text-slate-500 text-sm">Controle de oficinas, orçamentos e agendamentos.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto flex-wrap">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar por ID, placa ou oficina..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -908,7 +892,7 @@ export default function MaintenanceView() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400 font-mono">
|
||||
<span className="text-sm font-bold text-orange-600 dark:text-orange-400 font-mono">
|
||||
{item.placa || '-'}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
|
|
@ -926,71 +910,36 @@ export default function MaintenanceView() {
|
|||
)}
|
||||
|
||||
{/* Seletor de Filtro Aberto/Fechado - Destacado acima da tabela */}
|
||||
<div className="bg-gradient-to-r from-emerald-500/10 via-blue-500/10 to-emerald-500/10 border-2 border-emerald-500/30 dark:border-emerald-500/20 rounded-2xl p-4 shadow-lg">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="bg-gradient-to-r from-orange-500/10 via-blue-500/10 to-orange-500/10 border-2 border-orange-500/30 dark:border-orange-500/20 rounded-2xl p-4 shadow-lg">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/20 rounded-xl">
|
||||
<Database size={20} className="text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="p-2 bg-orange-500/20 rounded-xl">
|
||||
<Lock size={20} className="text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300 block mb-1">
|
||||
Visualização de Dados
|
||||
Filtrar por Status de Manutenção
|
||||
</label>
|
||||
<p className="text-[10px] text-slate-500 dark:text-slate-400">
|
||||
Selecione o modo de visualização da tabela
|
||||
Visualize todas as manutenções, apenas as abertas ou apenas as fechadas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Botões de Modo de Visualização */}
|
||||
<div className="flex bg-slate-100 dark:bg-[#141414] p-1 rounded-xl border border-slate-200 dark:border-[#333]">
|
||||
<button
|
||||
onClick={() => setViewMode('maintenance')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'maintenance'
|
||||
? 'bg-emerald-500 text-white shadow-md'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-200/50 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Manutenções
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('payment_pending')}
|
||||
className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'payment_pending'
|
||||
? 'bg-emerald-500 text-white shadow-md'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-200/50 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Pendências Pagamento
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropdown de Status - Apenas visível no modo Manutenção */}
|
||||
{viewMode === 'maintenance' && (
|
||||
<div className="h-8 w-[1px] bg-slate-200 dark:bg-[#333] mx-2 hidden md:block" />
|
||||
)}
|
||||
|
||||
{viewMode === 'maintenance' && (
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="bg-white dark:bg-[#1c1c1c] border-2 border-emerald-500/50 dark:border-emerald-500/30 text-slate-700 dark:text-slate-200 px-6 py-2.5 rounded-xl text-xs font-bold focus:outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/20 cursor-pointer shadow-sm hover:shadow-md transition-all min-w-[150px]"
|
||||
>
|
||||
<option value="total">📊 Total</option>
|
||||
<option value="aberta">🔓 Aberta</option>
|
||||
<option value="fechada">🔒 Fechada</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="bg-white dark:bg-[#1c1c1c] border-2 border-orange-500/50 dark:border-orange-500/30 text-slate-700 dark:text-slate-200 px-6 py-3 rounded-xl text-sm font-bold focus:outline-none focus:border-orange-500 focus:ring-2 focus:ring-orange-500/20 cursor-pointer shadow-md hover:shadow-lg transition-all min-w-[180px]"
|
||||
>
|
||||
<option value="total">📊 Total</option>
|
||||
<option value="aberta">🔓 Aberta</option>
|
||||
<option value="fechada">🔒 Fechada</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px] w-full max-w-full overflow-hidden min-w-0">
|
||||
{/* Usando Conector Universal (SmartTable) conforme solicitado */}
|
||||
<React.Suspense fallback={<div className="flex items-center justify-center h-full"><LoadingOverlay isLoading={true} message="Carregando dados..." variant="minimal" /></div>}>
|
||||
<SmartTable
|
||||
{/* Colunas alinhadas ao contrato: docs/PADROES_ROTAS_APRESENTACAO.md § GET /manutencao_frota/apresentar */}
|
||||
<ExcelTable
|
||||
data={filteredData}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
|
|
@ -998,7 +947,7 @@ export default function MaintenanceView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'idmanutencao_frota', width: '80px' },
|
||||
{ header: 'ATENDIMENTO', field: 'atendimento', width: '100px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'PLACA RESERVA', field: 'placa_reserva', width: '100px' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '110px' },
|
||||
{ header: 'OFICINA', field: 'oficina', width: '160px' },
|
||||
|
|
@ -1024,14 +973,14 @@ export default function MaintenanceView() {
|
|||
{ header: 'PREV. ENTREGA', field: 'previsao_entrega', width: '100px' },
|
||||
{ header: 'DATA FINAL.', field: 'data_finalizacao', width: '100px' },
|
||||
{ header: 'DATA RETIRADA', field: 'data_retirada', width: '100px' },
|
||||
{ header: 'ORÇ. INICIAL', field: 'orcamento_inicial', width: '110px', className: 'font-mono text-emerald-600 dark:text-emerald-400', render: (row) => formatCurrency(row.orcamento_inicial) },
|
||||
{ header: 'ORÇ. FINAL', field: 'orcamento_final', width: '110px', className: 'font-mono text-emerald-600 dark:text-emerald-400', render: (row) => formatCurrency(row.orcamento_final) },
|
||||
{ header: 'ORÇ. INICIAL', field: 'orcamento_inicial', width: '110px', className: 'font-mono text-orange-600 dark:text-orange-400', render: (row) => formatCurrency(row.orcamento_inicial) },
|
||||
{ header: 'ORÇ. FINAL', field: 'orcamento_final', width: '110px', className: 'font-mono text-orange-600 dark:text-orange-400', render: (row) => formatCurrency(row.orcamento_final) },
|
||||
{ header: 'DIF. ORÇ.', field: 'dif_orcamento', width: '100px', className: 'font-mono', render: (row) => formatCurrency(row.dif_orcamento) },
|
||||
{ header: 'COND. PAG.', field: 'condicao_pagamento', width: '120px', render: (row) => (
|
||||
<span>
|
||||
{row.condicao_pagamento}
|
||||
{row.condicao_pagamento === 'Parcelado' && row.qtd_parcelas_condicao_pag && (
|
||||
<span className="ml-1 text-emerald-500 font-bold">({row.qtd_parcelas_condicao_pag}x)</span>
|
||||
<span className="ml-1 text-orange-500 font-bold">({row.qtd_parcelas_condicao_pag}x)</span>
|
||||
)}
|
||||
</span>
|
||||
)},
|
||||
|
|
@ -1057,7 +1006,6 @@ export default function MaintenanceView() {
|
|||
// onEdit={handleOpenModal}
|
||||
// onDelete={(item) => deleteMaintenance(item.idmanutencao_frota)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
|
|
@ -1079,9 +1027,9 @@ export default function MaintenanceView() {
|
|||
<TabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
>
|
||||
{tab === 'basicos' ? 'Básico' : tab === 'orcamentos' ? 'Orçamentos' : 'Datas'}
|
||||
{tab}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
|
@ -1148,7 +1096,7 @@ export default function MaintenanceView() {
|
|||
label="Dif. Orçamento"
|
||||
readOnly
|
||||
value={formData.dif_orcamento === '' || formData.dif_orcamento == null ? '' : formatCurrency(formData.dif_orcamento)}
|
||||
className="font-mono text-emerald-600 dark:text-emerald-400"
|
||||
className="font-mono text-orange-600 dark:text-orange-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1205,9 +1153,9 @@ export default function MaintenanceView() {
|
|||
}}
|
||||
/>
|
||||
{valorParcela != null && (
|
||||
<div className="bg-emerald-500/10 dark:bg-emerald-500/20 border border-emerald-500/30 rounded-xl px-3 py-2 flex flex-col justify-end">
|
||||
<div className="bg-orange-500/10 dark:bg-orange-500/20 border border-orange-500/30 rounded-xl px-3 py-2 flex flex-col justify-end">
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Valor por parcela</span>
|
||||
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-400 font-mono mt-0.5">{formatCurrency(valorParcela)}</p>
|
||||
<p className="text-sm font-bold text-orange-700 dark:text-orange-400 font-mono mt-0.5">{formatCurrency(valorParcela)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1217,15 +1165,12 @@ export default function MaintenanceView() {
|
|||
})()}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{editingItem && (
|
||||
<DarkSelect
|
||||
label="Validação Financeiro"
|
||||
options={validacaoOptions}
|
||||
value={formData.validacao_financeiro}
|
||||
onChange={v => setFormData({...formData, validacao_financeiro: v})}
|
||||
disabled={true} // Bloqueado estritamente conforme solicitado
|
||||
/>
|
||||
)}
|
||||
<DarkSelect
|
||||
label="Validação Financeiro"
|
||||
options={validacaoOptions}
|
||||
value={formData.validacao_financeiro}
|
||||
onChange={v => setFormData({...formData, validacao_financeiro: v})}
|
||||
/>
|
||||
<DarkSelect
|
||||
label="Resp. Aprovação"
|
||||
options={aprovacaoOptions}
|
||||
|
|
@ -1239,7 +1184,7 @@ export default function MaintenanceView() {
|
|||
<input
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
className="w-full text-sm text-slate-600 dark:text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:bg-emerald-500/20 file:text-emerald-600 dark:file:text-emerald-400 file:font-bold file:cursor-pointer hover:file:bg-emerald-500/30"
|
||||
className="w-full text-sm text-slate-600 dark:text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:bg-orange-500/20 file:text-orange-600 dark:file:text-orange-400 file:font-bold file:cursor-pointer hover:file:bg-orange-500/30"
|
||||
onChange={(e) => setFilePdfOrcamento(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{(filePdfOrcamento || formData.pdf_orcamento) && (
|
||||
|
|
@ -1259,7 +1204,7 @@ export default function MaintenanceView() {
|
|||
<input
|
||||
type="file"
|
||||
accept=".pdf,application/pdf,image/*"
|
||||
className="w-full text-sm text-slate-600 dark:text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:bg-emerald-500/20 file:text-emerald-600 dark:file:text-emerald-400 file:font-bold file:cursor-pointer hover:file:bg-emerald-500/30"
|
||||
className="w-full text-sm text-slate-600 dark:text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:bg-orange-500/20 file:text-orange-600 dark:file:text-orange-400 file:font-bold file:cursor-pointer hover:file:bg-orange-500/30"
|
||||
onChange={(e) => setFileNotaFiscal(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{(fileNotaFiscal || formData.nota_fiscal) && (
|
||||
|
|
@ -1315,7 +1260,7 @@ export default function MaintenanceView() {
|
|||
<div className="gap-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-400 tracking-wider ml-1">Descrição da Manutenção</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 min-h-[60px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[60px]"
|
||||
value={formData.manutencao}
|
||||
onChange={e => setFormData({...formData, manutencao: e.target.value})}
|
||||
/>
|
||||
|
|
@ -1323,55 +1268,15 @@ export default function MaintenanceView() {
|
|||
<div className="gap-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-400 tracking-wider ml-1">Observações</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 min-h-[60px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[60px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Campos de Observação Financeira e de Manutenção - Visíveis apenas para itens em Pendente Pagamento durante edição */}
|
||||
{editingItem && formData.status === 'Pendente Pagamento' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="gap-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-400 tracking-wider ml-1">Obs. Financeiro</label>
|
||||
<textarea
|
||||
readOnly
|
||||
className="w-full bg-amber-500/5 dark:bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none transition-all min-h-[60px]"
|
||||
value={formData.obs_financeiro}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gap-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-stone-400 tracking-wider ml-1">Obs. Manutenção</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 min-h-[60px]"
|
||||
value={formData.obs_manutencao}
|
||||
onChange={e => setFormData({...formData, obs_manutencao: e.target.value})}
|
||||
placeholder="Observações internas da manutenção..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="bg-slate-50 dark:bg-[#1c1c1c] border-t border-slate-200 dark:border-[#2a2a2a] p-4 gap-2">
|
||||
{editingItem && editingItem.status === 'Pendente Pagamento' && (
|
||||
<DarkButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border-emerald-500/30 text-emerald-500 hover:bg-emerald-500/10 mr-auto"
|
||||
onClick={async () => {
|
||||
if (window.confirm('Deseja retornar esta manutenção para "Pendente Aprovação"?')) {
|
||||
// Enviamos o formData completo para não perder a obs_manutencao
|
||||
await handleStatusUpdate(editingItem.idmanutencao_frota, 'Pendente Aprovação', formData);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retornar para Aprovação
|
||||
</DarkButton>
|
||||
)}
|
||||
<DarkButton type="button" variant="ghost" onClick={() => setIsModalOpen(false)}>Cancelar</DarkButton>
|
||||
<DarkButton type="submit" onClick={handleSubmit}>
|
||||
{editingItem ? 'Salvar Alterações' : 'Criar Solicitação'}
|
||||
|
|
@ -1387,7 +1292,7 @@ export default function MaintenanceView() {
|
|||
<DialogHeader className="p-4 md:p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div className="flex items-start md:items-center gap-3 md:gap-4">
|
||||
<div className="p-3 md:p-4 bg-emerald-500/10 rounded-2xl text-emerald-600 shadow-inner shrink-0">
|
||||
<div className="p-3 md:p-4 bg-orange-500/10 rounded-2xl text-orange-600 shadow-inner shrink-0">
|
||||
<Wrench size={24} className="md:w-7 md:h-7" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -1396,7 +1301,7 @@ export default function MaintenanceView() {
|
|||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-xs md:text-sm flex flex-wrap items-center gap-y-1 gap-x-2">
|
||||
<span className="whitespace-nowrap">
|
||||
Placa: <span className="text-emerald-500 font-bold">{selectedMaintenance?.placa}</span>
|
||||
Placa: <span className="text-orange-500 font-bold">{selectedMaintenance?.placa}</span>
|
||||
</span>
|
||||
<span className="hidden md:inline text-slate-400">|</span>
|
||||
<span className="whitespace-nowrap">
|
||||
|
|
@ -1410,7 +1315,7 @@ export default function MaintenanceView() {
|
|||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${
|
||||
(selectedMaintenance.manutencao || '').toLowerCase().startsWith('abert')
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
||||
? 'bg-orange-500/10 text-orange-400 border border-orange-500/30'
|
||||
: (selectedMaintenance.manutencao || '').toLowerCase().startsWith('fech')
|
||||
? 'bg-slate-500/10 text-slate-300 border border-slate-500/40'
|
||||
: 'bg-slate-700/20 text-slate-300 border border-slate-600/40'
|
||||
|
|
@ -1437,6 +1342,17 @@ export default function MaintenanceView() {
|
|||
return <><Lock size={14} /> Fechar Manutenção</>;
|
||||
})()}
|
||||
</DarkButton>
|
||||
<DarkButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenModal(selectedMaintenance);
|
||||
setIsDetailPanelOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 h-9 text-xs md:text-sm px-3"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
Editar
|
||||
</DarkButton>
|
||||
<DarkButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
|
|
@ -1450,17 +1366,6 @@ export default function MaintenanceView() {
|
|||
<Trash2 size={14} />
|
||||
Excluir
|
||||
</DarkButton>
|
||||
<DarkButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenModal(selectedMaintenance);
|
||||
setIsDetailPanelOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 h-9 text-xs md:text-sm px-3"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
Editar
|
||||
</DarkButton>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
|
@ -1468,26 +1373,26 @@ export default function MaintenanceView() {
|
|||
<div className="flex-1 overflow-hidden p-6 bg-white dark:bg-[#1c1c1c]">
|
||||
<Tabs defaultValue="detalhes" className="w-full h-full flex flex-col">
|
||||
<TabsList className="bg-slate-100 dark:bg-[#141414] border border-slate-200 dark:border-[#2a2a2a] w-full justify-start p-1 h-auto mb-4 shrink-0">
|
||||
<TabsTrigger value="detalhes" className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2">
|
||||
<TabsTrigger value="detalhes" className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2">
|
||||
Detalhes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="completo"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
onClick={() => handleLoadHistorico('completo')}
|
||||
>
|
||||
Histórico Completo
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="detalhado"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
onClick={() => handleLoadHistorico('detalhado')}
|
||||
>
|
||||
Histórico Detalhado
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="estatisticas"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
onClick={() => handleLoadHistorico('estatisticas')}
|
||||
>
|
||||
Estatísticas
|
||||
|
|
@ -1495,7 +1400,7 @@ export default function MaintenanceView() {
|
|||
{(selectedMaintenance?.pdf_orcamento || selectedMaintenance?.nota_fiscal) && (
|
||||
<TabsTrigger
|
||||
value="documentos"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2 flex items-center gap-1.5"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2 flex items-center gap-1.5"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Documentos
|
||||
|
|
@ -1518,7 +1423,7 @@ export default function MaintenanceView() {
|
|||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Placa</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{selectedMaintenance.placa ?? '-'}</p>
|
||||
<p className="text-sm font-bold text-orange-600 dark:text-orange-500 mt-1 font-mono">{selectedMaintenance.placa ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Placa Reserva</label>
|
||||
|
|
@ -1602,7 +1507,7 @@ export default function MaintenanceView() {
|
|||
<p className="text-sm font-bold text-slate-800 dark:text-white mt-1">
|
||||
{selectedMaintenance.condicao_pagamento ?? '-'}
|
||||
{selectedMaintenance.condicao_pagamento === 'Parcelado' && selectedMaintenance.qtd_parcelas_condicao_pag && (
|
||||
<span className="ml-1 text-emerald-500">({selectedMaintenance.qtd_parcelas_condicao_pag}x)</span>
|
||||
<span className="ml-1 text-orange-500">({selectedMaintenance.qtd_parcelas_condicao_pag}x)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1613,20 +1518,20 @@ export default function MaintenanceView() {
|
|||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Orçamento Inicial</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.orcamento_inicial)}</p>
|
||||
<p className="text-sm font-bold text-orange-600 dark:text-orange-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.orcamento_inicial)}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Orçamento Final</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.orcamento_final)}</p>
|
||||
<p className="text-sm font-bold text-orange-600 dark:text-orange-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.orcamento_final)}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Dif. Orçamento</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.dif_orcamento)}</p>
|
||||
<p className="text-sm font-bold text-orange-600 dark:text-orange-500 mt-1 font-mono">{formatCurrency(selectedMaintenance.dif_orcamento)}</p>
|
||||
</div>
|
||||
{selectedMaintenance.condicao_pagamento === 'Parcelado' && selectedMaintenance.qtd_parcelas_condicao_pag && (
|
||||
<div className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-emerald-600 dark:text-emerald-400 tracking-wider">Valor da Parcela</label>
|
||||
<p className="text-sm font-bold text-emerald-600 dark:text-emerald-400 mt-1 font-mono">
|
||||
<div className="bg-orange-500/5 dark:bg-orange-500/10 border border-orange-500/20 rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-orange-600 dark:text-orange-400 tracking-wider">Valor da Parcela</label>
|
||||
<p className="text-sm font-bold text-orange-600 dark:text-orange-400 mt-1 font-mono">
|
||||
{(() => {
|
||||
const total = parseCurrency(selectedMaintenance.orcamento_final);
|
||||
const parcelas = parseInt(selectedMaintenance.qtd_parcelas_condicao_pag, 10);
|
||||
|
|
@ -1693,14 +1598,6 @@ export default function MaintenanceView() {
|
|||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Observações</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance.obs ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Observação de Manutenção</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance.obs_manutencao ?? '-'}</p>
|
||||
</div>
|
||||
<div className="bg-amber-500/5 dark:bg-amber-500/10 border border-amber-500/20 rounded-xl p-4">
|
||||
<label className="text-[10px] uppercase font-bold text-amber-600 dark:text-amber-400 tracking-wider">Observação Financeira</label>
|
||||
<p className="text-sm text-slate-800 dark:text-white mt-1 whitespace-pre-wrap">{selectedMaintenance.obs_financeiro ?? '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
|
@ -1723,9 +1620,9 @@ export default function MaintenanceView() {
|
|||
{historicoData.completo.total_manutencoes ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4">
|
||||
<span className="text-[10px] uppercase font-bold text-emerald-700 dark:text-emerald-300 tracking-wider">Concluídas</span>
|
||||
<p className="text-2xl font-bold text-emerald-700 dark:text-emerald-300 mt-1">
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-xl p-4">
|
||||
<span className="text-[10px] uppercase font-bold text-orange-700 dark:text-orange-300 tracking-wider">Concluídas</span>
|
||||
<p className="text-2xl font-bold text-orange-700 dark:text-orange-300 mt-1">
|
||||
{historicoData.completo.manutencoes_concluidas ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1781,11 +1678,11 @@ export default function MaintenanceView() {
|
|||
) : (
|
||||
<div className="space-y-4">
|
||||
{historicoData.detalhado.length > 0 ? (
|
||||
<SmartTable
|
||||
<ExcelTable
|
||||
data={historicoData.detalhado}
|
||||
columns={[
|
||||
{ header: 'ID Manutenção', field: 'idmanutencao_frota', width: '120px' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '140px' },
|
||||
{ header: 'Oficina', field: 'oficina', width: '180px' },
|
||||
{ header: 'Motivo', field: 'motivo_atendimento', width: '140px' },
|
||||
|
|
@ -1847,7 +1744,7 @@ export default function MaintenanceView() {
|
|||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">
|
||||
Valor Total Gasto
|
||||
</span>
|
||||
<p className="text-2xl font-bold text-emerald-700 dark:text-emerald-300 mt-1">
|
||||
<p className="text-2xl font-bold text-orange-700 dark:text-orange-300 mt-1">
|
||||
{formatCurrency(historicoData.estatisticas.valor_total_gasto)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1858,7 +1755,7 @@ export default function MaintenanceView() {
|
|||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">
|
||||
Valor Médio Manutenção
|
||||
</span>
|
||||
<p className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mt-1">
|
||||
<p className="text-sm font-bold text-orange-700 dark:text-orange-300 mt-1">
|
||||
{formatCurrency(historicoData.estatisticas.valor_medio_manutencao)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1890,11 +1787,11 @@ export default function MaintenanceView() {
|
|||
<TabsContent value="documentos" className="flex-1 overflow-hidden flex flex-col m-0 pt-2">
|
||||
{selectedMaintenance && (selectedMaintenance.pdf_orcamento || selectedMaintenance.nota_fiscal) && (
|
||||
<Tabs defaultValue={selectedMaintenance.pdf_orcamento ? "pdf" : "nota"} className="w-full h-full flex flex-col">
|
||||
<TabsList className="bg-slate-100/50 dark:bg-emerald-500/5 border border-slate-200 dark:border-emerald-500/20 w-fit p-1 h-auto mb-4 shrink-0 self-center md:self-start">
|
||||
<TabsList className="bg-slate-100/50 dark:bg-orange-500/5 border border-slate-200 dark:border-orange-500/20 w-fit p-1 h-auto mb-4 shrink-0 self-center md:self-start">
|
||||
{selectedMaintenance.pdf_orcamento && (
|
||||
<TabsTrigger
|
||||
value="pdf"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
>
|
||||
PDF Orçamento
|
||||
</TabsTrigger>
|
||||
|
|
@ -1902,7 +1799,7 @@ export default function MaintenanceView() {
|
|||
{selectedMaintenance.nota_fiscal && (
|
||||
<TabsTrigger
|
||||
value="nota"
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-6 py-2"
|
||||
>
|
||||
Nota Fiscal
|
||||
</TabsTrigger>
|
||||
|
|
@ -1913,7 +1810,7 @@ export default function MaintenanceView() {
|
|||
<div className="w-full h-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-2xl overflow-hidden flex flex-col shadow-sm">
|
||||
<div className="p-3 border-b border-slate-200 dark:border-[#333] flex items-center justify-between bg-white dark:bg-[#1a1a1a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-emerald-500" />
|
||||
<FileText size={16} className="text-orange-500" />
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Visualização: PDF Orçamento</span>
|
||||
</div>
|
||||
<a href={selectedMaintenance.pdf_orcamento} target="_blank" rel="noreferrer" className="text-[10px] uppercase font-bold text-blue-500 hover:underline">Abrir em nova aba</a>
|
||||
|
|
@ -1941,7 +1838,7 @@ export default function MaintenanceView() {
|
|||
<div className="w-full h-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-2xl overflow-hidden flex flex-col shadow-sm">
|
||||
<div className="p-3 border-b border-slate-200 dark:border-[#333] flex items-center justify-between bg-white dark:bg-[#1a1a1a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-emerald-500" />
|
||||
<FileText size={16} className="text-orange-500" />
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider">Visualização: Nota Fiscal</span>
|
||||
</div>
|
||||
<a href={selectedMaintenance.nota_fiscal} target="_blank" rel="noreferrer" className="text-[10px] uppercase font-bold text-blue-500 hover:underline">Abrir em nova aba</a>
|
||||
|
|
@ -1992,7 +1889,7 @@ export default function MaintenanceView() {
|
|||
Veículos: {selectedStatusRecords?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Listagem técnica dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-emerald-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
Listagem técnica dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-orange-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2000,10 +1897,10 @@ export default function MaintenanceView() {
|
|||
|
||||
<div className="flex-1 overflow-hidden p-6 bg-white dark:bg-[#1c1c1c]">
|
||||
<div className="h-full w-full rounded-2xl border border-slate-200 dark:border-[#2a2a2a] overflow-hidden shadow-sm">
|
||||
<SmartTable
|
||||
<ExcelTable
|
||||
data={selectedStatusRecords?.records || []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
|
|
@ -2034,3 +1931,6 @@ export default function MaintenanceView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,462 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Mail, Calendar, Clock, Upload, CircleAlert, CircleCheckBig, Truck, LayoutDashboard, Key, ShieldCheck, FileSearch, Trash2, Loader2, Settings, X, Package, DollarSign, Copy, Store, User, Activity, Columns, RefreshCcw } from 'lucide-react';
|
||||
import * as prafrotService from '../services/prafrotService';
|
||||
import { useAuthContext } from '@/components/shared/AuthProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { NfeDataDisplay } from '../components/NfeDataDisplay';
|
||||
|
||||
const MessagesView = () => {
|
||||
const { userData } = useAuthContext();
|
||||
const user = userData?.usuario || { username: '', email: '' };
|
||||
|
||||
const [dataCorte, setDataCorte] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [assuntoFiltro, setAssuntoFiltro] = useState('NFe');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [results, setResults] = useState(null);
|
||||
|
||||
// Email Credentials State
|
||||
const [emailCred, setEmailCred] = useState(user.email || '');
|
||||
const [passwordCred, setPasswordCred] = useState('');
|
||||
const [isSavingCreds, setIsSavingCreds] = useState(false);
|
||||
|
||||
// XML Validation State
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('manual'); // 'manual' | 'auto'
|
||||
const [nfes, setNfes] = useState([]);
|
||||
const [loadingNfes, setLoadingNfes] = useState(false);
|
||||
const [selectedNfeDetail, setSelectedNfeDetail] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'auto') {
|
||||
fetchNfes();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchNfes = async () => {
|
||||
setLoadingNfes(true);
|
||||
try {
|
||||
const data = await prafrotService.getNfes();
|
||||
const list = Array.isArray(data) ? data : (data.results || []);
|
||||
setNfes(list);
|
||||
} catch (error) {
|
||||
toast.error("Erro ao carregar NFes");
|
||||
} finally {
|
||||
setLoadingNfes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCredentials = async () => {
|
||||
if (!emailCred || !passwordCred) {
|
||||
toast.error("Preencha e-mail e senha");
|
||||
return;
|
||||
}
|
||||
setIsSavingCreds(true);
|
||||
try {
|
||||
await prafrotService.saveEmailCredentials(emailCred, passwordCred);
|
||||
toast.success("Credenciais salvas com sucesso");
|
||||
setPasswordCred('');
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Erro ao salvar credenciais");
|
||||
} finally {
|
||||
setIsSavingCreds(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessEmails = async () => {
|
||||
setIsProcessing(true);
|
||||
setResults(null);
|
||||
try {
|
||||
const resp = await prafrotService.processEmails({
|
||||
desde_data: dataCorte,
|
||||
assunto_filtro: assuntoFiltro,
|
||||
salvar_xmls: true
|
||||
});
|
||||
setResults(resp);
|
||||
toast.success(`${resp.total_xmls} XMLs processados com sucesso`);
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Erro ao processar e-mails");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateXML = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
try {
|
||||
const resp = await prafrotService.validateXML(file);
|
||||
setValidationResult(resp);
|
||||
if (resp.valido) {
|
||||
toast.success("XML válido!");
|
||||
} else {
|
||||
toast.error("XML inválido para o sistema");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message || "Erro ao validar XML");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white p-4 md:p-8 animate-in fade-in duration-500 overflow-hidden flex flex-col gap-6">
|
||||
{/* Header - Fixed Height Area */}
|
||||
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-emerald-500/10 rounded-2xl flex items-center justify-center border border-emerald-500/20 shadow-lg shadow-emerald-500/5">
|
||||
<Mail className="text-emerald-500" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight uppercase">
|
||||
Processador de <span className="text-emerald-500">XML</span>
|
||||
</h1>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.2em]">
|
||||
Ambiente Seguro de Processamento
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex p-1 bg-white/5 rounded-xl border border-white/5 shadow-inner">
|
||||
<button
|
||||
onClick={() => setActiveTab('manual')}
|
||||
className={`px-6 py-2 text-[10px] font-bold uppercase tracking-widest rounded-lg transition-all ${
|
||||
activeTab === 'manual'
|
||||
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('auto')}
|
||||
className={`px-6 py-2 text-[10px] font-bold uppercase tracking-widest rounded-lg transition-all ${
|
||||
activeTab === 'auto'
|
||||
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Automático
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* <button className="p-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all text-slate-400">
|
||||
<Settings size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content - Fluid Area */}
|
||||
<main className="flex-1 flex gap-6 min-h-0 relative">
|
||||
{/* Main List/Controls Section */}
|
||||
<div className={`flex-1 flex flex-col gap-6 transition-all duration-500 ${selectedNfeDetail ? 'hidden lg:flex' : 'flex'}`}>
|
||||
{activeTab === 'manual' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-full">
|
||||
{/* Left Column: Controls */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<div className="bg-[#141414] rounded-[2rem] border border-white/5 p-8 space-y-6 shadow-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Data de Corte</label>
|
||||
<div className="relative group">
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
||||
<input
|
||||
type="date"
|
||||
value={dataCorte}
|
||||
onChange={(e) => setDataCorte(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-black/40 border border-white/5 rounded-2xl text-xs font-bold focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Filtro de Conteúdo</label>
|
||||
<div className="relative group">
|
||||
<FileSearch className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
value={assuntoFiltro}
|
||||
onChange={(e) => setAssuntoFiltro(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-black/40 border border-white/5 rounded-2xl text-xs font-bold focus:outline-none focus:border-emerald-500 focus:ring-4 focus:ring-emerald-500/5 transition-all"
|
||||
placeholder="Ex: NFe, Fatura..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleProcessEmails}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-center justify-center gap-3 px-6 py-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl font-black text-[11px] uppercase tracking-[0.2em] transition-all shadow-xl shadow-emerald-500/20 disabled:opacity-50 hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{isProcessing ? <Loader2 className="animate-spin" size={20} /> : <Upload size={20} />}
|
||||
Sincronizar Mensagens
|
||||
</button>
|
||||
|
||||
{/* <div className="h-px bg-white/5" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Configuração de Credenciais</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="relative group">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
type="email"
|
||||
value={emailCred}
|
||||
onChange={(e) => setEmailCred(e.target.value)}
|
||||
placeholder="E-mail"
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-black/40 border border-white/5 rounded-2xl text-xs font-bold focus:outline-none focus:border-emerald-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Key className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
type="password"
|
||||
value={passwordCred}
|
||||
onChange={(e) => setPasswordCred(e.target.value)}
|
||||
placeholder="Senha"
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-black/40 border border-white/5 rounded-2xl text-xs font-bold focus:outline-none focus:border-emerald-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveCredentials}
|
||||
disabled={isSavingCreds}
|
||||
className="w-full py-3 bg-white/5 hover:bg-white/10 text-slate-300 rounded-2xl font-bold text-[10px] uppercase tracking-widest border border-white/10 transition-all active:scale-[0.98]"
|
||||
>
|
||||
{isSavingCreds ? <Loader2 className="animate-spin mx-auto" size={16} /> : "Atualizar Acesso"}
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Execution History */}
|
||||
<div className="lg:col-span-8 flex flex-col gap-6">
|
||||
<div className="flex-1 bg-[#141414] rounded-[2rem] border border-white/5 flex flex-col shadow-2xl overflow-hidden">
|
||||
<header className="p-8 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg">
|
||||
<Activity className="text-emerald-500" size={16} />
|
||||
</div>
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.2em]">Fluxo de Dados em Tempo Real</h3>
|
||||
</div>
|
||||
{results && (
|
||||
<span className="text-[9px] font-black bg-emerald-500/10 text-emerald-500 px-3 py-1 rounded-full border border-emerald-500/20 uppercase tracking-widest">
|
||||
Processamento Concluído
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex-1 p-8 overflow-y-auto custom-scrollbar">
|
||||
{isProcessing ? (
|
||||
<div className="h-full flex flex-col items-center justify-center space-y-4 opacity-40">
|
||||
<Loader2 className="animate-spin text-emerald-500" size={48} />
|
||||
<p className="text-sm font-black text-slate-500 uppercase tracking-[0.3em]">Varrendo Servidores de E-mail...</p>
|
||||
</div>
|
||||
) : results ? (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="bg-black/40 p-6 rounded-[1.5rem] border border-white/5">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase block mb-2 tracking-widest">Total XMLs</span>
|
||||
<span className="text-4xl font-black text-emerald-500 tracking-tighter">{results.total_xmls}</span>
|
||||
</div>
|
||||
{/* <div className="md:col-span-2 bg-black/40 p-6 rounded-[1.5rem] border border-white/5 relative overflow-hidden">
|
||||
<span className="text-[9px] font-bold text-slate-500 uppercase block mb-2 tracking-widest">Conta Processada</span>
|
||||
<span className="text-lg font-bold text-white tracking-tight">{results.email_processado}</span>
|
||||
<ShieldCheck className="absolute right-[-10px] bottom-[-10px] text-emerald-500/10" size={80} />
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">Arquivos Extraídos</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[400px] overflow-y-auto custom-scrollbar pr-2">
|
||||
{results.resultados.map((res, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-black/40 p-4 rounded-2xl border border-white/5 group hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-white/5 rounded-lg group-hover:bg-emerald-500/10 transition-colors">
|
||||
<Package size={14} className="text-slate-500 group-hover:text-emerald-500 transition-colors" />
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-400 group-hover:text-white transition-colors truncate">{res.arquivo}</span>
|
||||
</div>
|
||||
<CircleCheckBig size={16} className="text-emerald-500 opacity-40 group-hover:opacity-100 transition-all" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-4 opacity-20">
|
||||
<Mail size={64} className="text-slate-600" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-black uppercase tracking-[0.2em]">Aguardando Comando</p>
|
||||
<p className="text-[10px] font-bold text-slate-500 uppercase">Inicie a sincronização para buscar novos XMLs no servidor.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-[#141414] rounded-[2rem] border border-white/5 flex-1 flex flex-col shadow-2xl overflow-hidden animate-in slide-in-from-right-4 duration-500">
|
||||
<header className="p-8 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-lg">
|
||||
<Columns size={16} className="text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="text-[11px] font-black uppercase tracking-[0.2em]">Repositório de Documentos Processados</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={fetchNfes} className="p-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-slate-400 transition-all">
|
||||
<RefreshCcw size={16} className={loadingNfes ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest bg-black/40 px-4 py-2 rounded-xl border border-white/5">
|
||||
{nfes.length} Registros Encontrados
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{loadingNfes ? (
|
||||
<div className="h-full flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="animate-spin text-emerald-500" size={40} />
|
||||
<span className="text-[10px] font-black text-slate-500 uppercase tracking-[0.3em]">Carregando Repositório...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8">
|
||||
<table className="w-full text-left border-separate border-spacing-y-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Identificador</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Emitente / Origem</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Valor Fiscal</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Data Emissão</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-500 uppercase tracking-widest text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nfes.map(item => {
|
||||
const nfeData = item.nfe || item;
|
||||
const nfeId = item.id || nfeData.id;
|
||||
const isSelected = selectedNfeDetail?.id === nfeId;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={nfeId}
|
||||
onClick={() => setSelectedNfeDetail(item)}
|
||||
className={`group cursor-pointer transition-all duration-300 ${isSelected ? 'scale-[0.99]' : 'hover:scale-[1.01]'}`}
|
||||
>
|
||||
<td className={`px-6 py-5 bg-black/40 rounded-l-[1.5rem] border-y border-l transition-all duration-300 ${isSelected ? 'bg-emerald-500/10 border-emerald-500/20' : 'border-white/5 hover:border-emerald-500/30'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg transition-colors ${isSelected ? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/20' : 'bg-white/5 group-hover:bg-emerald-500/10 group-hover:text-emerald-500 text-slate-500'}`}>
|
||||
<FileSearch size={16} />
|
||||
</div>
|
||||
<span className="text-xs font-black tracking-tight text-white">#{nfeId}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-6 py-5 bg-black/40 border-y transition-all duration-300 ${isSelected ? 'bg-emerald-500/10 border-emerald-500/20' : 'border-white/5 group-hover:border-emerald-500/30'}`}>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-bold text-white uppercase group-hover:text-emerald-400 transition-colors">{nfeData.emitente_nome || '---'}</span>
|
||||
<span className="text-[9px] font-bold text-slate-500 font-mono mt-0.5">{nfeData.emitente_cnpj || '---'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-6 py-5 bg-black/40 border-y transition-all duration-300 ${isSelected ? 'bg-emerald-500/10 border-emerald-500/20' : 'border-white/5 group-hover:border-emerald-500/30'}`}>
|
||||
<span className="text-sm font-black text-emerald-500 font-mono tracking-tighter">
|
||||
R$ {parseFloat(nfeData.valor_total || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-5 bg-black/40 border-y transition-all duration-300 ${isSelected ? 'bg-emerald-500/10 border-emerald-500/20' : 'border-white/5 group-hover:border-emerald-500/30'}`}>
|
||||
<div className="flex items-center gap-2 text-[11px] font-bold text-slate-400 group-hover:text-slate-200 transition-colors">
|
||||
<Calendar size={12} className="opacity-40" />
|
||||
{nfeData.data_emissao ? new Date(nfeData.data_emissao).toLocaleDateString() : '---'}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-6 py-5 bg-black/40 rounded-r-[1.5rem] border-y border-r text-right transition-all duration-300 ${isSelected ? 'bg-emerald-500/10 border-emerald-500/20' : 'border-white/5 group-hover:border-emerald-500/30'}`}>
|
||||
<button
|
||||
className={`p-2.5 rounded-xl transition-all ${isSelected ? 'bg-emerald-500 text-white' : 'bg-white/5 group-hover:bg-emerald-500 text-white shadow-lg'}`}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{nfes.length === 0 && !loadingNfes && (
|
||||
<div className="py-20 flex flex-col items-center justify-center space-y-4 opacity-20">
|
||||
<CircleAlert size={48} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Nenhuma NFe processada encontrada</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrated Side Panel - The "Power-Up" */}
|
||||
<div className={`fixed inset-y-0 right-0 w-full lg:w-[60vw] xl:w-[50vw] bg-[#141416] border-l border-white/10 shadow-[-20px_0_50px_rgba(0,0,0,0.5)] transform transition-transform duration-200 ease-out z-40 flex flex-col will-change-transform ${selectedNfeDetail ? 'translate-x-0' : 'translate-x-full'}`}>
|
||||
{selectedNfeDetail && (
|
||||
<>
|
||||
<header className="p-8 border-b border-white/5 flex items-center justify-between bg-black/20">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-emerald-500 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-emerald-500/20">
|
||||
<FileSearch size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-black tracking-tight uppercase">Detalhes do <span className="text-emerald-500">Documento</span></h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Protocolo Fiscal:</span>
|
||||
<span className="text-[10px] font-mono text-emerald-500/80 font-bold bg-emerald-500/5 px-2 py-0.5 rounded border border-emerald-500/10">
|
||||
{selectedNfeDetail.nfe?.numero || selectedNfeDetail.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedNfeDetail(null)}
|
||||
className="p-3 bg-white/5 hover:bg-rose-500 hover:text-white border border-white/10 rounded-2xl transition-all group"
|
||||
>
|
||||
<X size={20} className="transition-transform group-hover:rotate-90 duration-300" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<NfeDataDisplay data={selectedNfeDetail} />
|
||||
</div>
|
||||
|
||||
<footer className="p-6 bg-black/40 border-t border-white/5 flex justify-end gap-3">
|
||||
<button className="flex items-center gap-2 px-6 py-3 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all border border-white/10">
|
||||
<Copy size={16} /> Copiar Chave
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedNfeDetail(null)}
|
||||
className="px-8 py-3 bg-emerald-500 hover:bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Fechar Visualização
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="flex items-center justify-between opacity-40 hover:opacity-100 transition-opacity duration-500 px-4">
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<ShieldCheck size={14} className="text-emerald-500" />
|
||||
<span className="text-[9px] font-black text-slate-500 uppercase tracking-[0.3em]">Criptografia iT Guys Standard (256-bit AES)</span>
|
||||
</div> */}
|
||||
{/* <div className="text-[9px] font-black text-slate-500 uppercase tracking-[0.3em]">Ambiente Platform Sistemas v4.0</div> */}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesView;
|
||||
|
|
@ -11,7 +11,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -23,7 +23,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -36,7 +36,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -107,14 +107,14 @@ export default function MokiView() {
|
|||
<div className="space-y-6 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Checklists Moki</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Checklists Moki</h1>
|
||||
<p className="text-slate-500 text-sm">Inspeções e vistorias realizadas.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="w-full md:w-64 bg-white dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-10 pr-4 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full md:w-64 bg-white dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg pl-10 pr-4 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
placeholder="Buscar checklist..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -132,12 +132,12 @@ export default function MokiView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'idmoki_frota', width: '80px' },
|
||||
{ header: 'DATA', field: 'data', width: '100px', render: (row) => row.data?.split('T')[0] || row.data_checklist?.split('T')[0] },
|
||||
{ header: 'CHECKLIST', field: 'checklist', width: '220px', className: 'font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'CHECKLIST', field: 'checklist', width: '220px', className: 'font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS CHECKLIST', field: 'status_checklist', width: '150px' },
|
||||
{ header: 'STATUS', field: 'status', width: '120px', render: (row) => (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border ${
|
||||
row.status === 'Não Conforme' ? 'bg-rose-500/10 text-rose-500 border-rose-500/20' :
|
||||
row.status === 'Aprovado' || row.status === 'Conforme' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
row.status === 'Aprovado' || row.status === 'Conforme' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}`}>
|
||||
{row.status || 'Pendente'}
|
||||
|
|
@ -175,7 +175,7 @@ export default function MokiView() {
|
|||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
<div className="bg-emerald-500/5 p-4 rounded-xl border border-emerald-500/10 mb-2">
|
||||
<div className="bg-orange-500/5 p-4 rounded-xl border border-orange-500/10 mb-2">
|
||||
<DarkInput
|
||||
type="number"
|
||||
label="ID do Moki (Obrigatório)"
|
||||
|
|
@ -184,7 +184,7 @@ export default function MokiView() {
|
|||
required
|
||||
placeholder="Ex: 123456"
|
||||
/>
|
||||
<p className="text-[10px] text-emerald-500/60 mt-1 ml-1 uppercase font-bold tracking-widest">Este campo deve ser preenchido manualmente para novos registros.</p>
|
||||
<p className="text-[10px] text-orange-500/60 mt-1 ml-1 uppercase font-bold tracking-widest">Este campo deve ser preenchido manualmente para novos registros.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
|
@ -219,3 +219,6 @@ export default function MokiView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -26,7 +26,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -39,7 +39,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -115,7 +115,17 @@ export default function MonitoringView() {
|
|||
const handleOpenModal = (item = null) => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({ ...initialFormState, ...item });
|
||||
// Format date properly for HTML date input (YYYY-MM-DD)
|
||||
const formattedItem = { ...item };
|
||||
if (formattedItem.data_carga) {
|
||||
// Handle various date formats from backend
|
||||
const date = new Date(formattedItem.data_carga);
|
||||
if (!isNaN(date.getTime())) {
|
||||
// Format to YYYY-MM-DD for HTML date input
|
||||
formattedItem.data_carga = date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
setFormData({ ...initialFormState, ...formattedItem });
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData(initialFormState);
|
||||
|
|
@ -143,14 +153,14 @@ export default function MonitoringView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Monitoramento & Logística</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Monitoramento & Logística</h1>
|
||||
<p className="text-slate-500 text-sm">Acompanhamento em tempo real e rastreabilidade.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar unidade..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -165,7 +175,7 @@ export default function MonitoringView() {
|
|||
{/* Status Highlights */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ label: 'Em Operação', status: 'Em Operação', color: 'bg-emerald-500/10 text-emerald-600', icon: <CheckCircle size={20} />, hover: 'hover:border-emerald-500/30' },
|
||||
{ label: 'Em Operação', status: 'Em Operação', color: 'bg-orange-500/10 text-orange-600', icon: <CheckCircle size={20} />, hover: 'hover:border-orange-500/30' },
|
||||
{ label: 'Veículo Alugado', status: 'Veículo Alugado', color: 'bg-blue-500/10 text-blue-600', icon: <Truck size={20} />, hover: 'hover:border-blue-500/30' },
|
||||
].map((card, i) => (
|
||||
<div
|
||||
|
|
@ -195,7 +205,7 @@ export default function MonitoringView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'idmonitoramento_frota', width: '80px' },
|
||||
{ header: 'ID EXTERNO', field: 'id_externo', width: '120px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'UNIDADE', field: 'unidade', width: '150px' },
|
||||
{ header: 'MOTORISTA', field: 'motorista', width: '150px' },
|
||||
{ header: 'DATA CARGA', field: 'data_carga', width: '150px' },
|
||||
|
|
@ -246,7 +256,7 @@ export default function MonitoringView() {
|
|||
valueKey="NOME_FAVORECIDO"
|
||||
placeholder="Buscar motorista..."
|
||||
/>
|
||||
<DarkInput type="date" label="Data Carga" value={formData.data_carga?.split('T')[0]} onChange={e => setFormData({...formData, data_carga: e.target.value})} />
|
||||
<DarkInput type="date" label="Data Carga" value={formData.data_carga || ''} onChange={e => setFormData({...formData, data_carga: e.target.value})} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
<DarkInput label="ID Externo" value={formData.id_externo} onChange={e => setFormData({...formData, id_externo: e.target.value})} />
|
||||
|
|
@ -255,7 +265,7 @@ export default function MonitoringView() {
|
|||
<div className="gap-4 pb-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações de Monitoramento</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[100px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[100px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
|
|
@ -274,7 +284,7 @@ export default function MonitoringView() {
|
|||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-emerald-500/10 rounded-2xl text-emerald-600 shadow-inner">
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl text-orange-600 shadow-inner">
|
||||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -282,7 +292,7 @@ export default function MonitoringView() {
|
|||
Veículos: {selectedStatusRecords?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Monitoramento detalhado dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-emerald-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
Monitoramento detalhado dos {selectedStatusRecords?.records?.length || 0} veículos com status <span className="text-orange-500 font-bold">"{selectedStatusRecords?.title}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -293,7 +303,7 @@ export default function MonitoringView() {
|
|||
<ExcelTable
|
||||
data={selectedStatusRecords?.records || []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
|
|
@ -324,3 +334,6 @@ export default function MonitoringView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,676 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Plus, X, Loader2, Activity, Filter, RefreshCcw, Calendar, Trash2, Eye, User, Truck, Clock, ShieldCheck, MapPin, LayoutGrid, Table2, Columns, Archive, FileText, Package, CheckCircle2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getTripRequests, updateTripStatus, deleteTripRequest, closeTripRequest, archiveTripRequest } from '../services/prafrotService';
|
||||
import { TripDetailsModal } from '../components/TripDetailsModal';
|
||||
import { OperationsManagerModal } from '../components/OperationsManagerModal';
|
||||
import ExcelTable from '../components/ExcelTable';
|
||||
import { GrKanbanBoard } from '../../gr/components/GrKanbanBoard';
|
||||
import { toast } from 'sonner';
|
||||
import { AnalysisPanel } from '../components/AnalysisPanel';
|
||||
import { NfeViewerModal } from '../components/NfeViewerModal';
|
||||
import { AttendanceFormModal } from '../components/AttendanceFormModal';
|
||||
|
||||
const formatCPF = (cpf) => {
|
||||
if (!cpf) return '---';
|
||||
const cleanCPF = String(cpf).replace(/\D/g, '');
|
||||
if (cleanCPF.length !== 11) return cpf;
|
||||
return cleanCPF.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
|
||||
};
|
||||
|
||||
const ensureArray = (val) => {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (val && typeof val === 'object') return Object.values(val);
|
||||
return [];
|
||||
};
|
||||
|
||||
const renderValue = (val) => {
|
||||
if (!val) return '---';
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return val.numero || val.chave || val.id || JSON.stringify(val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
const SolicitacoesView = () => {
|
||||
const [activeTab, setActiveTab] = useState('PENDENTE'); // PENDENTE, MONITORAMENTO, RELATORIO, FINALIZADO
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTrip, setSelectedTrip] = useState(null);
|
||||
const [showOpsManager, setShowOpsManager] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('cards'); // cards, table, kanban
|
||||
const [showAnalysis, setShowAnalysis] = useState(false);
|
||||
const [selectedNfe, setSelectedNfe] = useState(null);
|
||||
const [showAttendanceForm, setShowAttendanceForm] = useState(false);
|
||||
const [tripToAttend, setTripToAttend] = useState(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getTripRequests();
|
||||
|
||||
let allRequests = [];
|
||||
if (Array.isArray(data)) {
|
||||
allRequests = data;
|
||||
} else if (data && (data.LIBERADO || data.PENDENTE || data.FECHADO)) {
|
||||
// Nova estrutura agrupada por status: LIBERADO, PENDENTE, FECHADO
|
||||
const liberados = ensureArray(data.LIBERADO).map(r => ({
|
||||
...r,
|
||||
status: 'LIBERADO'
|
||||
}));
|
||||
const pendentes = ensureArray(data.PENDENTE).map(r => ({
|
||||
...r,
|
||||
status: 'PENDENTE'
|
||||
}));
|
||||
const finalizados = ensureArray(data.FINALIZADO || data.FECHADO).map(r => ({
|
||||
...r,
|
||||
status: 'FINALIZADO'
|
||||
}));
|
||||
allRequests = [...liberados, ...pendentes, ...finalizados];
|
||||
} else if (data?.data) {
|
||||
allRequests = Array.isArray(data.data) ? data.data : (typeof data.data === 'object' ? Object.values(data.data) : []);
|
||||
} else if (typeof data === 'object' && data !== null) {
|
||||
// Fallback: tenta pegar qualquer array presente no objeto
|
||||
allRequests = Object.values(data).find(Array.isArray) || [];
|
||||
}
|
||||
|
||||
setRequests(allRequests);
|
||||
} catch (error) {
|
||||
toast.error('Erro ao carregar solicitações');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredRequests = requests.filter(req => {
|
||||
const status = req.status || 'PENDENTE';
|
||||
const matchesSearch =
|
||||
req.nome_completo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
req.placa_do_cavalo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
req.idsolicitacoes?.toString().includes(searchTerm);
|
||||
|
||||
if (activeTab === 'PENDENTE') return matchesSearch && status === 'PENDENTE';
|
||||
if (activeTab === 'MONITORAMENTO') return matchesSearch && status === 'LIBERADO';
|
||||
if (activeTab === 'FECHADO' || activeTab === 'FINALIZADO') return matchesSearch && (status === 'FECHADO' || status === 'FINALIZADO');
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
pendentes: requests.filter(r => (r.status || 'PENDENTE') === 'PENDENTE').length,
|
||||
monitorando: requests.filter(r => r.status === 'LIBERADO').length,
|
||||
total: requests.length
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-transparent text-white font-sans selection:bg-emerald-500/30 selection:text-emerald-200" style={{ fontFamily: 'var(--font-main)' }}>
|
||||
{/* Header section integrated with Dashboard style */}
|
||||
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-8">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest mb-1">
|
||||
<ShieldCheck size={14} /> Painel de Monitoramento
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight uppercase text-white">Gestão de <span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-emerald-600">Solicitações</span></h1>
|
||||
<p className="text-zinc-500 font-medium text-xs">Controle centralizado de solicitações e monitoramento em tempo real.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-[#1c1c1c] border border-white/5 rounded-xl">
|
||||
<RefreshCcw size={14} className={`text-zinc-500 cursor-pointer hover:text-emerald-400 transition-colors ${loading ? 'animate-spin' : ''}`} onClick={fetchData} />
|
||||
<div className="w-px h-4 bg-zinc-800 mx-1" />
|
||||
<Calendar size={14} className="text-zinc-500" />
|
||||
<span className="text-[10px] font-bold text-zinc-300">{new Date().toLocaleDateString('pt-BR')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowOpsManager(true)}
|
||||
className="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all border border-white/5"
|
||||
>
|
||||
Gerenciar Operações
|
||||
</button>
|
||||
{/* {activeTab === 'MONITORAMENTO' && (
|
||||
<button
|
||||
onClick={() => setShowAnalysis(true)}
|
||||
className="px-6 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-emerald-500/20"
|
||||
>
|
||||
Análises
|
||||
</button>
|
||||
)} */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Container */}
|
||||
<section className="space-y-6">
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-[#1c1c1c]/50 border border-white/5 rounded-[32px] p-6 flex flex-col gap-6 backdrop-blur-xl">
|
||||
|
||||
{/* Navigation Tabs - The Selector */}
|
||||
<div className="flex items-center gap-1 p-1.5 bg-black/40 rounded-2xl border border-white/5 overflow-x-auto max-w-full">
|
||||
{[
|
||||
{ id: 'PENDENTE', label: 'Pendentes', count: stats.pendentes, color: 'amber' },
|
||||
{ id: 'MONITORAMENTO', label: 'Monitoramento', count: stats.monitorando, color: 'emerald' },
|
||||
//{ id: 'RELATORIO', label: 'Relatório', count: null, color: 'blue' },
|
||||
{ id: 'FINALIZADO', label: 'Finalizados', count: null, color: 'zinc' }
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all whitespace-nowrap ${activeTab === tab.id
|
||||
? `bg-zinc-800 text-white border border-white/10 shadow-lg`
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count !== null && (
|
||||
<span className={`w-5 h-5 flex items-center justify-center rounded-lg text-[8px] ${activeTab === tab.id ? 'bg-zinc-700 text-white' : 'bg-zinc-900 text-zinc-600'
|
||||
}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search & Global Actions - Now Below the Selector */}
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 w-full">
|
||||
{/* View Mode Switcher */}
|
||||
<div className="flex items-center gap-1 p-1 bg-black/40 rounded-xl border border-white/5 w-full md:w-auto justify-center">
|
||||
{[
|
||||
{ id: 'cards', icon: LayoutGrid, label: 'Cards' },
|
||||
{ id: 'table', icon: Table2, label: 'Tabela' },
|
||||
{ id: 'kanban', icon: Columns, label: 'Kanban' }
|
||||
].map(mode => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setViewMode(mode.id)}
|
||||
className={`p-2.5 rounded-lg transition-all ${viewMode === mode.id ? 'bg-zinc-800 text-white shadow-lg' : 'text-zinc-600 hover:text-zinc-400'}`}
|
||||
title={mode.label}
|
||||
>
|
||||
<mode.icon size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-4 w-full">
|
||||
<div className="relative group flex-1">
|
||||
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-600 group-focus-within:text-emerald-500 transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar motorista, placa ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-black/40 border border-white/5 rounded-xl text-xs focus:outline-none focus:ring-4 focus:ring-emerald-500/5 focus:border-emerald-500/30 transition-all font-semibold"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="px-4 py-3 text-[9px] font-bold text-zinc-600 uppercase tracking-widest hover:text-white transition-colors border border-white/5 rounded-xl hover:bg-white/5"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dynamic Content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center justify-center py-40 gap-4"
|
||||
>
|
||||
<Loader2 className="animate-spin text-emerald-500" size={32} />
|
||||
<span className="text-zinc-600 font-bold uppercase tracking-widest text-[9px]">Acessando dados...</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="min-h-[400px]"
|
||||
>
|
||||
{activeTab === 'PENDENTE' || activeTab === 'MONITORAMENTO' ? (
|
||||
<>
|
||||
{viewMode === 'cards' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{filteredRequests.length > 0 ? (
|
||||
filteredRequests.map(trip => (
|
||||
<TripCard
|
||||
key={trip.idsolicitacoes}
|
||||
trip={trip}
|
||||
onView={() => setSelectedTrip(trip)}
|
||||
onViewNfe={(chave) => setSelectedNfe(chave)}
|
||||
onRefresh={fetchData}
|
||||
onAttend={() => {
|
||||
setTripToAttend(trip);
|
||||
setShowAttendanceForm(true);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyState message="Nenhum registro encontrado." />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'table' && (
|
||||
<div className="bg-[#1c1c1c]/50 border border-white/5 rounded-[32px] overflow-hidden h-[600px]">
|
||||
<ExcelTable
|
||||
data={filteredRequests}
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idsolicitacoes', width: '80px' },
|
||||
{ header: 'Motorista', field: 'nome_completo', width: '200px' },
|
||||
{
|
||||
header: 'CPF',
|
||||
field: 'cpf',
|
||||
width: '130px',
|
||||
render: (row) => formatCPF(row.cpf)
|
||||
},
|
||||
{ header: 'Cavalo', field: 'placa_do_cavalo', width: '100px', className: 'font-mono uppercase font-bold text-emerald-500' },
|
||||
{ header: 'Carreta', field: 'placa_da_carreta', width: '100px' },
|
||||
{ header: 'Operação', field: 'operacao', width: '120px' },
|
||||
{
|
||||
header: 'NFE',
|
||||
field: 'nota_fiscal',
|
||||
width: '80px',
|
||||
render: (row) => {
|
||||
// Tenta obter a chave da NFe de várias fontes possíveis
|
||||
// Estrutura complexa: row.nota_fiscal pode ser { nfe: { chave: ... }, itens: ... }
|
||||
const nfeComplex = row.nfe || row.nota_fiscal;
|
||||
|
||||
// Tenta extrair a chave de nfeComplex.nfe.chave OU nfeComplex.chave OU nfeComplex (string)
|
||||
const chave = nfeComplex?.nfe?.chave || nfeComplex?.chave || (typeof nfeComplex === 'string' ? nfeComplex : null);
|
||||
const numero = nfeComplex?.nfe?.numero || nfeComplex?.numero;
|
||||
|
||||
if (chave && chave !== 'NÃO POSSUI' && chave.length > 20) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedNfe(chave); }}
|
||||
className="p-2 bg-emerald-500/10 text-emerald-500 rounded-lg hover:bg-emerald-500 hover:text-white transition-all shadow-sm flex items-center gap-2"
|
||||
title={`Ver NFe ${numero || ''}`}
|
||||
>
|
||||
<Eye size={16} />
|
||||
{numero && <span className="text-[10px] font-bold">{numero}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return renderValue(nfeComplex);
|
||||
}
|
||||
},
|
||||
{ header: 'CTe', field: 'cte', width: '120px' },
|
||||
{ header: 'Origem', field: 'origem', width: '150px' },
|
||||
{ header: 'Destino', field: 'destino', width: '150px' },
|
||||
{ header: 'Palets', field: 'quantidade_palet', width: '80px' },
|
||||
{ header: 'Chapatex', field: 'quantidade_chapatex', width: '80px' },
|
||||
{ header: 'Tipo', field: 'tipo_de_veiculo', width: '100px' },
|
||||
{ header: 'Situação', field: 'situacao', width: '150px' },
|
||||
{ header: 'Status', field: 'status', width: '120px' }
|
||||
]}
|
||||
onEdit={(item) => setSelectedTrip(item)}
|
||||
onDelete={(item) => deleteTripRequest(item.idsolicitacoes).then(fetchData)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'kanban' && (
|
||||
<div className="h-[600px] w-full">
|
||||
<GrKanbanBoard
|
||||
columns={[
|
||||
{
|
||||
id: 'PENDENTE',
|
||||
title: 'Pendentes',
|
||||
color: 'amber',
|
||||
cards: ensureArray(requests).filter(r => (r.status || 'PENDENTE') === 'PENDENTE' && (
|
||||
r.nome_completo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.placa_do_cavalo?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)).map(r => ({
|
||||
id: r.idsolicitacoes,
|
||||
title: r.nome_completo,
|
||||
subtitle: formatCPF(r.cpf),
|
||||
date: new Date(r.created_at || new Date()).toLocaleDateString('pt-BR'),
|
||||
base: r.operacao,
|
||||
details: [
|
||||
{ label: 'Cavalo', value: r.placa_do_cavalo },
|
||||
{ label: 'Carreta', value: r.placa_da_carreta || '---' },
|
||||
{ label: 'Doc', value: renderValue(r.cte || r.nota_fiscal) },
|
||||
{ label: 'Rota', value: `${r.origem} → ${r.destino}` }
|
||||
],
|
||||
...r
|
||||
}))
|
||||
},
|
||||
{
|
||||
id: 'LIBERADO',
|
||||
title: 'Monitoramento',
|
||||
color: 'emerald',
|
||||
cards: ensureArray(requests).filter(r => r.status === 'LIBERADO' && (
|
||||
r.nome_completo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.placa_do_cavalo?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)).map(r => ({
|
||||
id: r.idsolicitacoes,
|
||||
title: r.nome_completo,
|
||||
subtitle: formatCPF(r.cpf),
|
||||
date: new Date(r.created_at || new Date()).toLocaleDateString('pt-BR'),
|
||||
base: r.operacao,
|
||||
details: [
|
||||
{ label: 'Cavalo', value: r.placa_do_cavalo },
|
||||
{ label: 'Carreta', value: r.placa_da_carreta || '---' },
|
||||
{ label: 'Doc', value: renderValue(r.cte || r.nota_fiscal) },
|
||||
{ label: 'Rota', value: `${r.origem} → ${r.destino}` }
|
||||
],
|
||||
...r
|
||||
}))
|
||||
},
|
||||
{
|
||||
id: 'FINALIZADO',
|
||||
title: 'Finalizados',
|
||||
color: 'zinc',
|
||||
cards: ensureArray(requests).filter(r => (r.status === 'FECHADO' || r.status === 'FINALIZADO') && (
|
||||
r.nome_completo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.placa_do_cavalo?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)).map(r => ({
|
||||
id: r.idsolicitacoes,
|
||||
title: r.nome_completo,
|
||||
subtitle: formatCPF(r.cpf),
|
||||
date: new Date(r.created_at || new Date()).toLocaleDateString('pt-BR'),
|
||||
base: r.operacao,
|
||||
details: [
|
||||
{ label: 'Cavalo', value: r.placa_do_cavalo },
|
||||
{ label: 'Carreta', value: r.placa_da_carreta || '---' },
|
||||
{ label: 'Doc', value: renderValue(r.cte || r.nota_fiscal) },
|
||||
{ label: 'Rota', value: `${r.origem} → ${r.destino}` }
|
||||
],
|
||||
...r
|
||||
}))
|
||||
}
|
||||
]}
|
||||
onCardClick={(card) => setSelectedTrip(card)}
|
||||
onCardMove={async (id, newStatus) => {
|
||||
if (newStatus === 'LIBERADO') {
|
||||
const trip = requests.find(r => r.idsolicitacoes === id);
|
||||
if (trip) {
|
||||
setTripToAttend(trip);
|
||||
setShowAttendanceForm(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateTripStatus(id, newStatus);
|
||||
toast.success('Status atualizado');
|
||||
fetchData();
|
||||
} catch (e) { toast.error('Erro ao mover'); }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (activeTab === 'FECHADO' || activeTab === 'FINALIZADO') ? (
|
||||
<div className="bg-[#1c1c1c]/30 border border-white/5 rounded-3xl overflow-hidden">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/5 bg-black/20">
|
||||
{['ID', 'Motorista', 'Placa', 'Operação', 'Status', 'Data'].map(h => (
|
||||
<th key={h} className="px-6 py-4 text-[9px] font-bold text-zinc-500 uppercase tracking-widest">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{filteredRequests.map(trip => (
|
||||
<tr key={trip.idsolicitacoes} className="hover:bg-white/5 transition-colors group">
|
||||
<td className="px-6 py-4 text-xs font-bold text-zinc-500">#{trip.idsolicitacoes}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center text-[10px] font-bold text-zinc-500 group-hover:text-emerald-400">{trip.nome_completo?.[0]}</div>
|
||||
<span className="text-xs font-bold text-zinc-300 uppercase">{trip.nome_completo}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs font-bold text-zinc-400 uppercase">{trip.placa_do_cavalo}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-3 py-1 bg-zinc-800 rounded-lg text-[9px] font-bold uppercase tracking-tighter text-zinc-400">{trip.operacao}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-3 py-1 bg-emerald-500/10 text-emerald-400 rounded-full text-[8px] font-bold uppercase tracking-tighter">FINALIZADO</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs font-bold text-zinc-600">{new Date().toLocaleDateString('pt-BR')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-[#1c1c1c]/30 border border-white/5 border-dashed rounded-[32px] p-8 aspect-[16/9] flex items-center justify-center text-zinc-700 font-bold uppercase tracking-widest italic text-[10px]">
|
||||
[ Histórico de Atendimento ]
|
||||
</div>
|
||||
<div className="bg-[#1c1c1c]/30 border border-white/5 border-dashed rounded-[32px] p-8 aspect-[16/9] flex items-center justify-center text-zinc-700 font-bold uppercase tracking-widest italic text-[10px]">
|
||||
[ Desempenho por Unidade ]
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
|
||||
{/* Modals */}
|
||||
<AnimatePresence>
|
||||
{selectedTrip && (
|
||||
<TripDetailsModal
|
||||
trip={selectedTrip}
|
||||
onClose={() => setSelectedTrip(null)}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
{showOpsManager && (
|
||||
<OperationsManagerModal
|
||||
onClose={() => setShowOpsManager(false)}
|
||||
/>
|
||||
)}
|
||||
{showAnalysis && (
|
||||
<AnalysisPanel
|
||||
onClose={() => setShowAnalysis(false)}
|
||||
/>
|
||||
)}
|
||||
{selectedNfe && (
|
||||
<NfeViewerModal
|
||||
chave={selectedNfe}
|
||||
onClose={() => setSelectedNfe(null)}
|
||||
/>
|
||||
)}
|
||||
{showAttendanceForm && tripToAttend && (
|
||||
<AttendanceFormModal
|
||||
trip={tripToAttend}
|
||||
onClose={() => {
|
||||
setShowAttendanceForm(false);
|
||||
setTripToAttend(null);
|
||||
}}
|
||||
onRefresh={fetchData}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TripCard = ({ trip, onView, onRefresh, onViewNfe, onAttend }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleAttend = (e) => {
|
||||
e.stopPropagation();
|
||||
onAttend();
|
||||
};
|
||||
|
||||
const handleArchive = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Finalizar esta solicitação?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await closeTripRequest(trip.idsolicitacoes);
|
||||
toast.success('Solicitação finalizada!');
|
||||
onRefresh();
|
||||
} catch (e) { toast.error('Erro ao finalizar'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!window.confirm('Excluir?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteTripRequest(trip.idsolicitacoes);
|
||||
toast.success('Excluído');
|
||||
onRefresh();
|
||||
} catch (e) { toast.error('Erro ao excluir'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
onClick={onView}
|
||||
className="bg-[#1c1c1c]/50 border border-white/5 rounded-[28px] p-5 hover:bg-[#252525]/50 hover:border-white/10 transition-all group relative overflow-hidden cursor-pointer active:scale-95 flex flex-col justify-between min-h-[180px]"
|
||||
>
|
||||
{/* Decorative Accent */}
|
||||
<div className={`absolute top-0 left-0 right-0 h-1.5 ${trip.status === 'PENDENTE' ? 'bg-amber-500/50' : 'bg-emerald-500/50'
|
||||
}`} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="px-3 py-1 bg-zinc-800 rounded-lg text-xs font-medium uppercase text-zinc-300 group-hover:text-emerald-400 transition-colors tracking-wide">{trip.operacao}</span>
|
||||
{/* Tag de status removida de dentro do card conforme solicitado */}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-500 group-hover:bg-emerald-500/10 group-hover:text-emerald-400 transition-colors">
|
||||
<User size={18} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-zinc-200 group-hover:text-white transition-colors uppercase truncate leading-tight">{trip.nome_completo}</div>
|
||||
<div className="text-[11px] font-normal text-zinc-500 uppercase">{formatCPF(trip.cpf)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-500">
|
||||
<Truck size={18} />
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-zinc-200 uppercase">{trip.placa_do_cavalo}</div>
|
||||
<div className="text-[9px] font-normal text-zinc-500 uppercase">Cavalo</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-zinc-300 uppercase">{trip.placa_da_carreta || '---'}</div>
|
||||
<div className="text-[9px] font-normal text-zinc-500 uppercase">Carreta</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={12} className="text-zinc-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-zinc-300 truncate">{renderValue(trip.cte || trip.nota_fiscal)}</div>
|
||||
<div className="text-[8px] font-normal text-zinc-600 uppercase">Documento</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Package size={12} className="text-zinc-600" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-zinc-300">P: {trip.quantidade_palet || 0} / C: {trip.quantidade_chapatex || 0}</div>
|
||||
<div className="text-[8px] font-normal text-zinc-600 uppercase">Palet / Chap</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trip.situacao && (
|
||||
<div className="mt-3 px-3 py-2 bg-blue-500/10 border border-blue-500/20 rounded-xl">
|
||||
<div className="text-[9px] font-bold text-blue-400 uppercase tracking-widest mb-0.5">Observação / Situação</div>
|
||||
<div className="text-[10px] text-zinc-300 line-clamp-2">{trip.situacao}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-3 border-t border-white/5">
|
||||
<div className="w-8 h-8 rounded-xl bg-zinc-800/20 flex items-center justify-center text-zinc-600">
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div className="text-[10px] font-normal text-zinc-400 uppercase flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate">{trip.origem}</span>
|
||||
<span className="text-zinc-600 font-medium">→</span>
|
||||
<span className="truncate">{trip.destino}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-white/5">
|
||||
{trip.status === 'PENDENTE' && (
|
||||
<button
|
||||
onClick={handleAttend}
|
||||
disabled={loading}
|
||||
className="flex-1 h-9 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl text-[10px] font-medium uppercase tracking-widest transition-all"
|
||||
>
|
||||
Atender
|
||||
</button>
|
||||
)}
|
||||
{trip.status === 'LIBERADO' && (
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
disabled={loading}
|
||||
className="h-10 w-12 bg-emerald-600/20 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl flex items-center justify-center transition-all"
|
||||
title="Finalizar Solicitação"
|
||||
>
|
||||
<CheckCircle2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const nfeComplex = trip.nfe || trip.nota_fiscal;
|
||||
const chave = nfeComplex?.nfe?.chave || nfeComplex?.chave || (typeof nfeComplex === 'string' ? nfeComplex : null);
|
||||
|
||||
if (chave && chave !== 'NÃO POSSUI' && chave.length > 20) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onViewNfe(chave); }}
|
||||
className="h-10 px-4 bg-emerald-600/10 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl flex items-center gap-2 transition-all font-bold text-[10px] uppercase tracking-widest border border-emerald-600/20"
|
||||
title="Visualizar DANFE"
|
||||
>
|
||||
<Eye size={18} /> NFe
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{trip.status === 'LIBERADO' && (<div className="flex-1"></div>)} {/* Spacer */}
|
||||
|
||||
{/* Botão de excluir comentado conforme solicitado */}
|
||||
{/*
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="h-10 w-12 bg-black/40 hover:bg-red-500/20 text-zinc-600 hover:text-red-500 rounded-xl flex items-center justify-center transition-all border border-white/5"
|
||||
title="Excluir"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = ({ message }) => (
|
||||
<div className="col-span-full py-40 flex flex-col items-center justify-center text-zinc-800 font-bold uppercase tracking-[0.2em] italic border border-dashed border-white/5 rounded-3xl text-[10px]">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SolicitacoesView;
|
||||
|
|
@ -113,7 +113,7 @@ export default function StatisticsView() {
|
|||
label: 'Frota Total',
|
||||
value: totalVeiculos.toLocaleString(),
|
||||
icon: <Truck />,
|
||||
color: 'bg-emerald-500/10 text-emerald-600'
|
||||
color: 'bg-orange-500/10 text-orange-600'
|
||||
},
|
||||
{
|
||||
label: 'Em Manutenção (Aberta)',
|
||||
|
|
@ -152,8 +152,8 @@ export default function StatisticsView() {
|
|||
{/* Header com Design Premium */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-medium text-white tracking-tight">
|
||||
Dashboard <span className="text-emerald-500">Estatístico</span>
|
||||
<h1 className="text-4xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
Dashboard <span className="text-orange-500">Estatístico</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 font-medium text-lg">
|
||||
Monitoramento de BI e KPIs em tempo real da operação de frota.
|
||||
|
|
@ -167,7 +167,7 @@ export default function StatisticsView() {
|
|||
<RefreshCw size={18} className={statsLoading ? "animate-spin" : ""} />
|
||||
Sincronizar Dados
|
||||
</button>
|
||||
{/* <button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-emerald-600 text-white font-bold text-sm hover:bg-emerald-700 hover:shadow-xl hover:shadow-emerald-500/20 hover:-translate-y-0.5 transition-all">
|
||||
{/* <button className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-orange-600 text-white font-bold text-sm hover:bg-orange-700 hover:shadow-xl hover:shadow-orange-500/20 hover:-translate-y-0.5 transition-all">
|
||||
<Download size={18} />
|
||||
Exportar BI
|
||||
</button> */}
|
||||
|
|
@ -176,7 +176,7 @@ export default function StatisticsView() {
|
|||
|
||||
{/* KPI Section */}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-500/10 to-blue-500/10 blur-2xl opacity-50 -z-10" />
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-orange-500/10 to-blue-500/10 blur-2xl opacity-50 -z-10" />
|
||||
<StatsGrid stats={stats} />
|
||||
</div>
|
||||
|
||||
|
|
@ -267,7 +267,7 @@ export default function StatisticsView() {
|
|||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] bg-white dark:bg-[#0f0f0f] border-slate-200 dark:border-[#2a2a2a] p-0 overflow-hidden shadow-2xl flex flex-col !outline-none">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] shrink-0 bg-slate-50/50 dark:bg-[#0f0f0f]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-emerald-500/10 rounded-2xl text-emerald-600 shadow-inner">
|
||||
<div className="p-4 bg-orange-500/10 rounded-2xl text-orange-600 shadow-inner">
|
||||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -275,7 +275,7 @@ export default function StatisticsView() {
|
|||
Veículos: {selectedStatus?.status}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
Listagem completa dos {selectedStatus?.total} veículos com status <span className="text-emerald-500 font-bold">"{selectedStatus?.status}"</span>.
|
||||
Listagem completa dos {selectedStatus?.total} veículos com status <span className="text-orange-500 font-bold">"{selectedStatus?.status}"</span>.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -286,7 +286,7 @@ export default function StatisticsView() {
|
|||
<ExcelTable
|
||||
data={selectedStatus?.registros ? JSON.parse(selectedStatus.registros).filter(r => r !== null) : []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
|
|
@ -324,7 +324,7 @@ export default function StatisticsView() {
|
|||
<Card className="border-none shadow-sm hover:shadow-md transition-all overflow-hidden bg-white dark:bg-[#1c1c1c]">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b dark:border-[#2a2a2a] mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-xl text-emerald-600">
|
||||
<div className="p-2 bg-orange-500/10 rounded-xl text-orange-600">
|
||||
<LineChartIcon size={20} />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">Fluxo de Manutenção Mensal</CardTitle>
|
||||
|
|
@ -391,7 +391,7 @@ export default function StatisticsView() {
|
|||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Regional</span>
|
||||
<MapPin size={14} className="text-emerald-500" />
|
||||
<MapPin size={14} className="text-orange-500" />
|
||||
</div>
|
||||
<CardTitle className="text-lg font-bold">Veículos por Base</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -490,7 +490,7 @@ export default function StatisticsView() {
|
|||
<CardTitle className="text-xl font-bold">Disponibilidade Detalhada</CardTitle>
|
||||
<p className="text-sm text-slate-500">Breakdown por status de operação.</p>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-500/10 rounded-2xl text-emerald-600">
|
||||
<div className="p-3 bg-orange-500/10 rounded-2xl text-orange-600">
|
||||
<Gauge size={24} strokeWidth={2.5} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -500,7 +500,7 @@ export default function StatisticsView() {
|
|||
<div key={i} className="group cursor-default">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-slate-100 group-hover:text-emerald-500 transition-colors uppercase tracking-tight truncate">{item.status_disponibilidade || 'NÃO INFORMADO'}</span>
|
||||
<span className="text-sm font-bold text-slate-800 dark:text-slate-100 group-hover:text-orange-500 transition-colors uppercase tracking-tight truncate">{item.status_disponibilidade || 'NÃO INFORMADO'}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase truncate">{item.disponibilidade}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
|
|
@ -512,7 +512,7 @@ export default function StatisticsView() {
|
|||
</div>
|
||||
<div className="w-full h-2 bg-slate-100 dark:bg-[#2a2a2a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all duration-1000 ease-out"
|
||||
className="h-full bg-orange-500 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${(item.total / (totalVeiculos || 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -556,3 +556,6 @@ export default function StatisticsView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -27,7 +27,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -40,7 +40,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-lg font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -79,7 +79,7 @@ const StatusCell = ({ currentStatus, id, options, onUpdate }) => {
|
|||
value={tempValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-emerald-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-orange-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
|
|
@ -94,7 +94,7 @@ const StatusCell = ({ currentStatus, id, options, onUpdate }) => {
|
|||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
currentStatus === 'Disponível' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
currentStatus === 'Disponível' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
currentStatus === 'Em Manutenção' ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}
|
||||
|
|
@ -203,7 +203,7 @@ export default function StatusView() {
|
|||
{/* ... Header ... */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Status da Frota</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Status da Frota</h1>
|
||||
<p className="text-slate-500 text-sm">Histórico de movimentação e estados dos veículos.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
|
|
@ -211,7 +211,7 @@ export default function StatusView() {
|
|||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -232,7 +232,7 @@ export default function StatusView() {
|
|||
rowKey="idstatus_frota"
|
||||
columns={[
|
||||
{ header: 'ID', field: 'idstatus_frota', width: '80px' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'STATUS', field: 'status_frota', width: '150px', render: (row) => (
|
||||
<StatusCell
|
||||
currentStatus={row.status_frota}
|
||||
|
|
@ -327,7 +327,7 @@ export default function StatusView() {
|
|||
<div className="gap-4 pb-4">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">Observações / Motivo</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[80px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700 min-h-[80px]"
|
||||
value={formData.obs}
|
||||
onChange={e => setFormData({...formData, obs: e.target.value})}
|
||||
/>
|
||||
|
|
@ -344,3 +344,6 @@ export default function StatusView() {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -106,3 +106,6 @@ const TableDebug = () => {
|
|||
};
|
||||
|
||||
export default TableDebug;
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -33,7 +33,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -46,7 +46,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -87,7 +87,7 @@ const StatusCell = ({ currentStatus, idVehicle, options, onUpdate }) => {
|
|||
value={tempValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-emerald-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-orange-500 rounded px-1 py-0.5 text-[10px] uppercase font-bold text-slate-700 dark:text-slate-200 focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent row selection if applied
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
|
|
@ -102,7 +102,7 @@ const StatusCell = ({ currentStatus, idVehicle, options, onUpdate }) => {
|
|||
<div
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
currentStatus === 'ATIVO' ? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' :
|
||||
currentStatus === 'ATIVO' ? 'bg-orange-500/10 text-orange-500 border-orange-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -234,14 +234,14 @@ export default function VehiclesView() {
|
|||
{/* Header Actions */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight">Frota & Ativos</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">Frota & Ativos</h1>
|
||||
<p className="text-slate-500 text-sm">Gerencie os veículos cadastrados na plataforma.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64"
|
||||
placeholder="Buscar placa..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -273,10 +273,10 @@ export default function VehiclesView() {
|
|||
</div>
|
||||
<div
|
||||
onClick={() => handleStatusClick('Em Operação')}
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex items-center justify-between group cursor-pointer hover:border-emerald-500/30"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] rounded-xl p-6 shadow-sm hover:shadow-md transition-all flex items-center justify-between group cursor-pointer hover:border-orange-500/30"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-600 group-hover:bg-emerald-500 group-hover:text-white transition-colors">
|
||||
<div className="p-3 bg-orange-500/10 rounded-xl text-orange-600 group-hover:bg-orange-500 group-hover:text-white transition-colors">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -297,7 +297,7 @@ export default function VehiclesView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'idveiculo_frota', width: '80px' },
|
||||
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'PLACA', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'MODELO', field: 'modelo', width: '140px' },
|
||||
{ header: ' FABRICANTE', field: 'fabricante', width: '120px' },
|
||||
{ header: 'CORES', field: 'cor', width: '100px' },
|
||||
|
|
@ -347,7 +347,7 @@ export default function VehiclesView() {
|
|||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] bg-slate-50 dark:bg-[#1c1c1c]">
|
||||
<DialogTitle className="text-xl font-medium text-white uppercase tracking-tight">
|
||||
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
{editingVehicle ? `Editando: ${formData.placa}` : 'Cadastro de Novo Veículo'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-stone-500">
|
||||
|
|
@ -366,7 +366,7 @@ export default function VehiclesView() {
|
|||
<TabsTrigger
|
||||
key={tab}
|
||||
value={tab}
|
||||
className="data-[state=active]:bg-emerald-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
className="data-[state=active]:bg-orange-600 data-[state=active]:text-white text-slate-500 dark:text-stone-400 text-[10px] uppercase font-bold px-4 py-2"
|
||||
>
|
||||
{tab === 'tecnico' ? 'RASTREADOR' : tab}
|
||||
</TabsTrigger>
|
||||
|
|
@ -472,7 +472,7 @@ export default function VehiclesView() {
|
|||
<div className="gap-4 pt-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500 tracking-wider ml-1">Observações Gerais</label>
|
||||
<textarea
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-lg px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 transition-all placeholder:text-slate-400 min-h-[80px]"
|
||||
value={formData.observacoes}
|
||||
onChange={e => setFormData({...formData, observacoes: e.target.value})}
|
||||
/>
|
||||
|
|
@ -498,7 +498,7 @@ export default function VehiclesView() {
|
|||
<Truck size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-2xl font-medium text-white uppercase tracking-tight">
|
||||
<DialogTitle className="text-2xl font-bold text-slate-800 dark:text-white uppercase tracking-tight">
|
||||
Veículos: {selectedStatusRecords?.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400 font-medium text-sm">
|
||||
|
|
@ -513,7 +513,7 @@ export default function VehiclesView() {
|
|||
<ExcelTable
|
||||
data={selectedStatusRecords?.records || []}
|
||||
columns={[
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Placa', field: 'placa', width: '100px', className: 'font-mono font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Modelo', field: 'modelo', width: '150px' },
|
||||
{ header: 'Unidade', field: 'base', width: '120px' },
|
||||
{ header: 'Motorista', field: 'motorista', width: '180px' },
|
||||
|
|
@ -543,3 +543,6 @@ export default function VehiclesView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const DarkInput = ({ label, ...props }) => (
|
|||
<div className="space-y-1.5">
|
||||
{label && <label className="text-[10px] uppercase font-bold text-slate-500 dark:text-slate-400 tracking-wider ml-1">{label}</label>}
|
||||
<input
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all placeholder:text-slate-400 dark:placeholder:text-slate-700"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -23,7 +23,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition-all cursor-pointer"
|
||||
className="w-full bg-slate-50 dark:bg-[#141414] border border-slate-200 dark:border-[#333] rounded-xl px-3 py-2 text-sm text-slate-700 dark:text-slate-200 focus:outline-none focus:border-orange-500 focus:ring-1 focus:ring-orange-500 transition-all cursor-pointer"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map(opt => (
|
||||
|
|
@ -36,7 +36,7 @@ const DarkSelect = ({ label, options, value, onChange }) => (
|
|||
const DarkButton = ({ children, variant = 'primary', className = '', ...props }) => {
|
||||
const baseClass = "px-4 py-2 rounded-xl font-bold text-sm transition-all shadow-lg active:scale-95 flex items-center justify-center gap-2";
|
||||
const variants = {
|
||||
primary: "bg-emerald-600 hover:bg-emerald-500 text-white shadow-emerald-500/10",
|
||||
primary: "bg-orange-600 hover:bg-orange-500 text-white shadow-orange-500/10",
|
||||
secondary: "bg-slate-100 dark:bg-[#2a2a2a] hover:bg-slate-200 dark:hover:bg-[#333] text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-[#333]",
|
||||
ghost: "bg-transparent hover:bg-slate-100 dark:hover:bg-[#2a2a2a] text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||
};
|
||||
|
|
@ -107,14 +107,14 @@ export default function WorkshopsView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-white tracking-tight uppercase">Gestão de Oficinas</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight uppercase">Gestão de Oficinas</h1>
|
||||
<p className="text-slate-500 text-sm">Rede credenciada e prestadores de serviços.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 md:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
|
||||
<input
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-emerald-500 w-full md:w-64 transition-all"
|
||||
className="bg-white dark:bg-[#1c1c1c] border border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 pl-10 pr-4 py-2 rounded-xl text-sm focus:outline-none focus:border-orange-500 w-full md:w-64 transition-all"
|
||||
placeholder="Buscar por nome, CNPJ ou cidade..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
|
|
@ -134,7 +134,7 @@ export default function WorkshopsView() {
|
|||
columns={[
|
||||
{ header: 'ID', field: 'idoficinas_frota', width: '80px' },
|
||||
{ header: 'Cod. Estab.', field: 'cod_estabelecimento', width: '100px' },
|
||||
{ header: 'Nome Reduzido', field: 'nome_reduzido', width: '220px', className: 'font-bold text-emerald-600 dark:text-emerald-500' },
|
||||
{ header: 'Nome Reduzido', field: 'nome_reduzido', width: '220px', className: 'font-bold text-orange-600 dark:text-orange-500' },
|
||||
{ header: 'Razão Social', field: 'razao_social', width: '250px' },
|
||||
{ header: 'CNPJ', field: 'cnpj', width: '150px' },
|
||||
{ header: 'Tipo', field: 'tipo_estabelecimento', width: '180px' },
|
||||
|
|
@ -160,7 +160,7 @@ export default function WorkshopsView() {
|
|||
<DialogContent className="max-w-3xl bg-white dark:bg-[#1c1c1c] border-slate-200 dark:border-[#2a2a2a] text-slate-700 dark:text-slate-200 p-0 overflow-hidden shadow-2xl">
|
||||
<DialogHeader className="p-6 border-b border-slate-200 dark:border-[#2a2a2a] bg-slate-50/50 dark:bg-[#222]/30">
|
||||
<DialogTitle className="text-slate-800 dark:text-white uppercase font-bold flex items-center gap-2">
|
||||
<div className="w-2 h-6 bg-emerald-500 rounded-xl"></div>
|
||||
<div className="w-2 h-6 bg-orange-500 rounded-xl"></div>
|
||||
{editingItem ? 'Editar Oficina' : 'Cadastrar Nova Oficina'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500 dark:text-slate-400">
|
||||
|
|
@ -171,7 +171,7 @@ export default function WorkshopsView() {
|
|||
<form onSubmit={handleSubmit} className="px-6 py-6 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
|
||||
{/* Seção: Identificação */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[11px] font-bold text-emerald-600 dark:text-emerald-500 uppercase tracking-[0.2em] border-b border-emerald-500/20 pb-1">Identificação</h3>
|
||||
<h3 className="text-[11px] font-bold text-orange-600 dark:text-orange-500 uppercase tracking-[0.2em] border-b border-orange-500/20 pb-1">Identificação</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="md:col-span-1">
|
||||
<DarkInput label="Cod. Estabelecimento" value={formData.cod_estabelecimento} onChange={e => setFormData({...formData, cod_estabelecimento: e.target.value})} />
|
||||
|
|
@ -191,7 +191,7 @@ export default function WorkshopsView() {
|
|||
|
||||
{/* Seção: Localização e Contato */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[11px] font-bold text-emerald-600 dark:text-emerald-500 uppercase tracking-[0.2em] border-b border-emerald-500/20 pb-1">Localização e Contato</h3>
|
||||
<h3 className="text-[11px] font-bold text-orange-600 dark:text-orange-500 uppercase tracking-[0.2em] border-b border-orange-500/20 pb-1">Localização e Contato</h3>
|
||||
<DarkInput label="Endereço" value={formData.endereco} onChange={e => setFormData({...formData, endereco: e.target.value})} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
|
@ -216,3 +216,6 @@ export default function WorkshopsView() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue