🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus, Edit, Trash2, Save, X, Upload, Download, ChevronUp, ChevronDown, FileText } from 'lucide-react';
|
||||
import { useComponentesPeca, ComponenteFormData, ComponentePeca } from '@/hooks/useComponentesPeca';
|
||||
import { useComponentesTableActions } from '@/hooks/useComponentesTableActions';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface ComponentesPopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
peca: Peca;
|
||||
}
|
||||
|
||||
type SortField = 'marca_componente' | 'descricao' | 'perfil' | 'peso_unitario' | 'quantidade_por_peca';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
export function ComponentesPopup({ isOpen, onClose, peca }: ComponentesPopupProps) {
|
||||
const { user } = useAuth();
|
||||
const { componentes, loading, saveComponente, updateComponente, deleteComponente, loadComponentes } = useComponentesPeca(peca.id);
|
||||
const { importLoading, exportTemplateCSV, exportComponentesCSV, importComponentesCSV } = useComponentesTableActions();
|
||||
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<SortField>('marca_componente');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [formData, setFormData] = useState<ComponenteFormData>({
|
||||
marca_componente: '',
|
||||
descricao: '',
|
||||
perfil: '',
|
||||
peso_unitario: 0,
|
||||
quantidade_por_peca: 1
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
marca_componente: '',
|
||||
descricao: '',
|
||||
perfil: '',
|
||||
peso_unitario: 0,
|
||||
quantidade_por_peca: 1
|
||||
});
|
||||
setIsAdding(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) {
|
||||
return <ChevronUp className="h-3 w-3 opacity-30" />;
|
||||
}
|
||||
return sortOrder === 'asc' ?
|
||||
<ChevronUp className="h-3 w-3" /> :
|
||||
<ChevronDown className="h-3 w-3" />;
|
||||
};
|
||||
|
||||
const sortedComponentes = [...componentes].sort((a, b) => {
|
||||
let aValue = a[sortField];
|
||||
let bValue = b[sortField];
|
||||
|
||||
if (aValue === null || aValue === undefined) aValue = '';
|
||||
if (bValue === null || bValue === undefined) bValue = '';
|
||||
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aStr.localeCompare(bStr, undefined, { numeric: true });
|
||||
} else {
|
||||
return bStr.localeCompare(aStr, undefined, { numeric: true });
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.marca_componente) {
|
||||
return;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (editingId) {
|
||||
success = await updateComponente(editingId, formData);
|
||||
} else {
|
||||
success = await saveComponente(formData, peca.id);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (componente: ComponentePeca) => {
|
||||
setFormData({
|
||||
marca_componente: componente.marca_componente,
|
||||
descricao: componente.descricao || '',
|
||||
perfil: componente.perfil || '',
|
||||
peso_unitario: componente.peso_unitario,
|
||||
quantidade_por_peca: componente.quantidade_por_peca
|
||||
});
|
||||
setEditingId(componente.id);
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Tem certeza que deseja apagar este componente?')) {
|
||||
await deleteComponente(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCSVImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && user) {
|
||||
importComponentesCSV(file, peca.id, user.id, loadComponentes);
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const calculateTotalQuantity = (componenteQtd: number) => {
|
||||
return componenteQtd * (peca.quantidade || 1);
|
||||
};
|
||||
|
||||
const calculateTotalWeight = (componenteQtd: number, pesoUnitario: number) => {
|
||||
return calculateTotalQuantity(componenteQtd) * pesoUnitario;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Componentes da Peça: {peca.marca}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
OF: {peca.of_number} | Fase: {peca.etapa_fase || 'N/A'} | Quantidade: {peca.quantidade}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Formulário de Adição/Edição */}
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
{editingId ? 'Editar Componente' : 'Adicionar Componente'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="marca_componente" className="text-sm">Componente *</Label>
|
||||
<Input
|
||||
id="marca_componente"
|
||||
value={formData.marca_componente}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, marca_componente: e.target.value }))}
|
||||
placeholder="1000-9999"
|
||||
maxLength={4}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="descricao" className="text-sm">Descrição</Label>
|
||||
<Input
|
||||
id="descricao"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição do componente"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="perfil" className="text-sm">Perfil</Label>
|
||||
<Input
|
||||
id="perfil"
|
||||
value={formData.perfil}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, perfil: e.target.value }))}
|
||||
placeholder="Perfil do componente"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="peso_unitario" className="text-sm">Peso Unitário</Label>
|
||||
<Input
|
||||
id="peso_unitario"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.peso_unitario}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, peso_unitario: parseFloat(e.target.value) || 0 }))}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="quantidade_por_peca" className="text-sm">Qtd por Peça</Label>
|
||||
<Input
|
||||
id="quantidade_por_peca"
|
||||
type="number"
|
||||
value={formData.quantidade_por_peca}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, quantidade_por_peca: parseInt(e.target.value) || 1 }))}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} size="sm" className="h-8">
|
||||
<Save className="h-3 w-3 mr-2" />
|
||||
{editingId ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetForm} size="sm" className="h-8">
|
||||
<X className="h-3 w-3 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Botões de ação */}
|
||||
{!isAdding && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => setIsAdding(true)} size="sm" className="h-8">
|
||||
<Plus className="h-3 w-3 mr-2" />
|
||||
Adicionar Componente
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleCSVImport}
|
||||
disabled={importLoading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button variant="outline" size="sm" disabled={importLoading} className="h-8">
|
||||
<Upload className="h-3 w-3 mr-2" />
|
||||
{importLoading ? 'Importando...' : 'Importar CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={exportTemplateCSV} size="sm" className="h-8">
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Modelo Tabela
|
||||
</Button>
|
||||
|
||||
{componentes.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => exportComponentesCSV(componentes, peca.marca)}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabela de Componentes */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Componentes Cadastrados ({componentes.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm">Carregando componentes...</div>
|
||||
) : componentes.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
Nenhum componente cadastrado para esta peça
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-8">
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none px-2 py-1"
|
||||
onClick={() => handleSort('marca_componente')}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
Componente
|
||||
{getSortIcon('marca_componente')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none px-2 py-1"
|
||||
onClick={() => handleSort('descricao')}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
Descrição
|
||||
{getSortIcon('descricao')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none px-2 py-1"
|
||||
onClick={() => handleSort('perfil')}
|
||||
>
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
Perfil
|
||||
{getSortIcon('perfil')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none text-center px-2 py-1"
|
||||
onClick={() => handleSort('quantidade_por_peca')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1 text-xs font-medium">
|
||||
Qtd/Peça
|
||||
{getSortIcon('quantidade_por_peca')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center px-2 py-1">
|
||||
<span className="text-xs font-medium">Qtd Total</span>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 select-none text-right px-2 py-1"
|
||||
onClick={() => handleSort('peso_unitario')}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1 text-xs font-medium">
|
||||
Peso Unit.
|
||||
{getSortIcon('peso_unitario')}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right px-2 py-1">
|
||||
<span className="text-xs font-medium">Peso Total</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-center px-2 py-1 w-20">
|
||||
<span className="text-xs font-medium">Ações</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedComponentes.map((componente) => (
|
||||
<TableRow key={componente.id} className="h-8 hover:bg-muted/50">
|
||||
<TableCell className="font-medium px-2 py-1 text-sm">{componente.marca_componente}</TableCell>
|
||||
<TableCell className="px-2 py-1 text-sm">{componente.descricao || '-'}</TableCell>
|
||||
<TableCell className="px-2 py-1 text-sm">{componente.perfil || '-'}</TableCell>
|
||||
<TableCell className="text-center px-2 py-1 text-sm">{componente.quantidade_por_peca}</TableCell>
|
||||
<TableCell className="text-center font-medium px-2 py-1 text-sm">
|
||||
{calculateTotalQuantity(componente.quantidade_por_peca)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right px-2 py-1 text-sm">{componente.peso_unitario.toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right font-medium px-2 py-1 text-sm">
|
||||
{calculateTotalWeight(componente.quantidade_por_peca, componente.peso_unitario).toFixed(1)}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(componente)}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(componente.id)}
|
||||
className="h-5 w-5 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { AlertTriangle, Check, X, Upload, FileSpreadsheet, Weight, AlertCircle, Edit3 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { parseCSV } from '@/utils/csvUtils';
|
||||
|
||||
interface PecaCSVData {
|
||||
of_number: string;
|
||||
etapa_fase: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
quantidade: number;
|
||||
peso_unitario: number;
|
||||
peso_total: number;
|
||||
tratamento_superficial: string;
|
||||
material: string;
|
||||
perfil_principal: string;
|
||||
tem_componentes: boolean;
|
||||
marca_componente?: string;
|
||||
descricao_componente?: string;
|
||||
perfil_componente?: string;
|
||||
peso_unitario_componente?: number;
|
||||
quantidade_por_peca?: number;
|
||||
}
|
||||
|
||||
interface DuplicateInfo {
|
||||
index: number;
|
||||
marca: string;
|
||||
of_number: string;
|
||||
etapa_fase: string;
|
||||
isDuplicate: boolean;
|
||||
}
|
||||
|
||||
interface ValidationError {
|
||||
row: number;
|
||||
column: string;
|
||||
value: any;
|
||||
error: string;
|
||||
suggestion: string;
|
||||
canAutoFix: boolean;
|
||||
}
|
||||
|
||||
interface ImportarPecasModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImport: (pecas: PecaCSVData[]) => Promise<void>;
|
||||
pecasExistentes: Array<{ marca: string; of_number: string; etapa_fase: string }>;
|
||||
}
|
||||
|
||||
export function ImportarPecasModal({ open, onOpenChange, onImport, pecasExistentes }: ImportarPecasModalProps) {
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvData, setCsvData] = useState<PecaCSVData[]>([]);
|
||||
const [rawData, setRawData] = useState<any[]>([]);
|
||||
const [duplicates, setDuplicates] = useState<DuplicateInfo[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [step, setStep] = useState<'upload' | 'preview'>('upload');
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
const requiredHeaders = [
|
||||
'of_number', 'etapa_fase', 'marca', 'descricao', 'quantidade',
|
||||
'peso_unitario', 'peso_total', 'tratamento_superficial', 'material',
|
||||
'perfil_principal', 'tem_componentes', 'marca_componente',
|
||||
'descricao_componente', 'perfil_componente', 'peso_unitario_componente',
|
||||
'quantidade_por_peca'
|
||||
];
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && file.type === 'text/csv') {
|
||||
setCsvFile(file);
|
||||
console.log('📁 Arquivo CSV selecionado:', file.name, 'Tamanho:', file.size);
|
||||
} else {
|
||||
toast.error('Por favor, selecione um arquivo CSV válido');
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndCleanData = (rawRow: any, rowIndex: number): { data: PecaCSVData | null; errors: ValidationError[] } => {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
console.log(`🔍 Validando linha ${rowIndex + 1}:`, rawRow);
|
||||
|
||||
// Verificar campos obrigatórios
|
||||
const requiredStringFields = ['of_number', 'marca', 'descricao'];
|
||||
requiredStringFields.forEach(field => {
|
||||
if (!rawRow[field] || rawRow[field].toString().trim() === '') {
|
||||
errors.push({
|
||||
row: rowIndex + 1,
|
||||
column: field,
|
||||
value: rawRow[field],
|
||||
error: 'Campo obrigatório vazio',
|
||||
suggestion: 'Preencha este campo obrigatório',
|
||||
canAutoFix: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Validar e converter campos numéricos
|
||||
const numericFields = ['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca'];
|
||||
const convertedData: any = { ...rawRow };
|
||||
|
||||
numericFields.forEach(field => {
|
||||
const value = rawRow[field];
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
// Limpar e converter string para número
|
||||
let cleanValue = value.toString().replace(',', '.');
|
||||
const numValue = parseFloat(cleanValue);
|
||||
|
||||
if (isNaN(numValue)) {
|
||||
errors.push({
|
||||
row: rowIndex + 1,
|
||||
column: field,
|
||||
value: value,
|
||||
error: 'Valor numérico inválido',
|
||||
suggestion: `Corrigir para um número válido (ex: ${field === 'quantidade' ? '10' : '152.5'})`,
|
||||
canAutoFix: /^[\d,\.]+$/.test(value.toString())
|
||||
});
|
||||
convertedData[field] = 0;
|
||||
} else {
|
||||
// Para peso_unitario e peso_total, arredondar para integer
|
||||
if (field === 'peso_unitario' || field === 'peso_total') {
|
||||
convertedData[field] = Math.round(numValue);
|
||||
} else {
|
||||
convertedData[field] = numValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
convertedData[field] = field.includes('componente') ? 0 : (field === 'quantidade' ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Validar campo booleano
|
||||
const boolValue = rawRow.tem_componentes;
|
||||
if (boolValue !== null && boolValue !== undefined) {
|
||||
const strValue = boolValue.toString().toLowerCase().trim();
|
||||
if (['true', '1', 'sim', 'yes'].includes(strValue)) {
|
||||
convertedData.tem_componentes = true;
|
||||
} else if (['false', '0', 'não', 'nao', 'no'].includes(strValue)) {
|
||||
convertedData.tem_componentes = false;
|
||||
} else {
|
||||
errors.push({
|
||||
row: rowIndex + 1,
|
||||
column: 'tem_componentes',
|
||||
value: boolValue,
|
||||
error: 'Valor booleano inválido',
|
||||
suggestion: 'Use: true/false, 1/0, sim/não',
|
||||
canAutoFix: true
|
||||
});
|
||||
convertedData.tem_componentes = false;
|
||||
}
|
||||
} else {
|
||||
convertedData.tem_componentes = false;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`❌ Erros na linha ${rowIndex + 1}:`, errors);
|
||||
return { data: null, errors };
|
||||
}
|
||||
|
||||
// Criar objeto final
|
||||
const processedData: PecaCSVData = {
|
||||
of_number: convertedData.of_number?.toString().trim() || '',
|
||||
etapa_fase: convertedData.etapa_fase?.toString().trim() || '',
|
||||
marca: convertedData.marca?.toString().trim() || '',
|
||||
descricao: convertedData.descricao?.toString().trim() || '',
|
||||
quantidade: convertedData.quantidade || 0,
|
||||
peso_unitario: convertedData.peso_unitario || 0,
|
||||
peso_total: convertedData.peso_total || 0,
|
||||
tratamento_superficial: convertedData.tratamento_superficial?.toString().trim() || '',
|
||||
material: convertedData.material?.toString().trim() || '',
|
||||
perfil_principal: convertedData.perfil_principal?.toString().trim() || '',
|
||||
tem_componentes: convertedData.tem_componentes,
|
||||
marca_componente: convertedData.marca_componente?.toString().trim() || '',
|
||||
descricao_componente: convertedData.descricao_componente?.toString().trim() || '',
|
||||
perfil_componente: convertedData.perfil_componente?.toString().trim() || '',
|
||||
peso_unitario_componente: convertedData.peso_unitario_componente || 0,
|
||||
quantidade_por_peca: convertedData.quantidade_por_peca || 0
|
||||
};
|
||||
|
||||
console.log(`✅ Linha ${rowIndex + 1} processada:`, processedData);
|
||||
return { data: processedData, errors: [] };
|
||||
};
|
||||
|
||||
const processCSV = async () => {
|
||||
if (!csvFile) return;
|
||||
|
||||
console.log('🔄 Processando arquivo CSV...');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const text = await csvFile.text();
|
||||
const parsed = parseCSV(text);
|
||||
|
||||
console.log('📊 Dados parseados:', parsed.length, 'linhas');
|
||||
|
||||
if (parsed.length === 0) {
|
||||
toast.error('Arquivo CSV vazio ou inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
setRawData(parsed);
|
||||
|
||||
// Verificar headers
|
||||
const firstRow = parsed[0];
|
||||
const missingHeaders = requiredHeaders.filter(header => !(header in firstRow));
|
||||
|
||||
if (missingHeaders.length > 0) {
|
||||
console.error('❌ Headers obrigatórios ausentes:', missingHeaders);
|
||||
toast.error(`Colunas obrigatórias ausentes: ${missingHeaders.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Processar e validar cada linha
|
||||
const validData: PecaCSVData[] = [];
|
||||
const allErrors: ValidationError[] = [];
|
||||
|
||||
parsed.forEach((row, index) => {
|
||||
const { data, errors } = validateAndCleanData(row, index);
|
||||
if (data) {
|
||||
validData.push(data);
|
||||
}
|
||||
allErrors.push(...errors);
|
||||
});
|
||||
|
||||
console.log('✅ Linhas válidas processadas:', validData.length);
|
||||
console.log('❌ Erros encontrados:', allErrors.length);
|
||||
|
||||
setValidationErrors(allErrors);
|
||||
|
||||
// Verificar duplicatas para dados válidos
|
||||
const duplicateInfo: DuplicateInfo[] = validData.map((peca, index) => {
|
||||
const isDuplicate = pecasExistentes.some(existente =>
|
||||
existente.marca === peca.marca &&
|
||||
existente.of_number === peca.of_number &&
|
||||
existente.etapa_fase === peca.etapa_fase
|
||||
);
|
||||
|
||||
return {
|
||||
index,
|
||||
marca: peca.marca,
|
||||
of_number: peca.of_number,
|
||||
etapa_fase: peca.etapa_fase,
|
||||
isDuplicate
|
||||
};
|
||||
});
|
||||
|
||||
setCsvData(validData);
|
||||
setDuplicates(duplicateInfo);
|
||||
setStep('preview');
|
||||
|
||||
// Mostrar resumo
|
||||
if (allErrors.length > 0) {
|
||||
toast.warning(`${parsed.length - validData.length} linha(s) com problemas encontradas. Verifique os detalhes.`);
|
||||
} else {
|
||||
toast.success(`${validData.length} peça(s) válida(s) encontrada(s) para importação!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao processar CSV:', error);
|
||||
toast.error('Erro ao processar arquivo CSV');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoFix = () => {
|
||||
const fixableErrors = validationErrors.filter(err => err.canAutoFix);
|
||||
|
||||
if (fixableErrors.length === 0) {
|
||||
toast.info('Não há erros que possam ser corrigidos automaticamente');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aplicar correções automáticas e reprocessar
|
||||
const correctedRawData = rawData.map((row, index) => {
|
||||
const rowErrors = fixableErrors.filter(err => err.row === index + 1);
|
||||
const correctedRow = { ...row };
|
||||
|
||||
rowErrors.forEach(error => {
|
||||
if (error.column === 'tem_componentes') {
|
||||
correctedRow[error.column] = 'false';
|
||||
} else if (['quantidade', 'peso_unitario', 'peso_total', 'peso_unitario_componente', 'quantidade_por_peca'].includes(error.column)) {
|
||||
const cleaned = error.value.toString().replace(/[^\d,\.]/g, '').replace(',', '.');
|
||||
correctedRow[error.column] = cleaned;
|
||||
}
|
||||
});
|
||||
|
||||
return correctedRow;
|
||||
});
|
||||
|
||||
// Reprocessar com dados corrigidos
|
||||
const validData: PecaCSVData[] = [];
|
||||
const remainingErrors: ValidationError[] = [];
|
||||
|
||||
correctedRawData.forEach((row, index) => {
|
||||
const { data, errors } = validateAndCleanData(row, index);
|
||||
if (data) {
|
||||
validData.push(data);
|
||||
}
|
||||
remainingErrors.push(...errors);
|
||||
});
|
||||
|
||||
setValidationErrors(remainingErrors);
|
||||
setCsvData(validData);
|
||||
|
||||
toast.success(`${fixableErrors.length} erro(s) corrigido(s) automaticamente`);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const validPecas = csvData.filter((_, index) => !duplicates[index]?.isDuplicate);
|
||||
|
||||
console.log('🚀 Iniciando importação de', validPecas.length, 'peças...');
|
||||
|
||||
if (validPecas.length === 0) {
|
||||
toast.error('Nenhuma peça válida para importar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onImport(validPecas);
|
||||
console.log('✅ Importação concluída com sucesso!');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('❌ Erro na importação:', error);
|
||||
toast.error(`Erro ao importar peças: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
console.log('🔒 Fechando modal de importação');
|
||||
setCsvFile(null);
|
||||
setCsvData([]);
|
||||
setRawData([]);
|
||||
setDuplicates([]);
|
||||
setValidationErrors([]);
|
||||
setStep('upload');
|
||||
setShowErrors(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const duplicateCount = duplicates.filter(d => d.isDuplicate).length;
|
||||
const validCount = csvData.length - duplicateCount;
|
||||
const totalRows = rawData.length;
|
||||
const errorCount = validationErrors.length;
|
||||
const rowsWithErrors = new Set(validationErrors.map(err => err.row)).size;
|
||||
|
||||
const totalWeight = csvData
|
||||
.filter((_, index) => !duplicates[index]?.isDuplicate)
|
||||
.reduce((sum, peca) => sum + peca.peso_total, 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
Importar Peças via CSV
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="csv-file">Selecionar arquivo CSV</Label>
|
||||
<Input
|
||||
id="csv-file"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{csvFile && (
|
||||
<Alert>
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Arquivo selecionado: {csvFile.name} ({(csvFile.size / 1024).toFixed(1)} KB)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">Formato do arquivo CSV:</p>
|
||||
<p className="mb-1">Colunas obrigatórias (em ordem):</p>
|
||||
<div className="text-xs bg-muted p-3 rounded leading-relaxed break-words">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>of_number, etapa_fase, marca, descricao,</div>
|
||||
<div>quantidade, peso_unitario, peso_total,</div>
|
||||
<div>tratamento_superficial, material,</div>
|
||||
<div>perfil_principal, tem_componentes,</div>
|
||||
<div>marca_componente, descricao_componente,</div>
|
||||
<div>perfil_componente, peso_unitario_componente,</div>
|
||||
<div>quantidade_por_peca</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
||||
<FileSpreadsheet className="h-3 w-3 mr-1" />
|
||||
{totalRows} total
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
{validCount} válidas
|
||||
</Badge>
|
||||
{duplicateCount > 0 && (
|
||||
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{duplicateCount} duplicatas
|
||||
</Badge>
|
||||
)}
|
||||
{rowsWithErrors > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{rowsWithErrors} com erros
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{rowsWithErrors > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowErrors(!showErrors)}
|
||||
className="text-orange-600 border-orange-200"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 mr-1" />
|
||||
{showErrors ? 'Ocultar' : 'Ver'} Erros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{duplicateCount > 0 && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{duplicateCount} peça(s) já existem no banco de dados e serão ignoradas na importação.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rowsWithErrors > 0 && !showErrors && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>
|
||||
{rowsWithErrors} linha(s) não puderam ser processadas devido a erros nos dados.
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{validationErrors.some(err => err.canAutoFix) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoFix}
|
||||
className="text-blue-600"
|
||||
>
|
||||
<Edit3 className="h-3 w-3 mr-1" />
|
||||
Corrigir Automaticamente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showErrors && rowsWithErrors > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Erros Encontrados:</h4>
|
||||
{validationErrors.some(err => err.canAutoFix) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAutoFix}
|
||||
className="text-blue-600"
|
||||
>
|
||||
<Edit3 className="h-3 w-3 mr-1" />
|
||||
Corrigir Automaticamente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-32 border rounded-md">
|
||||
<div className="p-3 space-y-1">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index} className="text-xs p-2 bg-orange-50 rounded border-l-2 border-orange-300">
|
||||
<div className="font-medium text-orange-800">
|
||||
Linha {error.row}, Coluna "{error.column}"
|
||||
</div>
|
||||
<div className="text-orange-700">
|
||||
Valor: "{error.value}" - {error.error}
|
||||
</div>
|
||||
<div className="text-orange-600 italic">
|
||||
Sugestão: {error.suggestion}
|
||||
{error.canAutoFix && <span className="text-blue-600 ml-1">(Pode ser corrigido automaticamente)</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-64 border rounded-md">
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-10 gap-2 text-xs font-medium border-b pb-2 mb-2">
|
||||
<div>Status</div>
|
||||
<div>OF</div>
|
||||
<div>Fase</div>
|
||||
<div>Marca</div>
|
||||
<div>Descrição</div>
|
||||
<div>Qtd</div>
|
||||
<div>Peso Unit.</div>
|
||||
<div>Peso Total</div>
|
||||
<div>Material</div>
|
||||
<div>Perfil</div>
|
||||
</div>
|
||||
{csvData.map((peca, index) => {
|
||||
const duplicate = duplicates.find(d => d.index === index);
|
||||
return (
|
||||
<div key={index} className={`grid grid-cols-10 gap-2 text-xs py-1 ${duplicate?.isDuplicate ? 'text-red-600 bg-red-50' : 'text-green-600'}`}>
|
||||
<div>
|
||||
{duplicate?.isDuplicate ? (
|
||||
<X className="h-3 w-3" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<div>{peca.of_number}</div>
|
||||
<div>{peca.etapa_fase}</div>
|
||||
<div>{peca.marca}</div>
|
||||
<div className="truncate" title={peca.descricao}>{peca.descricao}</div>
|
||||
<div>{peca.quantidade}</div>
|
||||
<div>{peca.peso_unitario}</div>
|
||||
<div>{peca.peso_total}</div>
|
||||
<div className="truncate" title={peca.material}>{peca.material}</div>
|
||||
<div className="truncate" title={peca.perfil_principal}>{peca.perfil_principal}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{step === 'preview' && validCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Weight className="h-4 w-4" />
|
||||
<span>Peso Total: {totalWeight.toFixed(2)} kg</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{step === 'upload' ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={processCSV}
|
||||
disabled={!csvFile || isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{isLoading ? 'Processando...' : 'Processar CSV'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep('upload')}
|
||||
>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={validCount === 0 || isLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
{isLoading ? 'Importando...' : `Importar ${validCount} peças`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload, FileText, Trash2, Download, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { ImportarPecasModal } from './ImportarPecasModal';
|
||||
|
||||
interface PecaFormProps {
|
||||
ofNumbers: string[];
|
||||
onSave: (data: any) => Promise<boolean>;
|
||||
onUpdate: (id: string, data: any) => Promise<boolean>;
|
||||
onImportCSV: (file: File) => Promise<boolean>;
|
||||
saving: boolean;
|
||||
editingPeca?: Peca | null;
|
||||
onCancelEdit: () => void;
|
||||
hasRecentImport: boolean;
|
||||
onDeleteLastImport: () => void;
|
||||
pecas: Peca[];
|
||||
onImportPecas: (pecas: any[]) => Promise<void>;
|
||||
onUndoLastImport: () => void;
|
||||
canImport: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export function PecaForm({
|
||||
ofNumbers,
|
||||
onSave,
|
||||
onUpdate,
|
||||
onImportCSV,
|
||||
saving,
|
||||
editingPeca,
|
||||
onCancelEdit,
|
||||
hasRecentImport,
|
||||
onDeleteLastImport,
|
||||
pecas,
|
||||
onImportPecas,
|
||||
onUndoLastImport,
|
||||
canImport,
|
||||
canDelete
|
||||
}: PecaFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
of_number: '',
|
||||
etapa_fase: '',
|
||||
marca: '',
|
||||
descricao: '',
|
||||
quantidade: 1,
|
||||
peso_unitario: 0,
|
||||
peso_total: 0,
|
||||
tratamento_superficial: '',
|
||||
material: '',
|
||||
perfil_principal: '',
|
||||
tem_componentes: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPeca) {
|
||||
setFormData({
|
||||
of_number: editingPeca.of_number,
|
||||
etapa_fase: editingPeca.etapa_fase,
|
||||
marca: editingPeca.marca,
|
||||
descricao: editingPeca.descricao || '',
|
||||
quantidade: editingPeca.quantidade,
|
||||
peso_unitario: editingPeca.peso_unitario,
|
||||
peso_total: editingPeca.peso_total,
|
||||
tratamento_superficial: editingPeca.tratamento_superficial || '',
|
||||
material: editingPeca.material || '',
|
||||
perfil_principal: editingPeca.perfil_principal || '',
|
||||
tem_componentes: editingPeca.tem_componentes || false
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
of_number: '',
|
||||
etapa_fase: '',
|
||||
marca: '',
|
||||
descricao: '',
|
||||
quantidade: 1,
|
||||
peso_unitario: 0,
|
||||
peso_total: 0,
|
||||
tratamento_superficial: '',
|
||||
material: '',
|
||||
perfil_principal: '',
|
||||
tem_componentes: false
|
||||
});
|
||||
}
|
||||
}, [editingPeca]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.type !== 'text/csv' && !file.name.endsWith('.csv')) {
|
||||
toast.error('Por favor, selecione um arquivo CSV');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onImportCSV(file);
|
||||
if (success && fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, [field]: value };
|
||||
|
||||
if (field === 'quantidade' || field === 'peso_unitario') {
|
||||
newData.peso_total = newData.quantidade * newData.peso_unitario;
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.of_number || !formData.marca) {
|
||||
toast.error('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (editingPeca) {
|
||||
success = await onUpdate(editingPeca.id, formData);
|
||||
} else {
|
||||
success = await onSave(formData);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setFormData({
|
||||
of_number: '',
|
||||
etapa_fase: '',
|
||||
marca: '',
|
||||
descricao: '',
|
||||
quantidade: 1,
|
||||
peso_unitario: 0,
|
||||
peso_total: 0,
|
||||
tratamento_superficial: '',
|
||||
material: '',
|
||||
perfil_principal: '',
|
||||
tem_componentes: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="of_number">Número OF *</Label>
|
||||
<Select
|
||||
value={formData.of_number}
|
||||
onValueChange={(value) => handleInputChange('of_number', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofNumbers.map(ofNumber => (
|
||||
<SelectItem key={ofNumber} value={ofNumber}>
|
||||
{ofNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="etapa_fase">Etapa/Fase</Label>
|
||||
<Input
|
||||
id="etapa_fase"
|
||||
value={formData.etapa_fase}
|
||||
onChange={(e) => handleInputChange('etapa_fase', e.target.value)}
|
||||
placeholder="Ex: Fabricação"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="marca">Marca *</Label>
|
||||
<Input
|
||||
id="marca"
|
||||
value={formData.marca}
|
||||
onChange={(e) => handleInputChange('marca', e.target.value)}
|
||||
placeholder="Marca da peça"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea
|
||||
id="descricao"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => handleInputChange('descricao', e.target.value)}
|
||||
placeholder="Descrição detalhada da peça"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="quantidade">Quantidade</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.quantidade}
|
||||
onChange={(e) => handleInputChange('quantidade', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="peso_unitario">Peso Unitário (kg)</Label>
|
||||
<Input
|
||||
id="peso_unitario"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.peso_unitario}
|
||||
onChange={(e) => handleInputChange('peso_unitario', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="peso_total">Peso Total (kg)</Label>
|
||||
<Input
|
||||
id="peso_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.peso_total.toFixed(2)}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tratamento_superficial">Tratamento Superficial</Label>
|
||||
<Input
|
||||
id="tratamento_superficial"
|
||||
value={formData.tratamento_superficial}
|
||||
onChange={(e) => handleInputChange('tratamento_superficial', e.target.value)}
|
||||
placeholder="Ex: Galvanização, Pintura"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="material">Material</Label>
|
||||
<Input
|
||||
id="material"
|
||||
value={formData.material}
|
||||
onChange={(e) => handleInputChange('material', e.target.value)}
|
||||
placeholder="Ex: Aço A36"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="perfil_principal">Perfil Principal</Label>
|
||||
<Input
|
||||
id="perfil_principal"
|
||||
value={formData.perfil_principal}
|
||||
onChange={(e) => handleInputChange('perfil_principal', e.target.value)}
|
||||
placeholder="Ex: Viga H, Coluna"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="tem_componentes"
|
||||
checked={formData.tem_componentes}
|
||||
onCheckedChange={(checked) => handleInputChange('tem_componentes', checked)}
|
||||
/>
|
||||
<Label htmlFor="tem_componentes">Esta peça possui componentes</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? 'Salvando...' : editingPeca ? 'Atualizar Peça' : 'Salvar Peça'}
|
||||
</Button>
|
||||
|
||||
{editingPeca && (
|
||||
<Button type="button" variant="outline" onClick={onCancelEdit}>
|
||||
Cancelar Edição
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{canImport && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Importar Peças</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Importar CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Importar de Lista
|
||||
</Button>
|
||||
|
||||
{hasRecentImport && canDelete && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onUndoLastImport}
|
||||
className="flex items-center gap-2 text-yellow-600 hover:text-yellow-700"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
Desfazer Importação
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onDeleteLastImport}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Apagar Última Importação
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = '/modelo_pecas.csv';
|
||||
link.download = 'modelo_pecas.csv';
|
||||
link.click();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Modelo CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ImportarPecasModal
|
||||
open={showImportModal}
|
||||
onOpenChange={setShowImportModal}
|
||||
onImport={onImportPecas}
|
||||
pecasExistentes={pecas.map(peca => ({
|
||||
marca: peca.marca,
|
||||
of_number: peca.of_number,
|
||||
etapa_fase: peca.etapa_fase
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableHeader } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Package } from 'lucide-react';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { usePecasTable } from '@/hooks/usePecasTable';
|
||||
import { TableFilters } from './table/TableFilters';
|
||||
import { TableActions } from './table/TableActions';
|
||||
import { TableHeaderSortable } from './table/TableHeaderSortable';
|
||||
import { PDFGenerator } from './table/PDFGenerator';
|
||||
import { BatchEditButton } from './table/BatchEditButton';
|
||||
import { TableRow as PecaTableRow } from './table/TableRow';
|
||||
import { EmptyState } from './table/EmptyState';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PecasTableProps {
|
||||
pecas: Peca[];
|
||||
onOpenComponentPopup: (pecaId: string) => void;
|
||||
onDeletePeca?: (pecaId: string) => void;
|
||||
onEditPeca?: (peca: Peca) => void;
|
||||
onDeleteLastImport?: () => void;
|
||||
onBatchUpdatePecas?: (pecaIds: string[], updates: any) => Promise<void>;
|
||||
hasRecentImport: boolean;
|
||||
canEdit?: boolean;
|
||||
canDelete?: boolean;
|
||||
ofNumber?: string;
|
||||
}
|
||||
|
||||
export function PecasTable({
|
||||
pecas,
|
||||
onOpenComponentPopup,
|
||||
onDeletePeca,
|
||||
onEditPeca,
|
||||
onDeleteLastImport,
|
||||
onBatchUpdatePecas,
|
||||
hasRecentImport,
|
||||
canEdit = true,
|
||||
canDelete = true,
|
||||
ofNumber = ''
|
||||
}: PecasTableProps) {
|
||||
const {
|
||||
ofFilter,
|
||||
setOfFilter,
|
||||
faseFilter,
|
||||
setFaseFilter,
|
||||
pecaFilter,
|
||||
setPecaFilter,
|
||||
descricaoFilter,
|
||||
setDescricaoFilter,
|
||||
priorityFilter,
|
||||
setPriorityFilter,
|
||||
semComponentesFilter,
|
||||
setSemComponentesFilter,
|
||||
sortField,
|
||||
sortOrder,
|
||||
handleSort,
|
||||
filteredAndSortedPecas,
|
||||
selectedPecas,
|
||||
selectAll,
|
||||
handleSelectAll,
|
||||
handleSelectPeca,
|
||||
searchPecasByComponent,
|
||||
pesoTotal
|
||||
} = usePecasTable(pecas);
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const selectedPecasArray = Array.from(selectedPecas);
|
||||
const pecasToExport = pecas.filter(peca => selectedPecasArray.includes(peca.id));
|
||||
|
||||
if (pecasToExport.length === 0) {
|
||||
toast.error('Nenhuma peça selecionada para exportar.');
|
||||
return;
|
||||
}
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8," +
|
||||
[
|
||||
Object.keys(pecasToExport[0]).join(','),
|
||||
...pecasToExport.map(peca => Object.values(peca).join(','))
|
||||
].join('\n');
|
||||
|
||||
const encodedUri = encodeURI(csvContent);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", encodedUri);
|
||||
link.setAttribute("download", "pecas.csv");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (!canDelete || !onDeletePeca) {
|
||||
toast.error('Você não tem permissão para excluir peças');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Tem certeza que deseja excluir ${selectedPecas.size} peças?`)) {
|
||||
try {
|
||||
for (const pecaId of selectedPecas) {
|
||||
await onDeletePeca(pecaId);
|
||||
}
|
||||
toast.success('Peças excluídas com sucesso!');
|
||||
} catch (error) {
|
||||
toast.error('Erro ao excluir peças.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUpdate = async (updates: any) => {
|
||||
if (!canEdit || !onBatchUpdatePecas) {
|
||||
toast.error('Você não tem permissão para editar peças');
|
||||
throw new Error('Sem permissão');
|
||||
}
|
||||
|
||||
const selectedPecasArray = Array.from(selectedPecas);
|
||||
await onBatchUpdatePecas(selectedPecasArray, updates);
|
||||
};
|
||||
|
||||
const handleEditPeca = (peca: Peca) => {
|
||||
if (!onEditPeca) {
|
||||
return;
|
||||
}
|
||||
onEditPeca(peca);
|
||||
};
|
||||
|
||||
const handleDeletePeca = (pecaId: string) => {
|
||||
if (!canDelete || !onDeletePeca) {
|
||||
toast.error('Você não tem permissão para excluir peças');
|
||||
return;
|
||||
}
|
||||
onDeletePeca(pecaId);
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(ofFilter || faseFilter || pecaFilter || descricaoFilter || priorityFilter !== 'all' || semComponentesFilter !== 'all');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TableFilters
|
||||
ofFilter={ofFilter}
|
||||
onOfFilterChange={setOfFilter}
|
||||
faseFilter={faseFilter}
|
||||
onFaseFilterChange={setFaseFilter}
|
||||
pecaFilter={pecaFilter}
|
||||
onPecaFilterChange={setPecaFilter}
|
||||
descricaoFilter={descricaoFilter}
|
||||
onDescricaoFilterChange={setDescricaoFilter}
|
||||
priorityFilter={priorityFilter}
|
||||
onPriorityFilterChange={setPriorityFilter}
|
||||
semComponentesFilter={semComponentesFilter}
|
||||
onSemComponentesFilterChange={setSemComponentesFilter}
|
||||
onSearchComponent={searchPecasByComponent}
|
||||
pesoTotal={pesoTotal}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<TableActions
|
||||
selectedPecas={selectedPecas}
|
||||
onExportCSV={handleExportCSV}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
hasRecentImport={hasRecentImport}
|
||||
onDeleteLastImport={onDeleteLastImport || (() => {})}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && onBatchUpdatePecas && (
|
||||
<BatchEditButton
|
||||
selectedPecas={selectedPecas}
|
||||
onBatchUpdate={handleBatchUpdate}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PDFGenerator
|
||||
selectedPecas={selectedPecas}
|
||||
allPecas={filteredAndSortedPecas}
|
||||
ofNumber={ofNumber}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAndSortedPecas.length === 0 ? (
|
||||
<EmptyState hasActiveFilters={hasActiveFilters} />
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeaderSortable
|
||||
selectAll={selectAll}
|
||||
onSelectAll={handleSelectAll}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedPecas.map((peca) => (
|
||||
<PecaTableRow
|
||||
key={peca.id}
|
||||
peca={peca}
|
||||
isSelected={selectedPecas.has(peca.id)}
|
||||
onSelect={(checked) => handleSelectPeca(peca.id, checked)}
|
||||
onEdit={() => handleEditPeca(peca)}
|
||||
onDelete={() => handleDeletePeca(peca.id)}
|
||||
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePrioridades } from '@/hooks/usePrioridades';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
prioridade: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({
|
||||
prioridade,
|
||||
className = "",
|
||||
size = 'md'
|
||||
}) => {
|
||||
const { getPrioridadeByCodigo } = usePrioridades();
|
||||
|
||||
const prioridadeConfig = getPrioridadeByCodigo(prioridade);
|
||||
|
||||
// Se não encontrar a configuração, usar cores padrão baseadas no código
|
||||
const getDefaultStyle = (codigo: string) => {
|
||||
switch (codigo) {
|
||||
case 'P1':
|
||||
return { cor: '#dc2626', nome: 'P1 - Urgente' };
|
||||
case 'P2':
|
||||
return { cor: '#ea580c', nome: 'P2 - Alta' };
|
||||
case 'P3':
|
||||
return { cor: '#2563eb', nome: 'P3 - Média' };
|
||||
case 'P4':
|
||||
default:
|
||||
return { cor: '#6b7280', nome: 'P4 - Baixa' };
|
||||
}
|
||||
};
|
||||
|
||||
const config = prioridadeConfig || getDefaultStyle(prioridade);
|
||||
const cor = config.cor;
|
||||
const nome = config.nome || prioridade;
|
||||
|
||||
// Definir tamanhos
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-0.5',
|
||||
md: 'text-sm px-2.5 py-0.5',
|
||||
lg: 'text-base px-3 py-1'
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={`text-white font-medium border-0 ${sizeClasses[size]} ${className}`}
|
||||
style={{ backgroundColor: cor }}
|
||||
title={nome}
|
||||
>
|
||||
{prioridade}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Edit3, Save, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BatchEditButtonProps {
|
||||
selectedPecas: Set<string>;
|
||||
onBatchUpdate: (updates: BatchUpdateData) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface BatchUpdateData {
|
||||
peso_unitario?: number;
|
||||
observacoes?: string;
|
||||
material?: string;
|
||||
perfil_principal?: string;
|
||||
tem_componentes?: boolean;
|
||||
}
|
||||
|
||||
export const BatchEditButton: React.FC<BatchEditButtonProps> = ({
|
||||
selectedPecas,
|
||||
onBatchUpdate,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updates, setUpdates] = useState<BatchUpdateData>({});
|
||||
const [componentesState, setComponentesState] = useState<'sem' | 'com' | null>(null);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
toast.error('Selecione pelo menos um campo para atualizar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await onBatchUpdate(updates);
|
||||
toast.success(`${selectedPecas.size} peças atualizadas com sucesso!`);
|
||||
setIsOpen(false);
|
||||
setUpdates({});
|
||||
setComponentesState(null);
|
||||
} catch (error) {
|
||||
toast.error('Erro ao atualizar peças');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setUpdates({});
|
||||
setComponentesState(null);
|
||||
};
|
||||
|
||||
const handleSemComponentesChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setComponentesState('sem');
|
||||
setUpdates(prev => ({ ...prev, tem_componentes: false }));
|
||||
} else {
|
||||
setComponentesState(null);
|
||||
const newUpdates = { ...updates };
|
||||
delete newUpdates.tem_componentes;
|
||||
setUpdates(newUpdates);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComComponentesChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setComponentesState('com');
|
||||
setUpdates(prev => ({ ...prev, tem_componentes: true }));
|
||||
} else {
|
||||
setComponentesState(null);
|
||||
const newUpdates = { ...updates };
|
||||
delete newUpdates.tem_componentes;
|
||||
setUpdates(newUpdates);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || selectedPecas.size === 0}
|
||||
className="h-8 bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200"
|
||||
>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
Editar Lote ({selectedPecas.size})
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Edit3 className="h-5 w-5" />
|
||||
Editar Peças em Lote
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Atualizando {selectedPecas.size} peças selecionadas
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="material">Material</Label>
|
||||
<Input
|
||||
id="material"
|
||||
placeholder="Deixe em branco para não alterar"
|
||||
value={updates.material || ''}
|
||||
onChange={(e) => setUpdates(prev => ({
|
||||
...prev,
|
||||
material: e.target.value || undefined
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="perfil_principal">Perfil Principal</Label>
|
||||
<Input
|
||||
id="perfil_principal"
|
||||
placeholder="Deixe em branco para não alterar"
|
||||
value={updates.perfil_principal || ''}
|
||||
onChange={(e) => setUpdates(prev => ({
|
||||
...prev,
|
||||
perfil_principal: e.target.value || undefined
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="peso_unitario">Peso Unitário (kg)</Label>
|
||||
<Input
|
||||
id="peso_unitario"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Deixe em branco para não alterar"
|
||||
value={updates.peso_unitario || ''}
|
||||
onChange={(e) => setUpdates(prev => ({
|
||||
...prev,
|
||||
peso_unitario: e.target.value ? parseFloat(e.target.value) : undefined
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Input
|
||||
id="observacoes"
|
||||
placeholder="Deixe em branco para não alterar"
|
||||
value={updates.observacoes || ''}
|
||||
onChange={(e) => setUpdates(prev => ({
|
||||
...prev,
|
||||
observacoes: e.target.value || undefined
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="sem_componentes"
|
||||
checked={componentesState === 'sem'}
|
||||
onCheckedChange={handleSemComponentesChange}
|
||||
/>
|
||||
<Label htmlFor="sem_componentes" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Sem Componentes
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="com_componentes"
|
||||
checked={componentesState === 'com'}
|
||||
onCheckedChange={handleComComponentesChange}
|
||||
/>
|
||||
<Label htmlFor="com_componentes" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Com Componentes
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Atualizando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Atualizar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
|
||||
interface EmptyStateProps {
|
||||
hasActiveFilters: boolean;
|
||||
}
|
||||
|
||||
export function EmptyState({ hasActiveFilters }: EmptyStateProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-center py-8">
|
||||
{hasActiveFilters
|
||||
? 'Nenhuma peça encontrada com os filtros aplicados'
|
||||
: 'Nenhuma peça cadastrada'
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { toast } from 'sonner';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
|
||||
interface PDFGeneratorProps {
|
||||
selectedPecas: Set<string>;
|
||||
allPecas: Peca[];
|
||||
ofNumber: string;
|
||||
}
|
||||
|
||||
export function PDFGenerator({ selectedPecas, allPecas, ofNumber }: PDFGeneratorProps) {
|
||||
const generatePDF = () => {
|
||||
const pecasToExport = allPecas.filter(peca => selectedPecas.has(peca.id));
|
||||
|
||||
if (pecasToExport.length === 0) {
|
||||
toast.error('Nenhuma peça selecionada para gerar PDF.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = new jsPDF('l', 'mm', 'a4'); // Landscape orientation
|
||||
|
||||
// Título
|
||||
doc.setFontSize(16);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Lista de Peças - OF: ${ofNumber}`, 20, 20);
|
||||
|
||||
// Data/hora
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 20, 30);
|
||||
|
||||
// Preparar dados para a tabela
|
||||
const tableData = pecasToExport.map(peca => [
|
||||
peca.of_number,
|
||||
peca.etapa_fase || '-',
|
||||
peca.marca,
|
||||
peca.descricao || '-',
|
||||
peca.prioridade || '-',
|
||||
peca.quantidade.toString(),
|
||||
`${peca.peso_unitario.toFixed(2)} kg`,
|
||||
`${peca.peso_total.toFixed(2)} kg`,
|
||||
peca.material || '-',
|
||||
peca.perfil_principal || '-'
|
||||
]);
|
||||
|
||||
// Cabeçalhos da tabela
|
||||
const headers = [
|
||||
['OF', 'Fase', 'Marca', 'Descrição', 'Prioridade', 'Qtd.', 'Peso Unit.', 'Peso Total', 'Material', 'Perfil']
|
||||
];
|
||||
|
||||
// Configurar a tabela
|
||||
autoTable(doc, {
|
||||
head: headers,
|
||||
body: tableData,
|
||||
startY: 40,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 2,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [66, 139, 202],
|
||||
textColor: 255,
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
},
|
||||
margin: { left: 10, right: 10 },
|
||||
tableWidth: 'auto',
|
||||
columnStyles: {
|
||||
0: { cellWidth: 25 }, // OF
|
||||
1: { cellWidth: 20 }, // Fase
|
||||
2: { cellWidth: 25 }, // Marca
|
||||
3: { cellWidth: 40 }, // Descrição
|
||||
4: { cellWidth: 25 }, // Prioridade
|
||||
5: { cellWidth: 15 }, // Qtd
|
||||
6: { cellWidth: 25 }, // Peso Unit
|
||||
7: { cellWidth: 25 }, // Peso Total
|
||||
8: { cellWidth: 30 }, // Material
|
||||
9: { cellWidth: 30 }, // Perfil
|
||||
}
|
||||
});
|
||||
|
||||
// Adicionar totais
|
||||
const totalPeso = pecasToExport.reduce((total, peca) => total + peca.peso_total, 0);
|
||||
const totalQuantidade = pecasToExport.reduce((total, peca) => total + peca.quantidade, 0);
|
||||
|
||||
const finalY = (doc as any).lastAutoTable.finalY || 40;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text(`Total de Peças: ${pecasToExport.length}`, 20, finalY + 15);
|
||||
doc.text(`Quantidade Total: ${totalQuantidade}`, 20, finalY + 25);
|
||||
doc.text(`Peso Total: ${totalPeso.toFixed(2)} kg`, 20, finalY + 35);
|
||||
|
||||
// Salvar o PDF
|
||||
doc.save(`pecas_${ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
|
||||
|
||||
toast.success(`PDF gerado com sucesso! ${pecasToExport.length} peças exportadas.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={generatePDF}
|
||||
disabled={selectedPecas.size === 0}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Gerar PDF ({selectedPecas.size})
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePrioridades } from '@/hooks/usePrioridades';
|
||||
|
||||
interface PriorityFilterProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PriorityFilter: React.FC<PriorityFilterProps> = ({ value, onChange }) => {
|
||||
const { prioridades, loading } = usePrioridades();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Select disabled>
|
||||
<SelectTrigger className="w-48 priority-filter-trigger">
|
||||
<SelectValue placeholder="Carregando..." />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-48 priority-filter-trigger">
|
||||
<SelectValue placeholder="Filtrar por prioridade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="priority-filter-content">
|
||||
<SelectItem value="all" className="priority-filter-item">
|
||||
Todas as prioridades
|
||||
</SelectItem>
|
||||
{prioridades.map((prioridade) => (
|
||||
<SelectItem key={prioridade.codigo} value={prioridade.codigo} className="priority-filter-item">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className="text-white font-medium text-xs"
|
||||
style={{ backgroundColor: prioridade.cor }}
|
||||
>
|
||||
{prioridade.codigo}
|
||||
</Badge>
|
||||
{prioridade.nome}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface SemComponentesFilterProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SemComponentesFilter({ value, onChange }: SemComponentesFilterProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-[160px] bg-background border-border text-foreground">
|
||||
<SelectValue placeholder="Sem Componentes" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background border-border">
|
||||
<SelectItem value="all" className="text-foreground hover:bg-muted">
|
||||
Todas
|
||||
</SelectItem>
|
||||
<SelectItem value="sim" className="text-foreground hover:bg-muted">
|
||||
Sim
|
||||
</SelectItem>
|
||||
<SelectItem value="nao" className="text-foreground hover:bg-muted">
|
||||
Não
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download, Trash2, Loader2 } from 'lucide-react';
|
||||
import { usePermissionControl } from '@/hooks/usePermissionControl';
|
||||
|
||||
interface TableActionsProps {
|
||||
selectedPecas: Set<string>;
|
||||
onExportCSV: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
hasRecentImport: boolean;
|
||||
onDeleteLastImport: () => void;
|
||||
deletingSelected?: boolean;
|
||||
}
|
||||
|
||||
export function TableActions({
|
||||
selectedPecas,
|
||||
onExportCSV,
|
||||
onDeleteSelected,
|
||||
hasRecentImport,
|
||||
onDeleteLastImport,
|
||||
deletingSelected = false
|
||||
}: TableActionsProps) {
|
||||
const { canImportExport, canDelete } = usePermissionControl();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canImportExport() && (
|
||||
<Button
|
||||
onClick={onExportCSV}
|
||||
disabled={selectedPecas.size === 0 || deletingSelected}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Exportar CSV ({selectedPecas.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete() && (
|
||||
<Button
|
||||
onClick={onDeleteSelected}
|
||||
disabled={selectedPecas.size === 0 || deletingSelected}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
{deletingSelected ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{deletingSelected ? 'Apagando...' : `Apagar Selecionadas (${selectedPecas.size})`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete() && hasRecentImport && (
|
||||
<Button
|
||||
onClick={onDeleteLastImport}
|
||||
disabled={deletingSelected}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Apagar Última Importação
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableBody as UITableBody } from '@/components/ui/table';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { TableRow } from './TableRow';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
interface TableBodyProps {
|
||||
filteredPecas: Peca[];
|
||||
selectedPecas: Set<string>;
|
||||
hasActiveFilters: boolean;
|
||||
onSelectPeca: (pecaId: string, checked: boolean) => void;
|
||||
onOpenComponentPopup: (pecaId: string) => void;
|
||||
onEditPeca: (peca: Peca) => void;
|
||||
onDeletePeca: (pecaId: string) => void;
|
||||
}
|
||||
|
||||
export function TableBody({
|
||||
filteredPecas,
|
||||
selectedPecas,
|
||||
hasActiveFilters,
|
||||
onSelectPeca,
|
||||
onOpenComponentPopup,
|
||||
onEditPeca,
|
||||
onDeletePeca
|
||||
}: TableBodyProps) {
|
||||
return (
|
||||
<UITableBody>
|
||||
{filteredPecas.length === 0 ? (
|
||||
<EmptyState hasActiveFilters={hasActiveFilters} />
|
||||
) : (
|
||||
filteredPecas.map((peca) => (
|
||||
<TableRow
|
||||
key={peca.id}
|
||||
peca={peca}
|
||||
isSelected={selectedPecas.has(peca.id)}
|
||||
onSelect={(checked) => onSelectPeca(peca.id, checked)}
|
||||
onOpenComponentPopup={() => onOpenComponentPopup(peca.id)}
|
||||
onEdit={() => onEditPeca(peca)}
|
||||
onDelete={() => onDeletePeca(peca.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</UITableBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, X, Info } from 'lucide-react';
|
||||
import { PriorityFilter } from './PriorityFilter';
|
||||
import { SemComponentesFilter } from './SemComponentesFilter';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface TableFiltersProps {
|
||||
ofFilter: string;
|
||||
onOfFilterChange: (value: string) => void;
|
||||
faseFilter: string;
|
||||
onFaseFilterChange: (value: string) => void;
|
||||
pecaFilter: string;
|
||||
onPecaFilterChange: (value: string) => void;
|
||||
descricaoFilter: string;
|
||||
onDescricaoFilterChange: (value: string) => void;
|
||||
priorityFilter: string;
|
||||
onPriorityFilterChange: (value: string) => void;
|
||||
semComponentesFilter: string;
|
||||
onSemComponentesFilterChange: (value: string) => void;
|
||||
onSearchComponent: (marca: string) => void;
|
||||
pesoTotal: number;
|
||||
}
|
||||
|
||||
export function TableFilters({
|
||||
ofFilter,
|
||||
onOfFilterChange,
|
||||
faseFilter,
|
||||
onFaseFilterChange,
|
||||
pecaFilter,
|
||||
onPecaFilterChange,
|
||||
descricaoFilter,
|
||||
onDescricaoFilterChange,
|
||||
priorityFilter,
|
||||
onPriorityFilterChange,
|
||||
semComponentesFilter,
|
||||
onSemComponentesFilterChange,
|
||||
onSearchComponent,
|
||||
pesoTotal
|
||||
}: TableFiltersProps) {
|
||||
const handleClearOfFilter = () => {
|
||||
onOfFilterChange('');
|
||||
};
|
||||
|
||||
const handleClearFaseFilter = () => {
|
||||
onFaseFilterChange('');
|
||||
};
|
||||
|
||||
const handleClearPecaFilter = () => {
|
||||
onPecaFilterChange('');
|
||||
};
|
||||
|
||||
const handleClearDescricaoFilter = () => {
|
||||
onDescricaoFilterChange('');
|
||||
};
|
||||
|
||||
const handleClearPriorityFilter = () => {
|
||||
onPriorityFilterChange('all');
|
||||
};
|
||||
|
||||
const handleClearSemComponentesFilter = () => {
|
||||
onSemComponentesFilterChange('all');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mb-4">
|
||||
{/* Linha dos quatro filtros individuais */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Filtro por OF */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Filtrar por OF..."
|
||||
value={ofFilter}
|
||||
onChange={(e) => onOfFilterChange(e.target.value)}
|
||||
className="pl-10 bg-background border-border text-foreground placeholder-muted-foreground"
|
||||
/>
|
||||
{ofFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearOfFilter}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtro por Fase */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Filtrar por fase..."
|
||||
value={faseFilter}
|
||||
onChange={(e) => onFaseFilterChange(e.target.value)}
|
||||
className="pl-10 bg-background border-border text-foreground placeholder-muted-foreground"
|
||||
/>
|
||||
{faseFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearFaseFilter}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtro por Peça com tooltip */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Filtrar peça... (ex: 32@47)"
|
||||
value={pecaFilter}
|
||||
onChange={(e) => onPecaFilterChange(e.target.value)}
|
||||
className="pl-10 pr-8 bg-background border-border text-foreground placeholder-muted-foreground"
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="absolute right-8 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><strong>Filtro por range:</strong></p>
|
||||
<p>Use @ para filtrar intervalos</p>
|
||||
<p><strong>Exemplo:</strong> 32@47 (peças de 32 a 47)</p>
|
||||
<p><strong>Busca normal:</strong> Digite parte da marca</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{pecaFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearPecaFilter}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtro por Descrição */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Filtrar por descrição..."
|
||||
value={descricaoFilter}
|
||||
onChange={(e) => onDescricaoFilterChange(e.target.value)}
|
||||
className="pl-10 bg-background border-border text-foreground placeholder-muted-foreground"
|
||||
/>
|
||||
{descricaoFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearDescricaoFilter}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segunda linha com filtros de prioridade, sem componentes e busca por componente */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex gap-6 items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-foreground">Prioridade</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<PriorityFilter
|
||||
value={priorityFilter}
|
||||
onChange={onPriorityFilterChange}
|
||||
/>
|
||||
|
||||
{priorityFilter !== 'all' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearPriorityFilter}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-foreground">Sem Componentes</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<SemComponentesFilter
|
||||
value={semComponentesFilter}
|
||||
onChange={onSemComponentesFilterChange}
|
||||
/>
|
||||
|
||||
{semComponentesFilter !== 'all' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearSemComponentesFilter}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const marca = prompt('Digite a marca do componente para buscar:');
|
||||
if (marca) {
|
||||
onSearchComponent(marca);
|
||||
}
|
||||
}}
|
||||
className="bg-background border-border text-foreground hover:bg-muted"
|
||||
>
|
||||
Buscar por Componente
|
||||
</Button>
|
||||
|
||||
{/* Campo de Peso Total */}
|
||||
<div className="text-sm font-medium text-foreground bg-muted/50 px-3 py-2 rounded-md border border-border">
|
||||
Peso total: {pesoTotal.toFixed(2)} kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableHead, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowUpDown } from 'lucide-react';
|
||||
|
||||
interface TableHeaderProps {
|
||||
selectAll: boolean;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
sortField: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSort: (field: string) => void;
|
||||
}
|
||||
|
||||
export function TableHeader({
|
||||
selectAll,
|
||||
onSelectAll,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSort
|
||||
}: TableHeaderProps) {
|
||||
const SortButton = ({ field, children }: { field: string; children: React.ReactNode }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 p-1 font-medium hover:bg-slate-700"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{children}
|
||||
<ArrowUpDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow className="border-slate-700 hover:bg-slate-700/50">
|
||||
<TableHead className="w-12 text-slate-300">
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={onSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="of_number">OF</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="etapa_fase">Fase</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="marca">Marca</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="descricao">Descrição</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="prioridade">Prioridade</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300 text-center">
|
||||
<SortButton field="quantidade">Qtd</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300 text-right">
|
||||
<SortButton field="peso_unitario">Peso Unit.</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300 text-right">
|
||||
<SortButton field="peso_total">Peso Total</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300">Material</TableHead>
|
||||
<TableHead className="text-slate-300">
|
||||
<SortButton field="perfil_principal">Perfil</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-300 text-center">Ações</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableHead, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { SortField, SortOrder } from '@/hooks/usePecasTable';
|
||||
|
||||
interface TableHeaderSortableProps {
|
||||
selectAll: boolean;
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
sortField: SortField;
|
||||
sortOrder: SortOrder;
|
||||
onSort: (field: SortField) => void;
|
||||
}
|
||||
|
||||
export function TableHeaderSortable({
|
||||
selectAll,
|
||||
onSelectAll,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSort
|
||||
}: TableHeaderSortableProps) {
|
||||
const SortButton = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 p-0 hover:bg-transparent"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span className="text-xs font-medium">{children}</span>
|
||||
{sortField === field && (
|
||||
sortOrder === 'asc' ? (
|
||||
<ChevronUp className="ml-1 h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="ml-1 h-3 w-3" />
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow className="border-b">
|
||||
<TableHead className="w-8 px-2">
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={onSelectAll}
|
||||
aria-label="Selecionar todas as peças"
|
||||
/>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2">
|
||||
<SortButton field="of_number">OF</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-12 px-2">
|
||||
<SortButton field="etapa_fase">Fase</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-14 px-2">
|
||||
<SortButton field="marca">Marca</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-32 px-2">
|
||||
<SortButton field="descricao">Descrição</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2">
|
||||
<SortButton field="prioridade">Prioridade</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2 text-center">
|
||||
<SortButton field="tem_componentes">Sem Comp.</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-12 px-2 text-right">
|
||||
<SortButton field="quantidade">Qtd.</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2 text-right">
|
||||
<SortButton field="peso_unitario">Peso Unit.</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2 text-right">
|
||||
<SortButton field="peso_total">Peso Total</SortButton>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2">
|
||||
<span className="text-xs font-medium">Material</span>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-16 px-2">
|
||||
<span className="text-xs font-medium">Perfil</span>
|
||||
</TableHead>
|
||||
|
||||
<TableHead className="w-20 px-2">
|
||||
<span className="text-xs font-medium">Ações</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
|
||||
import React from 'react';
|
||||
import { TableCell, TableRow as UITableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Edit, Trash2, Package, Check, X } from 'lucide-react';
|
||||
import { Peca } from '@/hooks/usePecas';
|
||||
import { PriorityBadge } from '../PriorityBadge';
|
||||
|
||||
interface TableRowProps {
|
||||
peca: Peca;
|
||||
isSelected: boolean;
|
||||
onSelect: (checked: boolean) => void;
|
||||
onOpenComponentPopup: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function TableRow({
|
||||
peca,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onOpenComponentPopup,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: TableRowProps) {
|
||||
return (
|
||||
<UITableRow className="hover:bg-muted/50">
|
||||
<TableCell className="w-8 px-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onSelect(checked as boolean)}
|
||||
aria-label={`Selecionar peça ${peca.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 font-medium text-foreground text-xs">
|
||||
{peca.of_number}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-12 px-2 text-muted-foreground text-xs">
|
||||
{peca.etapa_fase}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-14 px-2 text-muted-foreground text-xs">
|
||||
{peca.marca}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-32 px-2 text-foreground text-xs">
|
||||
{peca.descricao}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2">
|
||||
<PriorityBadge prioridade={peca.prioridade as any} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-center">
|
||||
<div className="flex justify-center">
|
||||
{!peca.tem_componentes ? (
|
||||
<div className="flex items-center justify-center w-5 h-5 bg-green-500 rounded-full shadow-sm">
|
||||
<Check className="h-3 w-3 text-white font-bold" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-5 h-5 bg-red-500 rounded-full shadow-sm">
|
||||
<X className="h-3 w-3 text-white font-bold" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-12 px-2 text-right text-muted-foreground text-xs">
|
||||
{peca.quantidade}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-right text-muted-foreground text-xs">
|
||||
{peca.peso_unitario} kg
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-right font-medium text-foreground text-xs">
|
||||
{(peca.quantidade * peca.peso_unitario).toFixed(2)} kg
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
|
||||
{peca.material || '-'}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-16 px-2 text-muted-foreground text-xs">
|
||||
{peca.perfil_principal || '-'}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="w-20 px-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenComponentPopup}
|
||||
title="Ver componentes"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
title="Editar peça"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
title="Excluir peça"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</UITableRow>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user