🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
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 { Alert, AlertDescription } from '@/components/ui/alert';
import { Upload, AlertCircle, CheckCircle } from 'lucide-react';
import { parseCSV } from '@/utils/csvUtils';
import { useCriarMaterial } from '@/hooks/useEstoque';
import { toast } from 'sonner';
interface CSVImportModalProps {
isOpen: boolean;
onClose: () => void;
}
export const CSVImportModal: React.FC<CSVImportModalProps> = ({ isOpen, onClose }) => {
const [file, setFile] = useState<File | null>(null);
const [csvData, setCsvData] = useState<any[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [importResult, setImportResult] = useState<{ success: number; errors: string[] } | null>(null);
const criarMaterial = useCriarMaterial();
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (selectedFile && selectedFile.type === 'text/csv') {
setFile(selectedFile);
setImportResult(null);
const reader = new FileReader();
reader.onload = (e) => {
const csvText = e.target?.result as string;
const parsed = parseCSV(csvText);
setCsvData(parsed);
};
reader.readAsText(selectedFile);
} else {
toast.error('Por favor, selecione um arquivo CSV válido');
}
};
const processImport = async () => {
if (csvData.length === 0) return;
setIsProcessing(true);
const errors: string[] = [];
let successCount = 0;
for (const row of csvData) {
try {
const materialData = {
codigo: row.codigo || '',
descricao: row.descricao || '',
tipo_material_id: row.tipo_material_id || null,
unidade: row.unidade || 'PC',
peso_unitario: parseFloat(row.peso_unitario) || 0,
quantidade_total: parseFloat(row.quantidade_total) || 0,
quantidade_disponivel: parseFloat(row.quantidade_disponivel) || 0,
quantidade_empenhada: parseFloat(row.quantidade_empenhada) || 0,
quantidade_minima: parseFloat(row.quantidade_minima) || 0,
quantidade_maxima: row.quantidade_maxima ? parseFloat(row.quantidade_maxima) : null,
lote_atual: row.lote_atual || null,
fornecedor: row.fornecedor || null,
localizacao: row.localizacao || null,
status: row.status || 'Normal',
certificado: row.certificado || null,
observacoes: row.observacoes || null
};
await criarMaterial.mutateAsync(materialData);
successCount++;
} catch (error: any) {
errors.push(`Erro na linha ${csvData.indexOf(row) + 2}: ${error.message}`);
}
}
setImportResult({ success: successCount, errors });
setIsProcessing(false);
if (successCount > 0) {
toast.success(`${successCount} materiais importados com sucesso!`);
}
};
const resetModal = () => {
setFile(null);
setCsvData([]);
setImportResult(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={resetModal}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Importar Matérias-Primas via CSV</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
O arquivo CSV deve conter as seguintes colunas: codigo, descricao, unidade, peso_unitario,
quantidade_total, quantidade_disponivel, quantidade_empenhada, quantidade_minima,
quantidade_maxima, lote_atual, fornecedor, localizacao, status, certificado, observacoes
</AlertDescription>
</Alert>
<div>
<Label htmlFor="csv-file">Selecionar arquivo CSV</Label>
<Input
id="csv-file"
type="file"
accept=".csv"
onChange={handleFileChange}
className="mt-1"
/>
</div>
{file && (
<div className="p-3 bg-muted rounded">
<p className="text-sm">
<strong>Arquivo:</strong> {file.name} ({csvData.length} registros encontrados)
</p>
</div>
)}
{importResult && (
<Alert className={importResult.errors.length > 0 ? "border-yellow-500" : "border-green-500"}>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
<p><strong>Resultado da Importação:</strong></p>
<p> {importResult.success} materiais importados com sucesso</p>
{importResult.errors.length > 0 && (
<div className="mt-2">
<p> {importResult.errors.length} erros encontrados:</p>
<ul className="text-xs mt-1 max-h-20 overflow-y-auto">
{importResult.errors.map((error, index) => (
<li key={index}> {error}</li>
))}
</ul>
</div>
)}
</AlertDescription>
</Alert>
)}
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={resetModal}>
Cancelar
</Button>
<Button
onClick={processImport}
disabled={csvData.length === 0 || isProcessing}
>
<Upload className="w-4 h-4 mr-2" />
{isProcessing ? 'Importando...' : 'Importar'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Settings } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TiposMateriaModal } from './TiposMateriaModal';
import { UnidadesMedidaModal } from './UnidadesMedidaModal';
import { LocalizacaoModal } from './LocalizacaoModal';
import { QualidadeAcoModal } from './QualidadeAcoModal';
export const CrudModalsManager: React.FC = () => {
const [tiposModalOpen, setTiposModalOpen] = useState(false);
const [unidadesModalOpen, setUnidadesModalOpen] = useState(false);
const [localizacaoModalOpen, setLocalizacaoModalOpen] = useState(false);
const [qualidadeModalOpen, setQualidadeModalOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="w-4 h-4 mr-2" />
Gerenciar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTiposModalOpen(true)}>
Tipos de Material
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setUnidadesModalOpen(true)}>
Unidades de Medida
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocalizacaoModalOpen(true)}>
Localizações
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setQualidadeModalOpen(true)}>
Qualidade do Aço
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TiposMateriaModal
isOpen={tiposModalOpen}
onClose={() => setTiposModalOpen(false)}
/>
<UnidadesMedidaModal
isOpen={unidadesModalOpen}
onClose={() => setUnidadesModalOpen(false)}
/>
<LocalizacaoModal
isOpen={localizacaoModalOpen}
onClose={() => setLocalizacaoModalOpen(false)}
/>
<QualidadeAcoModal
isOpen={qualidadeModalOpen}
onClose={() => setQualidadeModalOpen(false)}
/>
</>
);
};
@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Package, AlertTriangle, CheckCircle, XCircle, Trash2 } from 'lucide-react';
import { useEmpenhosMaterial, useOFsComEmpenhos, useCancelarEmpenho } from '@/hooks/useEmpenhosMaterial';
import { Skeleton } from '@/components/ui/skeleton';
export const EmpenhosMaterialComponent: React.FC = () => {
const [selectedOF, setSelectedOF] = useState<string>('all');
const { data: ofsComEmpenhos = [], isLoading: loadingOFs } = useOFsComEmpenhos();
const { data: empenhos = [], isLoading: loadingEmpenhos } = useEmpenhosMaterial(selectedOF === 'all' ? undefined : selectedOF);
const cancelarEmpenho = useCancelarEmpenho();
const getStatusColor = (status: string) => {
switch (status) {
case 'Empenhado':
return 'bg-yellow-500';
case 'Finalizado':
return 'bg-green-500';
case 'Cancelado':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'Empenhado':
return <AlertTriangle className="h-3 w-3" />;
case 'Finalizado':
return <CheckCircle className="h-3 w-3" />;
case 'Cancelado':
return <XCircle className="h-3 w-3" />;
default:
return <Package className="h-3 w-3" />;
}
};
const handleCancelarEmpenho = async (empenhoId: string) => {
try {
await cancelarEmpenho.mutateAsync(empenhoId);
} catch (error) {
console.error('Erro ao cancelar empenho:', error);
}
};
// Calcular totais
const totais = empenhos.reduce((acc, empenho) => {
acc.quantidadeEmpenhada += empenho.quantidade_empenhada;
acc.quantidadeUtilizada += empenho.quantidade_utilizada;
acc.valorTotal += (empenho.quantidade_empenhada * (empenho.estoque_materiais?.valor_unitario || 0));
if (empenho.status === 'Empenhado') acc.empenhosAtivos++;
else if (empenho.status === 'Finalizado') acc.empenhosFinalizados++;
else if (empenho.status === 'Cancelado') acc.empenhosCancelados++;
return acc;
}, {
quantidadeEmpenhada: 0,
quantidadeUtilizada: 0,
valorTotal: 0,
empenhosAtivos: 0,
empenhosFinalizados: 0,
empenhosCancelados: 0
});
if (loadingOFs) {
return <Skeleton className="w-full h-96" />;
}
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Gestão de Empenhos de Material
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Seletor de OF */}
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Ordem de Fabricação</label>
<Select value={selectedOF} onValueChange={setSelectedOF}>
<SelectTrigger>
<SelectValue placeholder="Selecione uma OF para ver os empenhos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as OFs</SelectItem>
{ofsComEmpenhos.map((of) => (
<SelectItem key={of} value={of}>
{of}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Cards de Resumo */}
{selectedOF && selectedOF !== 'all' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-blue-50 dark:bg-blue-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-blue-600">{totais.empenhosAtivos}</div>
<div className="text-sm text-blue-600">Empenhos Ativos</div>
</CardContent>
</Card>
<Card className="bg-green-50 dark:bg-green-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-green-600">{totais.empenhosFinalizados}</div>
<div className="text-sm text-green-600">Finalizados</div>
</CardContent>
</Card>
<Card className="bg-yellow-50 dark:bg-yellow-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-yellow-600">
{totais.quantidadeEmpenhada.toFixed(2)}
</div>
<div className="text-sm text-yellow-600">Qtd Empenhada</div>
</CardContent>
</Card>
<Card className="bg-purple-50 dark:bg-purple-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-purple-600">
R$ {totais.valorTotal.toFixed(2)}
</div>
<div className="text-sm text-purple-600">Valor Total</div>
</CardContent>
</Card>
</div>
)}
{/* Tabela de Empenhos */}
{loadingEmpenhos ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="w-full h-12" />
))}
</div>
) : empenhos.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Material</TableHead>
<TableHead>OF</TableHead>
<TableHead>Lote</TableHead>
<TableHead>Qtd Empenhada</TableHead>
<TableHead>Qtd Utilizada</TableHead>
<TableHead>Restante</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{empenhos.map((empenho) => {
const qtdRestante = empenho.quantidade_empenhada - empenho.quantidade_utilizada;
return (
<TableRow key={empenho.id}>
<TableCell>
<div>
<div className="font-medium">
{empenho.estoque_materiais?.descricao}
</div>
<div className="text-sm text-muted-foreground">
{empenho.estoque_materiais?.codigo}
</div>
</div>
</TableCell>
<TableCell className="font-medium">{empenho.of_number}</TableCell>
<TableCell>{empenho.lote || '-'}</TableCell>
<TableCell>
{empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</TableCell>
<TableCell>
{empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</TableCell>
<TableCell>
<span className={qtdRestante > 0 ? 'text-yellow-600' : 'text-green-600'}>
{qtdRestante.toFixed(2)} {empenho.estoque_materiais?.unidade}
</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`${getStatusColor(empenho.status)} text-white border-none`}
>
{getStatusIcon(empenho.status)}
<span className="ml-1">{empenho.status}</span>
</Badge>
</TableCell>
<TableCell>
{new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>
{empenho.status === 'Empenhado' && empenho.quantidade_utilizada === 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={cancelarEmpenho.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancelar Empenho</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja cancelar este empenho? Esta ação irá:
<ul className="list-disc list-inside mt-2">
<li>Reverter a quantidade empenhada para disponível</li>
<li>Cancelar a movimentação de empenho relacionada</li>
<li>Marcar o empenho como cancelado</li>
</ul>
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleCancelarEmpenho(empenho.id)}
className="bg-red-500 hover:bg-red-600"
>
Confirmar Cancelamento
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
{selectedOF && selectedOF !== 'all' ? (
<div>
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Nenhum empenho encontrado para a OF {selectedOF}</p>
</div>
) : (
<div>
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Selecione uma OF para visualizar os empenhos de material</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,262 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Package, AlertTriangle, CheckCircle, XCircle, Info } from 'lucide-react';
import { useEmpenhosMaterial, useOFsComEmpenhos } from '@/hooks/useEmpenhosMaterialSimplificado';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
export const EmpenhosMaterialSimplificado: React.FC = () => {
const [selectedOF, setSelectedOF] = useState<string>('all');
const { data: ofsComEmpenhos = [], isLoading: loadingOFs } = useOFsComEmpenhos();
const { data: empenhos = [], isLoading: loadingEmpenhos } = useEmpenhosMaterial(selectedOF === 'all' ? undefined : selectedOF);
const getStatusColor = (status: string) => {
switch (status) {
case 'Empenhado':
return 'bg-yellow-500';
case 'Finalizado':
return 'bg-green-500';
case 'Cancelado':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'Empenhado':
return <AlertTriangle className="h-3 w-3" />;
case 'Finalizado':
return <CheckCircle className="h-3 w-3" />;
case 'Cancelado':
return <XCircle className="h-3 w-3" />;
default:
return <Package className="h-3 w-3" />;
}
};
// Calcular totais
const totais = empenhos.reduce((acc, empenho) => {
acc.quantidadeEmpenhada += empenho.quantidade_empenhada;
acc.quantidadeUtilizada += empenho.quantidade_utilizada;
acc.valorTotal += (empenho.quantidade_empenhada * (empenho.estoque_materiais?.valor_unitario || 0));
if (empenho.status === 'Empenhado') acc.empenhosAtivos++;
else if (empenho.status === 'Finalizado') acc.empenhosFinalizados++;
else if (empenho.status === 'Cancelado') acc.empenhosCancelados++;
return acc;
}, {
quantidadeEmpenhada: 0,
quantidadeUtilizada: 0,
valorTotal: 0,
empenhosAtivos: 0,
empenhosFinalizados: 0,
empenhosCancelados: 0
});
if (loadingOFs) {
return <Skeleton className="w-full h-96" />;
}
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Visualização de Empenhos de Material
</CardTitle>
<div className="bg-blue-50 dark:bg-blue-950 p-3 rounded-lg">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-600" />
<p className="text-sm text-blue-600">
Os empenhos são gerados automaticamente através das movimentações.
Para cancelar um empenho, exclua a movimentação correspondente na aba "Movimentação".
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Seletor de OF */}
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Ordem de Fabricação</label>
<Select value={selectedOF} onValueChange={setSelectedOF}>
<SelectTrigger>
<SelectValue placeholder="Selecione uma OF para ver os empenhos" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as OFs</SelectItem>
{ofsComEmpenhos.map((of) => (
<SelectItem key={of} value={of}>
{of}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Cards de Resumo */}
{selectedOF && selectedOF !== 'all' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-blue-50 dark:bg-blue-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-blue-600">{totais.empenhosAtivos}</div>
<div className="text-sm text-blue-600">Empenhos Ativos</div>
</CardContent>
</Card>
<Card className="bg-green-50 dark:bg-green-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-green-600">{totais.empenhosFinalizados}</div>
<div className="text-sm text-green-600">Finalizados</div>
</CardContent>
</Card>
<Card className="bg-yellow-50 dark:bg-yellow-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-yellow-600">
{totais.quantidadeEmpenhada.toFixed(2)}
</div>
<div className="text-sm text-yellow-600">Qtd Empenhada</div>
</CardContent>
</Card>
<Card className="bg-purple-50 dark:bg-purple-950">
<CardContent className="p-4">
<div className="text-2xl font-bold text-purple-600">
R$ {totais.valorTotal.toFixed(2)}
</div>
<div className="text-sm text-purple-600">Valor Total</div>
</CardContent>
</Card>
</div>
)}
{/* Tabela de Empenhos */}
{loadingEmpenhos ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="w-full h-12" />
))}
</div>
) : empenhos.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Material</TableHead>
<TableHead>OF</TableHead>
<TableHead>Lote</TableHead>
<TableHead>Qtd Empenhada</TableHead>
<TableHead>Qtd Utilizada</TableHead>
<TableHead>Restante</TableHead>
<TableHead>Status</TableHead>
<TableHead>Data</TableHead>
<TableHead>Informações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{empenhos.map((empenho) => {
const qtdRestante = empenho.quantidade_empenhada - empenho.quantidade_utilizada;
return (
<TableRow key={empenho.id}>
<TableCell>
<div>
<div className="font-medium">
{empenho.estoque_materiais?.descricao}
</div>
<div className="text-sm text-muted-foreground">
{empenho.estoque_materiais?.codigo}
</div>
</div>
</TableCell>
<TableCell className="font-medium">{empenho.of_number}</TableCell>
<TableCell>{empenho.lote || '-'}</TableCell>
<TableCell>
{empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</TableCell>
<TableCell>
{empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</TableCell>
<TableCell>
<span className={qtdRestante > 0 ? 'text-yellow-600' : 'text-green-600'}>
{qtdRestante.toFixed(2)} {empenho.estoque_materiais?.unidade}
</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`${getStatusColor(empenho.status)} text-white border-none`}
>
{getStatusIcon(empenho.status)}
<span className="ml-1">{empenho.status}</span>
</Badge>
</TableCell>
<TableCell>
{new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<Info className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Informações do Empenho</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<div><strong>Material:</strong> {empenho.estoque_materiais?.codigo} - {empenho.estoque_materiais?.descricao}</div>
<div><strong>OF:</strong> {empenho.of_number}</div>
<div><strong>Quantidade Empenhada:</strong> {empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}</div>
<div><strong>Quantidade Utilizada:</strong> {empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}</div>
<div><strong>Status:</strong> {empenho.status}</div>
<div><strong>Data:</strong> {new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}</div>
{empenho.lote && <div><strong>Lote:</strong> {empenho.lote}</div>}
{empenho.observacoes && <div><strong>Observações:</strong> {empenho.observacoes}</div>}
<div className="mt-4 p-2 bg-blue-50 border border-blue-200 rounded">
<strong>Para cancelar este empenho:</strong>
<br />
Acesse a aba "Movimentação" e exclua a movimentação de empenho correspondente.
</div>
</AlertDialogDescription>
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
{selectedOF && selectedOF !== 'all' ? (
<div>
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Nenhum empenho encontrado para a OF {selectedOF}</p>
</div>
) : (
<div>
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Selecione uma OF para visualizar os empenhos de material</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,143 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Trash2, Edit, Package } from 'lucide-react';
import { EstoqueMaterial, useExcluirMateriais } from '@/hooks/useEstoque';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface EstoqueBatchActionsProps {
selectedMaterials: EstoqueMaterial[];
onClearSelection: () => void;
onShowMovimentacao: () => void;
onShowBatchEdit: () => void;
}
export function EstoqueBatchActions({
selectedMaterials,
onClearSelection,
onShowMovimentacao,
onShowBatchEdit
}: EstoqueBatchActionsProps) {
const excluirMateriais = useExcluirMateriais();
const handleBatchDelete = async () => {
if (selectedMaterials.length === 0) {
console.log('Nenhum material selecionado para exclusão');
return;
}
try {
const materialIds = selectedMaterials.map(m => m.id);
console.log('Iniciando exclusão de materiais:', {
count: selectedMaterials.length,
ids: materialIds,
materials: selectedMaterials.map(m => ({ id: m.id, codigo: m.codigo, descricao: m.descricao }))
});
await excluirMateriais.mutateAsync(materialIds);
console.log('Exclusão concluída com sucesso');
onClearSelection();
} catch (error) {
console.error('Erro na exclusão de materiais:', error);
// Error is handled by the mutation hook via toast
}
};
if (selectedMaterials.length === 0) return null;
const confirmMessage = selectedMaterials.length === 1
? `Tem certeza que deseja excluir o material "${selectedMaterials[0].descricao}"?`
: `Tem certeza que deseja excluir ${selectedMaterials.length} materiais selecionados?`;
return (
<Card className="mb-4 bg-blue-50 border-blue-200">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-blue-700">
{selectedMaterials.length} material(is) selecionado(s)
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onShowBatchEdit}
className="text-blue-600 hover:text-blue-700"
>
<Edit className="w-4 h-4 mr-2" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onClick={onShowMovimentacao}
className="text-green-600 hover:text-green-700"
>
<Package className="w-4 h-4 mr-2" />
Movimentar
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={excluirMateriais.isPending}
className="text-red-600 hover:text-red-700 disabled:opacity-50"
>
<Trash2 className="w-4 h-4 mr-2" />
{excluirMateriais.isPending ? 'Excluindo...' : 'Excluir'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-slate-800 border-slate-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">Confirmar Exclusão</AlertDialogTitle>
<AlertDialogDescription className="text-slate-400">
{confirmMessage}
<br /><br />
<strong>Esta ação não pode ser desfeita.</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
disabled={excluirMateriais.isPending}
>
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={handleBatchDelete}
disabled={excluirMateriais.isPending}
className="bg-red-600 hover:bg-red-700 text-white"
>
{excluirMateriais.isPending ? 'Excluindo...' : 'Excluir'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClearSelection}
className="text-gray-500"
>
Limpar Seleção
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,264 @@
import React, { useState, useEffect } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { EstoqueMaterial, useTiposMateriaPrima, useAtualizarMaterial } from '@/hooks/useEstoque';
import { useUnidadesMedida, useLocalizacoesEstoque, useQualidadesAco } from '@/hooks/useEstoqueCRUD';
interface EstoqueBatchEditModalProps {
isOpen: boolean;
onClose: () => void;
selectedMaterials: EstoqueMaterial[];
onSuccess: () => void;
}
export const EstoqueBatchEditModal: React.FC<EstoqueBatchEditModalProps> = ({
isOpen,
onClose,
selectedMaterials,
onSuccess
}) => {
const [formData, setFormData] = useState({
tipo_material_id: '',
unidade: '',
quantidade_minima: '',
quantidade_maxima: '',
peso_unitario: '',
valor_unitario: '',
lote_atual: '',
fornecedor: '',
localizacao: '',
qualidade_aco: ''
});
const { data: tiposMaterial } = useTiposMateriaPrima();
const { data: unidadesMedida } = useUnidadesMedida();
const { data: localizacoes } = useLocalizacoesEstoque();
const { data: qualidadesAco } = useQualidadesAco();
const atualizarMaterial = useAtualizarMaterial();
useEffect(() => {
// Initialize form data with common values from selected materials
if (selectedMaterials.length > 0) {
setFormData({
tipo_material_id: '',
unidade: '',
quantidade_minima: '',
quantidade_maxima: '',
peso_unitario: '',
valor_unitario: '',
lote_atual: '',
fornecedor: '',
localizacao: '',
qualidade_aco: ''
});
}
}, [selectedMaterials]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await Promise.all(
selectedMaterials.map(material => {
const updates: Partial<EstoqueMaterial> = {};
if (formData.tipo_material_id !== '' && formData.tipo_material_id !== 'none') updates.tipo_material_id = formData.tipo_material_id;
if (formData.unidade !== '' && formData.unidade !== 'none') updates.unidade = formData.unidade;
if (formData.quantidade_minima !== '') updates.quantidade_minima = parseFloat(formData.quantidade_minima);
if (formData.quantidade_maxima !== '') updates.quantidade_maxima = parseFloat(formData.quantidade_maxima);
if (formData.peso_unitario !== '') updates.peso_unitario = parseFloat(formData.peso_unitario);
if (formData.valor_unitario !== '') updates.valor_unitario = parseFloat(formData.valor_unitario);
if (formData.lote_atual !== '') updates.lote_atual = formData.lote_atual;
if (formData.fornecedor !== '') updates.fornecedor = formData.fornecedor;
if (formData.localizacao !== '' && formData.localizacao !== 'none') updates.localizacao = formData.localizacao;
if (formData.qualidade_aco !== '' && formData.qualidade_aco !== 'none') {
updates.qualidade_aco = formData.qualidade_aco === "remove" ? null : formData.qualidade_aco;
}
return atualizarMaterial.mutateAsync({ id: material.id, ...updates });
})
);
onSuccess();
onClose();
} catch (error) {
console.error("Erro ao atualizar materiais em lote:", error);
alert("Erro ao atualizar materiais em lote.");
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Editar {selectedMaterials.length} Material(is) em Lote</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="tipo_material_id">Tipo de Material</Label>
<Select value={formData.tipo_material_id} onValueChange={(value) => handleInputChange('tipo_material_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Não alterar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Não alterar</SelectItem>
{tiposMaterial?.map((tipo) => (
<SelectItem key={tipo.id} value={tipo.id}>
{tipo.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="unidade">Unidade</Label>
<Select value={formData.unidade} onValueChange={(value) => handleInputChange('unidade', value)}>
<SelectTrigger>
<SelectValue placeholder="Não alterar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Não alterar</SelectItem>
{unidadesMedida?.map((unidade) => (
<SelectItem key={unidade.id} value={unidade.abreviacao}>
{unidade.abreviacao} - {unidade.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="quantidade_minima">Quantidade Mínima</Label>
<Input
id="quantidade_minima"
type="number"
step="0.01"
value={formData.quantidade_minima}
onChange={(e) => handleInputChange('quantidade_minima', e.target.value)}
placeholder="Não alterar"
/>
</div>
<div>
<Label htmlFor="quantidade_maxima">Quantidade Máxima</Label>
<Input
id="quantidade_maxima"
type="number"
step="0.01"
value={formData.quantidade_maxima}
onChange={(e) => handleInputChange('quantidade_maxima', e.target.value)}
placeholder="Não alterar"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="peso_unitario">Peso Unitário</Label>
<Input
id="peso_unitario"
type="number"
step="0.001"
value={formData.peso_unitario}
onChange={(e) => handleInputChange('peso_unitario', e.target.value)}
placeholder="Não alterar"
/>
</div>
<div>
<Label htmlFor="valor_unitario">Valor Unitário</Label>
<Input
id="valor_unitario"
type="number"
step="0.01"
value={formData.valor_unitario}
onChange={(e) => handleInputChange('valor_unitario', e.target.value)}
placeholder="Não alterar"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="lote_atual">Lote Atual</Label>
<Input
id="lote_atual"
value={formData.lote_atual}
onChange={(e) => handleInputChange('lote_atual', e.target.value)}
placeholder="Não alterar"
/>
</div>
<div>
<Label htmlFor="fornecedor">Fornecedor</Label>
<Input
id="fornecedor"
value={formData.fornecedor}
onChange={(e) => handleInputChange('fornecedor', e.target.value)}
placeholder="Não alterar"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="localizacao">Localização</Label>
<Select value={formData.localizacao} onValueChange={(value) => handleInputChange('localizacao', value)}>
<SelectTrigger>
<SelectValue placeholder="Não alterar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Não alterar</SelectItem>
{localizacoes?.map((localizacao) => (
<SelectItem key={localizacao.id} value={localizacao.nome}>
{localizacao.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="qualidade_aco">Qualidade do Aço</Label>
<Select value={formData.qualidade_aco} onValueChange={(value) => handleInputChange('qualidade_aco', value)}>
<SelectTrigger>
<SelectValue placeholder="Não alterar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Não alterar</SelectItem>
<SelectItem value="remove">Remover qualidade</SelectItem>
{qualidadesAco?.map((qualidade) => (
<SelectItem key={qualidade.id} value={qualidade.nome}>
{qualidade.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={atualizarMaterial.isPending}>
{atualizarMaterial.isPending ? 'Atualizando...' : 'Salvar Alterações'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,180 @@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
import { EstoqueMaterial } from '@/hooks/useEstoque';
import { useOFsAtivas } from '@/hooks/useOFsAtivas';
import { toast } from 'sonner';
interface EstoqueBatchMovementModalProps {
isOpen: boolean;
onClose: () => void;
selectedMaterials: EstoqueMaterial[];
onSuccess: () => void;
}
export function EstoqueBatchMovementModal({
isOpen,
onClose,
selectedMaterials,
onSuccess
}: EstoqueBatchMovementModalProps) {
const [formData, setFormData] = useState({
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
quantidade: '',
of_vinculada: '',
observacoes: '',
data_movimentacao: new Date().toISOString().split('T')[0]
});
const criarMovimentacao = useCriarMovimentacao();
const { data: ofsAtivas = [] } = useOFsAtivas();
const handleSubmit = async () => {
if (!formData.quantidade || !formData.tipo_movimentacao) {
toast.error('Preencha os campos obrigatórios');
return;
}
// Validar se OF é obrigatória para empenho e desempenho
if ((formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho') && !formData.of_vinculada) {
toast.error('OF vinculada é obrigatória para movimentações de empenho e desempenho');
return;
}
try {
const quantidade = parseFloat(formData.quantidade);
// Create movement for each selected material
for (const material of selectedMaterials) {
await criarMovimentacao.mutateAsync({
material_id: material.id,
tipo_movimentacao: formData.tipo_movimentacao,
quantidade,
of_vinculada: formData.of_vinculada || undefined,
observacoes: formData.observacoes || undefined,
data_movimentacao: formData.data_movimentacao
});
}
toast.success(`Movimentação criada para ${selectedMaterials.length} material(is)!`);
onSuccess();
onClose();
} catch (error) {
console.error('Erro ao criar movimentação em lote:', error);
toast.error('Erro ao criar movimentação em lote');
}
};
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Movimentação em Lote</DialogTitle>
<p className="text-sm text-muted-foreground">
Criará a mesma movimentação para {selectedMaterials.length} material(is) selecionado(s)
</p>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
<div className="space-y-2">
<Label>Tipo de Movimentação *</Label>
<Select value={formData.tipo_movimentacao} onValueChange={(value: any) => handleInputChange('tipo_movimentacao', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="entrada">Entrada</SelectItem>
<SelectItem value="saida">Saída</SelectItem>
<SelectItem value="transferencia">Transferência</SelectItem>
<SelectItem value="ajuste">Ajuste</SelectItem>
<SelectItem value="empenho">Empenho</SelectItem>
<SelectItem value="desempenho">Desempenho</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Quantidade *</Label>
<Input
type="number"
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', e.target.value)}
placeholder="Quantidade a movimentar"
required
/>
</div>
<div className="space-y-2">
<Label>Data da Movimentação</Label>
<Input
type="date"
value={formData.data_movimentacao}
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
/>
</div>
{requiresOF ? (
<div className="space-y-2">
<Label>OF Vinculada *</Label>
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{ofsAtivas.map((of) => (
<SelectItem key={of.of_number} value={of.of_number}>
{of.of_number} - {of.cliente || 'Cliente não informado'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-2">
<Label>OF Vinculada</Label>
<Input
value={formData.of_vinculada}
onChange={(e) => handleInputChange('of_vinculada', e.target.value)}
placeholder="Número da OF (opcional)"
/>
</div>
)}
<div className="space-y-2 md:col-span-2">
<Label>Observações</Label>
<Textarea
value={formData.observacoes}
onChange={(e) => handleInputChange('observacoes', e.target.value)}
placeholder="Observações sobre a movimentação"
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button
onClick={handleSubmit}
disabled={criarMovimentacao.isPending}
>
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,428 @@
import React, { useState, useCallback } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useDropzone } from 'react-dropzone';
import { Upload, FileText, AlertTriangle, CheckCircle, X } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCriarMaterial } from '@/hooks/useEstoque';
import { toast } from 'sonner';
interface EstoqueCSVImportModalProps {
isOpen: boolean;
onClose: () => void;
}
interface CSVRow {
[key: string]: string;
}
interface ValidationError {
row: number;
field: string;
value: string;
message: string;
}
interface ProcessedMaterial {
codigo: string;
descricao: string;
tipo_material_id?: string;
unidade: string;
quantidade_total: number;
quantidade_disponivel: number;
quantidade_empenhada: number;
quantidade_minima: number;
quantidade_maxima?: number;
peso_unitario: number;
valor_unitario?: number;
lote_atual?: string;
fornecedor?: string;
localizacao?: string;
status: 'Normal' | 'Crítico' | 'Excesso';
certificado?: string;
observacoes?: string;
comprimento?: number;
largura?: number;
espessura?: number;
qualidade_aco?: string;
kg_por_metro?: number;
}
const REQUIRED_FIELDS = ['descricao', 'unidade'];
const VALID_STATUS = ['Normal', 'Crítico', 'Excesso'] as const;
const VALID_UNITS = ['PC', 'KG', 'M', 'M2', 'M3', 'L', 'UN'];
export function EstoqueCSVImportModal({ isOpen, onClose }: EstoqueCSVImportModalProps) {
const [csvData, setCsvData] = useState<CSVRow[]>([]);
const [processedData, setProcessedData] = useState<ProcessedMaterial[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [fileName, setFileName] = useState('');
const criarMaterial = useCriarMaterial();
// Function to generate a unique codigo
const generateCodigo = (descricao: string, index: number): string => {
const prefix = descricao.substring(0, 3).toUpperCase().replace(/[^A-Z]/g, 'X');
const timestamp = Date.now().toString().slice(-6);
const indexStr = index.toString().padStart(3, '0');
return `${prefix}${timestamp}${indexStr}`;
};
const onDrop = useCallback((acceptedFiles: File[]) => {
const file = acceptedFiles[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
parseCSV(text);
};
reader.readAsText(file);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/csv': ['.csv'],
'application/vnd.ms-excel': ['.csv']
},
maxFiles: 1
});
const parseCSV = (text: string) => {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) {
toast.error('Arquivo CSV deve ter pelo menos uma linha de cabeçalho e uma linha de dados');
return;
}
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const rows: CSVRow[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
const row: CSVRow = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
rows.push(row);
}
setCsvData(rows);
processAndValidateData(rows);
};
const processAndValidateData = (data: CSVRow[]) => {
setIsProcessing(true);
const errors: ValidationError[] = [];
const processed: ProcessedMaterial[] = [];
data.forEach((row, index) => {
// Validate and normalize status
let normalizedStatus: 'Normal' | 'Crítico' | 'Excesso' = 'Normal';
if (row.status && row.status.trim()) {
const statusValue = row.status.trim();
if (VALID_STATUS.includes(statusValue as any)) {
normalizedStatus = statusValue as 'Normal' | 'Crítico' | 'Excesso';
} else {
errors.push({
row: index + 1,
field: 'status',
value: statusValue,
message: `Status inválido. Use: ${VALID_STATUS.join(', ')}`
});
}
}
// Generate codigo if not provided
const codigo = row.codigo?.trim() || generateCodigo(row.descricao || 'MATERIAL', index);
const material: ProcessedMaterial = {
codigo,
descricao: row.descricao?.trim() || '',
unidade: row.unidade?.trim() || 'PC',
quantidade_total: parseFloat(row.quantidade_total) || 0,
quantidade_disponivel: parseFloat(row.quantidade_disponivel) || 0,
quantidade_empenhada: parseFloat(row.quantidade_empenhada) || 0,
quantidade_minima: parseFloat(row.quantidade_minima) || 0,
quantidade_maxima: row.quantidade_maxima ? parseFloat(row.quantidade_maxima) : undefined,
peso_unitario: parseFloat(row.peso_unitario) || 0,
valor_unitario: row.valor_unitario ? parseFloat(row.valor_unitario) : undefined,
lote_atual: row.lote_atual?.trim() || undefined,
fornecedor: row.fornecedor?.trim() || undefined,
localizacao: row.localizacao?.trim() || undefined,
status: normalizedStatus,
certificado: row.certificado?.trim() || undefined,
observacoes: row.observacoes?.trim() || undefined,
comprimento: row.comprimento ? parseFloat(row.comprimento) : undefined,
largura: row.largura ? parseFloat(row.largura) : undefined,
espessura: row.espessura ? parseFloat(row.espessura) : undefined,
qualidade_aco: row.qualidade_aco?.trim() || undefined,
kg_por_metro: row.kg_por_metro ? parseFloat(row.kg_por_metro) : undefined,
};
// Validações
REQUIRED_FIELDS.forEach(field => {
if (!material[field as keyof ProcessedMaterial]) {
errors.push({
row: index + 1,
field,
value: row[field] || '',
message: `Campo obrigatório não informado`
});
}
});
// Validar unidade
if (material.unidade && !VALID_UNITS.includes(material.unidade)) {
errors.push({
row: index + 1,
field: 'unidade',
value: material.unidade,
message: `Unidade inválida. Use: ${VALID_UNITS.join(', ')}`
});
}
// Validar quantidades negativas
const quantityFields = ['quantidade_total', 'quantidade_disponivel', 'quantidade_empenhada', 'quantidade_minima'];
quantityFields.forEach(field => {
const value = material[field as keyof ProcessedMaterial] as number;
if (value < 0) {
errors.push({
row: index + 1,
field,
value: value.toString(),
message: 'Quantidade não pode ser negativa'
});
}
});
processed.push(material);
});
setValidationErrors(errors);
setProcessedData(processed);
setShowPreview(true);
setIsProcessing(false);
};
const handleImport = async () => {
if (validationErrors.length > 0) {
toast.error('Corrija os erros de validação antes de continuar');
return;
}
setIsProcessing(true);
let successCount = 0;
let errorCount = 0;
for (const material of processedData) {
try {
await criarMaterial.mutateAsync(material);
successCount++;
} catch (error) {
console.error('Erro ao importar material:', error);
errorCount++;
}
}
setIsProcessing(false);
if (successCount > 0) {
toast.success(`${successCount} material(is) importado(s) com sucesso!`);
}
if (errorCount > 0) {
toast.error(`${errorCount} material(is) falharam na importação`);
}
if (successCount > 0) {
handleClose();
}
};
const handleClose = () => {
setCsvData([]);
setProcessedData([]);
setValidationErrors([]);
setShowPreview(false);
setFileName('');
setIsProcessing(false);
onClose();
};
const getErrorsForRow = (rowIndex: number) => {
return validationErrors.filter(error => error.row === rowIndex + 1);
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Importar Materiais CSV
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden">
{!showPreview ? (
<div className="space-y-6">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-blue-600">Solte o arquivo aqui...</p>
) : (
<div>
<p className="text-lg font-medium text-gray-900 mb-2">
Arraste um arquivo CSV ou clique para selecionar
</p>
<p className="text-sm text-gray-500">
Arquivos .csv são aceitos
</p>
</div>
)}
</div>
<Card>
<CardContent className="p-6">
<h3 className="font-medium text-gray-900 mb-4">Formato esperado do CSV:</h3>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>Campos obrigatórios:</strong> descricao, unidade</p>
<p><strong>Campos opcionais:</strong> codigo, quantidade_total, quantidade_disponivel, quantidade_empenhada, quantidade_minima, quantidade_maxima, peso_unitario, valor_unitario, lote_atual, fornecedor, localizacao, status, certificado, observacoes, comprimento, largura, espessura, qualidade_aco, kg_por_metro</p>
<p><strong>Status válidos:</strong> Normal, Crítico, Excesso</p>
<p><strong>Unidades válidas:</strong> PC, KG, M, M2, M3, L, UN</p>
<p><strong>Nota:</strong> Se o campo 'codigo' não for informado, será gerado automaticamente pelo sistema.</p>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="space-y-4 h-full flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<span className="text-sm font-medium">{fileName}</span>
</div>
<Badge variant={validationErrors.length > 0 ? "destructive" : "default"}>
{processedData.length} registros
</Badge>
{validationErrors.length > 0 && (
<Badge variant="destructive">
<AlertTriangle className="h-3 w-3 mr-1" />
{validationErrors.length} erros
</Badge>
)}
</div>
</div>
{validationErrors.length > 0 && (
<Card className="border-red-200">
<CardContent className="p-4">
<h4 className="font-medium text-red-800 mb-3 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Erros de Validação
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{validationErrors.map((error, index) => (
<div key={index} className="text-sm text-red-700 bg-red-50 p-2 rounded">
<strong>Linha {error.row}:</strong> {error.field} - {error.message}
{error.value && <span className="ml-2 text-red-600">("{error.value}")</span>}
</div>
))}
</div>
</CardContent>
</Card>
)}
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-auto border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Linha</TableHead>
<TableHead>Código</TableHead>
<TableHead>Descrição</TableHead>
<TableHead>Unidade</TableHead>
<TableHead>Qtd Total</TableHead>
<TableHead>Qtd Disp.</TableHead>
<TableHead>Status</TableHead>
<TableHead>Erros</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processedData.map((material, index) => {
const errors = getErrorsForRow(index);
return (
<TableRow key={index} className={errors.length > 0 ? 'bg-red-50' : ''}>
<TableCell>{index + 1}</TableCell>
<TableCell className="max-w-24 truncate">{material.codigo}</TableCell>
<TableCell className="max-w-48 truncate">{material.descricao}</TableCell>
<TableCell>{material.unidade}</TableCell>
<TableCell>{material.quantidade_total}</TableCell>
<TableCell>{material.quantidade_disponivel}</TableCell>
<TableCell>
<Badge variant={material.status === 'Normal' ? 'default' : 'secondary'}>
{material.status}
</Badge>
</TableCell>
<TableCell>
{errors.length > 0 ? (
<Badge variant="destructive">{errors.length}</Badge>
) : (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
{showPreview ? (
<>
<Button variant="outline" onClick={() => setShowPreview(false)} disabled={isProcessing}>
<X className="h-4 w-4 mr-2" />
Voltar
</Button>
<Button
onClick={handleImport}
disabled={isProcessing || validationErrors.length > 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isProcessing ? 'Importando...' : `Importar ${processedData.length} Material(is)`}
</Button>
</>
) : (
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,292 @@
import React, { useState, useMemo } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertTriangle, ShoppingCart, Clock } from 'lucide-react';
import { useMateriaisCriticos } from '@/hooks/useMateriaisCriticos';
import { useMateriaisEmSC } from '@/hooks/useMateriaisEmSC';
import { useSolicitacoesCompra } from '@/hooks/useSolicitacoesCompra';
import { EstoqueMaterial } from '@/hooks/useEstoque';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
interface EstoqueCriticoModalProps {
isOpen: boolean;
onClose: () => void;
}
export function EstoqueCriticoModal({ isOpen, onClose }: EstoqueCriticoModalProps) {
const { materiaisCriticos, loading } = useMateriaisCriticos();
const { data: materiaisEmSCMap, isLoading: loadingMateriaisEmSC } = useMateriaisEmSC();
const { createSolicitacao, isCreating } = useSolicitacoesCompra();
const [selectedMaterials, setSelectedMaterials] = useState<EstoqueMaterial[]>([]);
const navigate = useNavigate();
// Separar materiais críticos em dois grupos
const { materiaisDisponiveis, materiaisEmSC } = useMemo(() => {
if (!materiaisEmSCMap) {
return {
materiaisDisponiveis: materiaisCriticos,
materiaisEmSC: []
};
}
const disponiveis: EstoqueMaterial[] = [];
const emSC: Array<EstoqueMaterial & { solicitacoes: any[] }> = [];
materiaisCriticos.forEach(material => {
if (materiaisEmSCMap.has(material.id)) {
const scInfo = materiaisEmSCMap.get(material.id);
emSC.push({
...material,
solicitacoes: scInfo.solicitacoes
});
} else {
disponiveis.push(material);
}
});
return { materiaisDisponiveis: disponiveis, materiaisEmSC: emSC };
}, [materiaisCriticos, materiaisEmSCMap]);
const handleSelectMaterial = (material: EstoqueMaterial, isSelected: boolean) => {
if (isSelected) {
setSelectedMaterials(prev => [...prev, material]);
} else {
setSelectedMaterials(prev => prev.filter(m => m.id !== material.id));
}
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedMaterials(materiaisDisponiveis);
} else {
setSelectedMaterials([]);
}
};
const handleGerarSC = async () => {
if (selectedMaterials.length === 0) {
toast.error('Selecione pelo menos um material para gerar a SC');
return;
}
try {
const hoje = new Date();
const prazoRecebimento = new Date();
prazoRecebimento.setDate(hoje.getDate() + 3);
const itens = selectedMaterials.map(material => ({
material_id: material.id,
quantidade: Math.ceil((material.quantidade_minima || 1) * 3),
prazo_recebimento: prazoRecebimento.toISOString().split('T')[0]
}));
const solicitacaoData = {
data_solicitacao: hoje.toISOString().split('T')[0],
objetivo: 'Geração de SC automática',
justificativa: `SC gerada automaticamente para reposição de ${selectedMaterials.length} material(is) crítico(s) em estoque.`,
itens,
anexos_urls: []
};
await createSolicitacao(solicitacaoData);
toast.success('Solicitação de compra gerada com sucesso!');
setSelectedMaterials([]);
onClose();
navigate('/solicitacao-compras');
} catch (error) {
console.error('Erro ao gerar SC:', error);
toast.error('Erro ao gerar solicitação de compra');
}
};
const isAllSelected = () => {
return materiaisDisponiveis.length > 0 && selectedMaterials.length === materiaisDisponiveis.length;
};
if (loading || loadingMateriaisEmSC) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-7xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Materiais com Estoque Crítico
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-4">
{materiaisCriticos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertTriangle className="h-12 w-12 text-green-500 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
Nenhum material crítico encontrado
</h3>
<p className="text-muted-foreground">
Todos os materiais estão com quantidades adequadas em estoque.
</p>
</div>
) : (
<div className="space-y-6">
{/* Materiais já em SC */}
{materiaisEmSC.length > 0 && (
<Card className="border-orange-200 bg-orange-50/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-700">
<Clock className="h-5 w-5" />
Materiais em Solicitação de Compra ({materiaisEmSC.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{materiaisEmSC.map((material) => (
<div key={material.id} className="bg-white p-4 rounded-lg border border-orange-200">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-medium text-foreground">{material.descricao}</h4>
<p className="text-sm text-muted-foreground">
Código: {material.codigo} | Disponível: {material.quantidade_disponivel} {material.unidade}
</p>
</div>
<Badge variant="outline" className="text-orange-600 bg-orange-100 border-orange-300">
Em SC
</Badge>
</div>
<div className="space-y-1">
{material.solicitacoes.map((sc, index) => (
<div key={index} className="text-xs bg-orange-100 px-2 py-1 rounded">
<span className="font-medium">{sc.numero_sc}</span> -
Qtd: {sc.quantidade} -
Status: <span className="font-medium">{sc.status}</span>
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Materiais disponíveis para nova SC */}
{materiaisDisponiveis.length > 0 && (
<Card className="border-red-200 bg-red-50/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-700">
<AlertTriangle className="h-5 w-5" />
Materiais Disponíveis para Nova SC ({materiaisDisponiveis.length})
</CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{selectedMaterials.length > 0 && (
<Badge variant="secondary">
{selectedMaterials.length} selecionado(s)
</Badge>
)}
</div>
<Button
onClick={handleGerarSC}
disabled={selectedMaterials.length === 0 || isCreating}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<ShoppingCart className="w-4 h-4 mr-2" />
{isCreating ? 'Gerando SC...' : 'Gerar SC Automática'}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-md">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-12">
<input
type="checkbox"
checked={isAllSelected()}
onChange={handleSelectAll}
className="w-4 h-4"
/>
</TableHead>
<TableHead>Descrição</TableHead>
<TableHead>Código</TableHead>
<TableHead>Lote</TableHead>
<TableHead>Unidade</TableHead>
<TableHead>Qtd. Disponível</TableHead>
<TableHead>Qtd. Mínima</TableHead>
<TableHead>Qtd. Sugerida</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materiaisDisponiveis.map((material) => {
const quantidadeSugerida = Math.ceil((material.quantidade_minima || 1) * 3);
return (
<TableRow key={material.id}>
<TableCell>
<input
type="checkbox"
checked={selectedMaterials.some(m => m.id === material.id)}
onChange={(e) => handleSelectMaterial(material, e.target.checked)}
className="w-4 h-4"
/>
</TableCell>
<TableCell className="font-medium">
{material.descricao}
</TableCell>
<TableCell>{material.codigo}</TableCell>
<TableCell>{material.lote_atual || '-'}</TableCell>
<TableCell>{material.unidade}</TableCell>
<TableCell className="text-red-600 font-medium">
{material.quantidade_disponivel}
</TableCell>
<TableCell className="font-medium">
{material.quantidade_minima}
</TableCell>
<TableCell className="text-green-600 font-medium">
{quantidadeSugerida}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-red-600 bg-red-100 border-red-300">
{material.status}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)}
</ScrollArea>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={onClose}>
Fechar
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+222
View File
@@ -0,0 +1,222 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendingUp, TrendingDown, RotateCcw, Package, ArrowRightLeft, AlertTriangle } from 'lucide-react';
import { useMovimentacoesEstoque } from '@/hooks/useEstoqueMovimentacoes';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface MovimentacaoEstoque {
id: string;
material_id: string;
tipo_movimentacao: 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho';
quantidade: number;
lote?: string;
fornecedor?: string;
of_vinculada?: string;
observacoes?: string;
data_movimentacao?: string;
created_at: string;
created_by?: string;
estoque_materiais?: {
codigo: string;
descricao: string;
};
}
const getMovementIcon = (tipo: string) => {
switch (tipo) {
case 'entrada':
return <TrendingUp className="h-4 w-4 text-green-600" />;
case 'saida':
return <TrendingDown className="h-4 w-4 text-red-600" />;
case 'ajuste':
return <RotateCcw className="h-4 w-4 text-blue-600" />;
case 'transferencia':
return <ArrowRightLeft className="h-4 w-4 text-purple-600" />;
case 'empenho':
return <Package className="h-4 w-4 text-orange-600" />;
case 'desempenho':
return <AlertTriangle className="h-4 w-4 text-yellow-600" />;
default:
return <Package className="h-4 w-4 text-gray-600" />;
}
};
const getMovementColor = (tipo: string) => {
switch (tipo) {
case 'entrada':
return 'bg-green-100 text-green-800 border-green-200';
case 'saida':
return 'bg-red-100 text-red-800 border-red-200';
case 'ajuste':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'transferencia':
return 'bg-purple-100 text-purple-800 border-purple-200';
case 'empenho':
return 'bg-orange-100 text-orange-800 border-orange-200';
case 'desempenho':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getMovementTitle = (tipo: string) => {
switch (tipo) {
case 'entrada':
return 'Entradas';
case 'saida':
return 'Saídas';
case 'ajuste':
return 'Ajustes';
case 'transferencia':
return 'Transferências';
case 'empenho':
return 'Empenhos';
case 'desempenho':
return 'Desempenhos';
default:
return 'Outras';
}
};
const MovementPanel: React.FC<{
tipo: string;
movimentacoes: MovimentacaoEstoque[];
icon: React.ReactNode;
}> = ({ tipo, movimentacoes, icon }) => {
const recentMovements = movimentacoes
.filter(mov => mov.tipo_movimentacao === tipo)
.slice(0, 5); // Mostrar apenas as 5 mais recentes
if (recentMovements.length === 0) {
return null;
}
return (
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
{icon}
{getMovementTitle(tipo)}
<Badge variant="secondary" className="ml-auto">
{recentMovements.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{recentMovements.map((movimento) => (
<div key={movimento.id} className="border-l-4 border-l-gray-200 pl-3 py-2 hover:bg-gray-50 rounded-r">
<div className="flex items-center justify-between mb-1">
<Badge className={`text-xs ${getMovementColor(movimento.tipo_movimentacao)}`}>
{movimento.tipo_movimentacao.toUpperCase()}
</Badge>
<span className="text-xs text-gray-500">
{format(new Date(movimento.created_at), 'dd/MM HH:mm', { locale: ptBR })}
</span>
</div>
<div className="text-sm font-medium text-gray-900 mb-1">
{movimento.estoque_materiais?.descricao || 'Material não identificado'}
</div>
<div className="flex items-center justify-between text-xs text-gray-600">
<span>Qtd: <strong>{movimento.quantidade}</strong></span>
{movimento.lote && (
<span>Lote: <strong>{movimento.lote}</strong></span>
)}
</div>
{movimento.of_vinculada && (
<div className="text-xs text-gray-500 mt-1">
OF: {movimento.of_vinculada}
</div>
)}
{movimento.fornecedor && (
<div className="text-xs text-gray-500 mt-1">
Fornecedor: {movimento.fornecedor}
</div>
)}
</div>
))}
</CardContent>
</Card>
);
};
export const EstoqueDashboard: React.FC = () => {
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} className="h-80">
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
const tiposMovimentacao = ['entrada', 'saida', 'ajuste', 'transferencia', 'empenho', 'desempenho'];
// Filtrar apenas movimentações dos últimos 30 dias
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const recentMovements = movimentacoes.filter(mov =>
new Date(mov.created_at) >= thirtyDaysAgo
);
return (
<div className="space-y-6">
{/* Resumo Geral */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Resumo de Movimentações (Últimos 30 dias)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{tiposMovimentacao.map(tipo => {
const count = recentMovements.filter(mov => mov.tipo_movimentacao === tipo).length;
return (
<div key={tipo} className="text-center">
<div className="flex items-center justify-center mb-2">
{getMovementIcon(tipo)}
</div>
<div className="text-2xl font-bold text-gray-900">{count}</div>
<div className="text-xs text-gray-500 capitalize">{tipo}</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Painéis de Movimentações */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tiposMovimentacao.map(tipo => (
<MovementPanel
key={tipo}
tipo={tipo}
movimentacoes={recentMovements}
icon={getMovementIcon(tipo)}
/>
))}
</div>
</div>
);
};
@@ -0,0 +1,93 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Package, AlertTriangle, TrendingUp, Box } from 'lucide-react';
import { EstoqueMaterial } from '@/hooks/useEstoque';
interface EstoqueDashboardCardsProps {
materiais: EstoqueMaterial[];
}
export const EstoqueDashboardCards: React.FC<EstoqueDashboardCardsProps> = ({ materiais }) => {
// Calcular Total (kg)
const totalKg = materiais.reduce((acc, material) => {
const kgPorMetro = material.kg_por_metro || 0;
const comprimento = material.comprimento || 0;
const quantidadeTotal = material.quantidade_total || 0;
// Fórmula: (Kg por metro * comprimento / 1000) * quantidade total
const kgItem = (kgPorMetro * comprimento / 1000) * quantidadeTotal;
return acc + kgItem;
}, 0);
// Calcular Total (unid)
const totalUnidades = materiais.reduce((acc, material) => {
return acc + (material.quantidade_total || 0);
}, 0);
// Calcular Total Disponível (unid)
const totalDisponiveis = materiais.reduce((acc, material) => {
return acc + (material.quantidade_disponivel || 0);
}, 0);
// Calcular Total Empenhado (unid)
const totalEmpenhadas = materiais.reduce((acc, material) => {
return acc + (material.quantidade_empenhada || 0);
}, 0);
const cards = [
{
title: 'Total (kg)',
value: totalKg.toFixed(2),
icon: Package,
color: 'text-blue-600',
bgColor: 'bg-blue-50'
},
{
title: 'Total (unid)',
value: totalUnidades.toString(),
icon: Box,
color: 'text-green-600',
bgColor: 'bg-green-50'
},
{
title: 'Total Disp. (unid)',
value: totalDisponiveis.toString(),
icon: TrendingUp,
color: 'text-purple-600',
bgColor: 'bg-purple-50'
},
{
title: 'Total Emp. (unid)',
value: totalEmpenhadas.toString(),
icon: AlertTriangle,
color: 'text-orange-600',
bgColor: 'bg-orange-50'
}
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4 sm:mb-6">
{cards.map((card, index) => {
const IconComponent = card.icon;
return (
<Card key={index} className={`${card.bgColor} border-none shadow-sm`}>
<CardHeader className="pb-2 px-3 sm:px-4 pt-3">
<div className="flex items-center justify-between">
<CardTitle className="text-xs sm:text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
<IconComponent className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent className="px-3 sm:px-4 pb-3">
<div className={`text-lg sm:text-2xl font-bold ${card.color}`}>
{card.value}
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { EstoqueMaterial, useTiposMateriaPrima, useCriarMaterial, useAtualizarMaterial } from '@/hooks/useEstoque';
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
import { useAuth } from '@/hooks/useAuth';
import { MaterialBasicInfo } from './material-form/MaterialBasicInfo';
import { MaterialTechnicalSpecs } from './material-form/MaterialTechnicalSpecs';
import { MaterialQuantitiesValues } from './material-form/MaterialQuantitiesValues';
import { MaterialAdditionalInfo } from './material-form/MaterialAdditionalInfo';
interface EstoqueMaterialModalProps {
isOpen: boolean;
onClose: () => void;
material?: EstoqueMaterial | null;
}
export const EstoqueMaterialModal: React.FC<EstoqueMaterialModalProps> = ({
isOpen,
onClose,
material
}) => {
const { user } = useAuth();
const [formData, setFormData] = useState<Partial<EstoqueMaterial>>({
codigo: '',
descricao: '',
tipo_material_id: '',
unidade: 'PC',
quantidade_total: 0,
quantidade_disponivel: 0,
quantidade_empenhada: 0,
quantidade_minima: 0,
quantidade_maxima: null,
peso_unitario: 0,
lote_atual: '',
localizacao: '',
status: 'Normal',
observacoes: '',
comprimento: null,
largura: null,
espessura: null,
qualidade_aco: ''
});
useEffect(() => {
if (material) {
setFormData({
codigo: material.codigo || '',
descricao: material.descricao || '',
tipo_material_id: material.tipo_material_id || '',
unidade: material.unidade || 'PC',
quantidade_total: material.quantidade_total || 0,
quantidade_disponivel: material.quantidade_disponivel || 0,
quantidade_empenhada: material.quantidade_empenhada || 0,
quantidade_minima: material.quantidade_minima || 0,
quantidade_maxima: material.quantidade_maxima || null,
peso_unitario: material.peso_unitario || 0,
lote_atual: material.lote_atual || '',
localizacao: material.localizacao || '',
status: material.status || 'Normal',
observacoes: material.observacoes || '',
comprimento: material.comprimento || null,
largura: material.largura || null,
espessura: material.espessura || null,
qualidade_aco: material.qualidade_aco || ''
});
} else {
setFormData({
codigo: '',
descricao: '',
tipo_material_id: '',
unidade: 'PC',
quantidade_total: 0,
quantidade_disponivel: 0,
quantidade_empenhada: 0,
quantidade_minima: 0,
quantidade_maxima: null,
peso_unitario: 0,
lote_atual: '',
localizacao: '',
status: 'Normal',
observacoes: '',
comprimento: null,
largura: null,
espessura: null,
qualidade_aco: ''
});
}
}, [material]);
const { data: tiposMaterial } = useTiposMateriaPrima();
const { data: lotes } = useRastreabilidadeMateriais();
const criarMaterial = useCriarMaterial();
const atualizarMaterial = useAtualizarMaterial();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.descricao?.trim()) {
alert('Por favor, preencha a descrição do material.');
return;
}
if (!formData.codigo?.trim()) {
alert('Por favor, preencha o código do material.');
return;
}
try {
// Preparar as observações com log de usuário e data
const currentDate = new Date().toLocaleString('pt-BR');
const userEmail = user?.email || 'Usuário não identificado';
const actionType = material ? 'atualização' : 'criação';
const logEntry = `\n[${currentDate}] ${actionType.charAt(0).toUpperCase() + actionType.slice(1)} realizada por: ${userEmail}`;
const observacoesComLog = (formData.observacoes || '') + logEntry;
const dataToSubmit = {
codigo: formData.codigo!,
descricao: formData.descricao!,
tipo_material_id: formData.tipo_material_id || '',
unidade: formData.unidade || 'PC',
quantidade_total: formData.quantidade_total || 0,
quantidade_disponivel: formData.quantidade_disponivel || 0,
quantidade_empenhada: formData.quantidade_empenhada || 0,
quantidade_minima: formData.quantidade_minima || 0,
quantidade_maxima: formData.quantidade_maxima,
peso_unitario: formData.peso_unitario || 0,
valor_unitario: formData.valor_unitario,
lote_atual: formData.lote_atual,
fornecedor: formData.fornecedor,
localizacao: formData.localizacao,
status: formData.status || 'Normal',
certificado: formData.certificado,
observacoes: observacoesComLog,
comprimento: formData.comprimento,
largura: formData.largura,
espessura: formData.espessura,
qualidade_aco: formData.qualidade_aco,
kg_por_metro: formData.kg_por_metro,
created_by: user?.id
};
if (material) {
if (material.id) {
await atualizarMaterial.mutateAsync({ id: material.id, ...dataToSubmit });
} else {
console.error("ID do material não encontrado para atualização.");
return;
}
} else {
await criarMaterial.mutateAsync(dataToSubmit);
}
onClose();
} catch (error) {
console.error('Erro ao salvar material:', error);
}
};
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{material ? 'Editar Material' : 'Novo Material'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<MaterialBasicInfo
formData={formData}
onInputChange={handleInputChange}
tiposMaterial={tiposMaterial}
lotes={lotes}
isEditing={!!material}
/>
<MaterialTechnicalSpecs
formData={formData}
onInputChange={handleInputChange}
/>
<MaterialQuantitiesValues
formData={formData}
onInputChange={handleInputChange}
/>
<MaterialAdditionalInfo
formData={formData}
onInputChange={handleInputChange}
/>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={criarMaterial.isPending || atualizarMaterial.isPending}>
{criarMaterial.isPending || atualizarMaterial.isPending ? 'Salvando...' : 'Salvar Material'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
+269
View File
@@ -0,0 +1,269 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FileText, Download, Filter, X } from 'lucide-react';
import { PosicaoEstoqueReport } from './reports/PosicaoEstoqueReport';
import { MovimentacoesReport } from './reports/MovimentacoesReport';
import { MateriaisCriticosReport } from './reports/MateriaisCriticosReport';
import { EmpenhosPorOFReport } from './reports/EmpenhosPorOFReport';
import { ReportPreviewModal } from './reports/ReportPreviewModal';
import { useOFsComEmpenhos } from '@/hooks/useEmpenhosMaterial';
const tiposRelatorio = [
{ value: 'posicao', label: 'Posição de Estoque', icon: FileText },
{ value: 'movimentacoes', label: 'Movimentações', icon: FileText },
{ value: 'criticos', label: 'Materiais Críticos', icon: FileText },
{ value: 'empenhos', label: 'Empenhos por OF', icon: FileText }
];
export const EstoqueReports: React.FC = () => {
const [tipoRelatorio, setTipoRelatorio] = useState('');
const [filters, setFilters] = useState({
data_inicio: '',
data_fim: '',
descricao_material: '',
status: 'todos',
of_vinculada: 'todos',
status_empenho: 'todos',
title: ''
});
// Buscar OFs com empenhos usando o novo hook
const { data: ofsComEmpenhos = [] } = useOFsComEmpenhos();
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const handleFilterChange = (field: string, value: string) => {
setFilters(prev => ({ ...prev, [field]: value }));
};
const clearFilters = () => {
setFilters({
data_inicio: '',
data_fim: '',
descricao_material: '',
status: 'todos',
of_vinculada: 'todos',
status_empenho: 'todos',
title: ''
});
};
const renderReport = () => {
// Ajustar os filtros para o relatório
const reportFilters = {
...filters,
of_vinculada: filters.of_vinculada === 'todos' ? '' : filters.of_vinculada
};
switch (tipoRelatorio) {
case 'posicao':
return <PosicaoEstoqueReport filters={reportFilters} />;
case 'movimentacoes':
return <MovimentacoesReport filters={reportFilters} />;
case 'criticos':
return <MateriaisCriticosReport filters={reportFilters} />;
case 'empenhos':
return <EmpenhosPorOFReport filters={reportFilters} />;
default:
return null;
}
};
const hasActiveFilters = Object.values(filters).some(value =>
value !== '' && value !== 'todos'
);
const shouldShowFilters = tipoRelatorio !== '';
const getReportId = () => {
return `relatorio-${tipoRelatorio}-${Date.now()}`;
};
return (
<>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Relatórios de Estoque
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Seleção do tipo de relatório */}
<div>
<Label htmlFor="tipo-relatorio">Tipo de Relatório</Label>
<Select value={tipoRelatorio} onValueChange={setTipoRelatorio}>
<SelectTrigger>
<SelectValue placeholder="Selecione o tipo de relatório" />
</SelectTrigger>
<SelectContent>
{tiposRelatorio.map((tipo) => {
const Icon = tipo.icon;
return (
<SelectItem key={tipo.value} value={tipo.value}>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" />
{tipo.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Filtros */}
{shouldShowFilters && (
<div className="space-y-4 p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtros
</h3>
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="h-8 flex items-center gap-1"
>
<X className="h-3 w-3" />
Limpar
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Filtros de data */}
{(tipoRelatorio === 'movimentacoes' || tipoRelatorio === 'empenhos') && (
<>
<div>
<Label htmlFor="data-inicio">Data Início</Label>
<Input
id="data-inicio"
type="date"
value={filters.data_inicio}
onChange={(e) => handleFilterChange('data_inicio', e.target.value)}
/>
</div>
<div>
<Label htmlFor="data-fim">Data Fim</Label>
<Input
id="data-fim"
type="date"
value={filters.data_fim}
onChange={(e) => handleFilterChange('data_fim', e.target.value)}
/>
</div>
</>
)}
{/* Filtro de material */}
{tipoRelatorio !== 'empenhos' && (
<div>
<Label htmlFor="descricao-material">Material (descrição)</Label>
<Input
id="descricao-material"
placeholder="Digite parte da descrição..."
value={filters.descricao_material}
onChange={(e) => handleFilterChange('descricao_material', e.target.value)}
/>
</div>
)}
{/* Filtro de status para posição */}
{tipoRelatorio === 'posicao' && (
<div>
<Label htmlFor="status">Status</Label>
<Select value={filters.status} onValueChange={(value) => handleFilterChange('status', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="Normal">Normal</SelectItem>
<SelectItem value="Crítico">Crítico</SelectItem>
<SelectItem value="Excesso">Excesso</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Filtros específicos para empenhos */}
{tipoRelatorio === 'empenhos' && (
<>
<div>
<Label htmlFor="of-empenhos">OF com Empenhos</Label>
<Select value={filters.of_vinculada} onValueChange={(value) => handleFilterChange('of_vinculada', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione uma OF" />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todas as OFs</SelectItem>
{ofsComEmpenhos.map((of) => (
<SelectItem key={of} value={of}>
{of}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status-empenho">Status do Empenho</Label>
<Select value={filters.status_empenho} onValueChange={(value) => handleFilterChange('status_empenho', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem>
<SelectItem value="Empenhado">Empenhado</SelectItem>
<SelectItem value="Finalizado">Finalizado</SelectItem>
<SelectItem value="Cancelado">Cancelado</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
</div>
)}
{/* Ações */}
{tipoRelatorio && (
<div className="flex gap-2">
<Button onClick={() => setIsPreviewOpen(true)}>
<FileText className="h-4 w-4 mr-2" />
Gerar Relatório
</Button>
</div>
)}
{/* Preview do relatório */}
{tipoRelatorio && !isPreviewOpen && (
<div className="border rounded-lg p-4 bg-background">
<div className="max-h-96 overflow-auto">
{renderReport()}
</div>
</div>
)}
</CardContent>
</Card>
<ReportPreviewModal
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
title={`Relatório: ${tiposRelatorio.find(t => t.value === tipoRelatorio)?.label || ''}`}
reportId={getReportId()}
filters={filters}
>
{renderReport()}
</ReportPreviewModal>
</>
);
};
+417
View File
@@ -0,0 +1,417 @@
import React, { useState, useMemo } from 'react';
import { StandardCard } from '@/components/layout/StandardCard';
import { Package, Search, Download, Upload, Settings, Users, Plus, Database, AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useEstoque } from '@/hooks/useEstoque';
import { EstoqueDashboardCards } from './EstoqueDashboardCards';
import { EstoqueMaterialModal } from './EstoqueMaterialModal';
import { TiposFiltroButtonsOtimizado } from './TiposFiltroButtonsOtimizado';
import { EstoqueBatchActions } from './EstoqueBatchActions';
import { EstoqueBatchEditModal } from './EstoqueBatchEditModal';
import { MovimentacaoModalSimplificada } from './MovimentacaoModalSimplificada';
import { EstoqueDesktopTable } from './table/EstoqueDesktopTable';
import { EstoqueMobileView } from './table/EstoqueMobileView';
import { CrudModalsManager } from './CrudModalsManager';
import { RastreabilidadeMP } from './RastreabilidadeMP';
import { EstoqueCriticoModal } from './EstoqueCriticoModal';
import { EstoqueMaterial } from '@/hooks/useEstoque';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
export function EstoqueTable() {
const { materiais, loading } = useEstoque();
const [tipoSelecionado, setTipoSelecionado] = useState('all');
const [categoriaFilter, setCategoriaFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('Normal');
const [searchTerm, setSearchTerm] = useState('');
const [descricaoFilter, setDescricaoFilter] = useState('');
const [loteFilter, setLoteFilter] = useState('');
const [selectedMaterials, setSelectedMaterials] = useState<EstoqueMaterial[]>([]);
const [sortField, setSortField] = useState('descricao');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [editingMaterial, setEditingMaterial] = useState<EstoqueMaterial | null>(null);
const [showMaterialModal, setShowMaterialModal] = useState(false);
const [showMovimentacaoModal, setShowMovimentacaoModal] = useState(false);
const [showBatchEditModal, setShowBatchEditModal] = useState(false);
const [showRastreabilidadeModal, setShowRastreabilidadeModal] = useState(false);
const [showEstoqueCriticoModal, setShowEstoqueCriticoModal] = useState(false);
const materiaisFiltrados = useMemo(() => {
let filtered = materiais;
// Filtro por termo de busca geral
if (searchTerm) {
filtered = filtered.filter(material =>
material.descricao.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.lote_atual?.toLowerCase().includes(searchTerm.toLowerCase()) ||
material.codigo.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Filtro específico por descrição
if (descricaoFilter) {
filtered = filtered.filter(material =>
material.descricao.toLowerCase().includes(descricaoFilter.toLowerCase())
);
}
// Filtro específico por lote
if (loteFilter) {
filtered = filtered.filter(material =>
material.lote_atual?.toLowerCase().includes(loteFilter.toLowerCase())
);
}
// Filtro por status
if (statusFilter !== 'all') {
filtered = filtered.filter(material => material.status === statusFilter);
}
// Filtro por categoria (direto/indireto)
if (categoriaFilter !== 'all') {
filtered = filtered.filter(material => material.tipos_materia_prima?.categoria === categoriaFilter);
}
// Filtro por tipo específico (botões dos grupos)
if (tipoSelecionado !== 'all') {
filtered = filtered.filter(material => material.tipos_materia_prima?.nome === tipoSelecionado);
}
filtered.sort((a, b) => {
let aValue, bValue;
switch (sortField) {
case 'descricao':
aValue = a.descricao;
bValue = b.descricao;
break;
case 'tipo':
aValue = a.tipos_materia_prima?.nome || '';
bValue = b.tipos_materia_prima?.nome || '';
break;
case 'quantidade_total':
aValue = a.quantidade_total;
bValue = b.quantidade_total;
break;
case 'quantidade_disponivel':
aValue = a.quantidade_disponivel;
bValue = b.quantidade_disponivel;
break;
case 'quantidade_empenhada':
aValue = a.quantidade_empenhada;
bValue = b.quantidade_empenhada;
break;
case 'status':
aValue = a.status;
bValue = b.status;
break;
default:
aValue = a.descricao;
bValue = b.descricao;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sortOrder === 'asc'
? aValue.localeCompare(bValue)
: bValue.localeCompare(aValue);
}
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
}
return 0;
});
return filtered;
}, [materiais, searchTerm, descricaoFilter, loteFilter, statusFilter, categoriaFilter, tipoSelecionado, sortField, sortOrder]);
const handleSort = (field: string) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const handleSelectMaterial = (material: EstoqueMaterial, isSelected: boolean) => {
if (isSelected) {
setSelectedMaterials(prev => [...prev, material]);
} else {
setSelectedMaterials(prev => prev.filter(m => m.id !== material.id));
}
};
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedMaterials(materiaisFiltrados);
} else {
setSelectedMaterials([]);
}
};
const isAllSelected = () => {
return materiaisFiltrados.length > 0 && selectedMaterials.length === materiaisFiltrados.length;
};
const handleEditMaterial = (material: EstoqueMaterial) => {
setEditingMaterial(material);
setShowMaterialModal(true);
};
const handleCloseModal = () => {
setShowMaterialModal(false);
setEditingMaterial(null);
};
const handleBatchEditSuccess = () => {
setSelectedMaterials([]);
setShowBatchEditModal(false);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'Crítico':
return 'text-red-600 bg-red-100 border-red-300';
case 'Normal':
return 'text-green-600 bg-green-100 border-green-300';
case 'Excesso':
return 'text-yellow-600 bg-yellow-100 border-yellow-300';
default:
return 'text-gray-600 bg-gray-100 border-gray-300';
}
};
const handleClearFilters = () => {
setSearchTerm('');
setDescricaoFilter('');
setLoteFilter('');
setStatusFilter('Normal');
setCategoriaFilter('all');
setTipoSelecionado('all');
setSelectedMaterials([]);
toast.success('Filtros limpos');
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* Dashboard Cards */}
<EstoqueDashboardCards materiais={materiais} />
<StandardCard title="Materiais em Estoque" icon={Package}>
<div className="space-y-4">
{/* Header com botões de ação */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">Controle de Materiais</h3>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => setShowMaterialModal(true)}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Plus className="w-4 h-4 mr-2" />
Novo Material
</Button>
<Button
onClick={() => setShowEstoqueCriticoModal(true)}
variant="outline"
className="bg-red-50 border-red-200 text-red-700 hover:bg-red-100"
>
<AlertTriangle className="w-4 h-4 mr-2" />
Estoque Crítico
</Button>
<CrudModalsManager />
<Button
variant="outline"
onClick={() => setShowRastreabilidadeModal(true)}
className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
>
<Database className="w-4 h-4 mr-2" />
Rastreabilidade
</Button>
</div>
</div>
{/* Filtros de Tipos */}
<TiposFiltroButtonsOtimizado
tipoSelecionado={tipoSelecionado}
onTipoChange={setTipoSelecionado}
categoriaFilter={categoriaFilter}
onCategoriaChange={setCategoriaFilter}
/>
{/* Filtros superiores com novos filtros específicos */}
<div className="space-y-4">
{/* Primeira linha - filtros principais */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Buscar por descrição ou lote..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os Status</SelectItem>
<SelectItem value="Normal">Normal</SelectItem>
<SelectItem value="Crítico">Crítico</SelectItem>
<SelectItem value="Excesso">Excesso</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleClearFilters}>
Limpar Filtros
</Button>
</div>
{/* Segunda linha - filtros específicos para Descrição e Lote */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center bg-muted/30 p-3 rounded-lg border">
<div className="flex-1">
<Input
placeholder="Filtrar por descrição..."
value={descricaoFilter}
onChange={(e) => setDescricaoFilter(e.target.value)}
className="bg-background"
/>
</div>
<div className="flex-1">
<Input
placeholder="Filtrar por lote..."
value={loteFilter}
onChange={(e) => setLoteFilter(e.target.value)}
className="bg-background"
/>
</div>
</div>
</div>
{/* Informações dos filtros */}
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>Total: {materiaisFiltrados.length} materiais</span>
{searchTerm && (
<Badge variant="secondary">
Busca: {searchTerm}
</Badge>
)}
{descricaoFilter && (
<Badge variant="secondary">
Descrição: {descricaoFilter}
</Badge>
)}
{loteFilter && (
<Badge variant="secondary">
Lote: {loteFilter}
</Badge>
)}
{statusFilter !== 'all' && (
<Badge variant="secondary">
Status: {statusFilter}
</Badge>
)}
{categoriaFilter !== 'all' && (
<Badge variant="secondary">
Categoria: {categoriaFilter === 'direto' ? 'Diretos' : 'Indiretos'}
</Badge>
)}
{tipoSelecionado !== 'all' && (
<Badge variant="secondary">
Tipo: {tipoSelecionado}
</Badge>
)}
</div>
{/* Ações em lote */}
{selectedMaterials.length > 0 && (
<EstoqueBatchActions
selectedMaterials={selectedMaterials}
onClearSelection={() => setSelectedMaterials([])}
onShowMovimentacao={() => setShowMovimentacaoModal(true)}
onShowBatchEdit={() => setShowBatchEditModal(true)}
/>
)}
{/* Tabela Desktop */}
<EstoqueDesktopTable
materiais={materiaisFiltrados}
selectedMaterials={selectedMaterials}
onSelectMaterial={handleSelectMaterial}
onEditMaterial={handleEditMaterial}
onSelectAll={handleSelectAll}
isAllSelected={isAllSelected}
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
getStatusColor={getStatusColor}
/>
{/* Vista Mobile */}
<EstoqueMobileView
materiais={materiaisFiltrados}
selectedMaterials={selectedMaterials}
onSelectMaterial={handleSelectMaterial}
onEditMaterial={handleEditMaterial}
getStatusColor={getStatusColor}
/>
</div>
</StandardCard>
{/* Modal de Material */}
<EstoqueMaterialModal
isOpen={showMaterialModal}
onClose={handleCloseModal}
material={editingMaterial}
/>
{/* Modal de Edição em Lote */}
<EstoqueBatchEditModal
isOpen={showBatchEditModal}
onClose={() => setShowBatchEditModal(false)}
selectedMaterials={selectedMaterials}
onSuccess={handleBatchEditSuccess}
/>
{/* Modal de Movimentação */}
<MovimentacaoModalSimplificada
isOpen={showMovimentacaoModal}
onClose={() => setShowMovimentacaoModal(false)}
selectedMaterials={selectedMaterials}
/>
{/* Modal de Estoque Crítico */}
<EstoqueCriticoModal
isOpen={showEstoqueCriticoModal}
onClose={() => setShowEstoqueCriticoModal(false)}
/>
{/* Modal de Rastreabilidade */}
<Dialog open={showRastreabilidadeModal} onOpenChange={setShowRastreabilidadeModal}>
<DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Rastreabilidade de Matéria Prima</DialogTitle>
</DialogHeader>
<RastreabilidadeMP />
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,110 @@
import React from 'react';
import { TableHead, TableRow } from '@/components/ui/table';
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface EstoqueTableHeaderProps {
sortField: string;
sortOrder: 'asc' | 'desc';
onSort: (field: string) => void;
isAllSelected: boolean;
onSelectAll: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const EstoqueTableHeader: React.FC<EstoqueTableHeaderProps> = ({
sortField,
sortOrder,
onSort,
isAllSelected,
onSelectAll
}) => {
const getSortIcon = (field: string) => {
if (sortField !== field) return <ArrowUpDown className="h-3 w-3" />;
return sortOrder === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />;
};
return (
<TableRow>
<TableHead className="w-8 px-1">
<input
type="checkbox"
className="w-3 h-3"
checked={isAllSelected}
onChange={onSelectAll}
/>
</TableHead>
<TableHead className="w-80 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('descricao')}
className="h-6 px-1 text-xs font-medium"
>
Descrição
{getSortIcon('descricao')}
</Button>
</TableHead>
<TableHead className="w-24 px-2 text-xs font-medium">Lote</TableHead>
<TableHead className="w-32 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('tipo')}
className="h-6 px-1 text-xs font-medium"
>
Tipo
{getSortIcon('tipo')}
</Button>
</TableHead>
<TableHead className="w-16 px-2 text-xs font-medium">Un.</TableHead>
<TableHead className="w-16 px-2 text-xs font-medium">Comp.</TableHead>
<TableHead className="w-20 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('quantidade_total')}
className="h-6 px-1 text-xs font-medium"
>
Total
{getSortIcon('quantidade_total')}
</Button>
</TableHead>
<TableHead className="w-20 px-2 text-xs font-medium">Peso</TableHead>
<TableHead className="w-20 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('quantidade_disponivel')}
className="h-6 px-1 text-xs font-medium"
>
Disp.
{getSortIcon('quantidade_disponivel')}
</Button>
</TableHead>
<TableHead className="w-20 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('quantidade_empenhada')}
className="h-6 px-1 text-xs font-medium"
>
Emp.
{getSortIcon('quantidade_empenhada')}
</Button>
</TableHead>
<TableHead className="w-24 px-2">
<Button
variant="ghost"
size="sm"
onClick={() => onSort('status')}
className="h-6 px-1 text-xs font-medium"
>
Status
{getSortIcon('status')}
</Button>
</TableHead>
<TableHead className="w-24 px-2 text-xs font-medium">Ações</TableHead>
</TableRow>
);
};
+177
View File
@@ -0,0 +1,177 @@
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 { Plus, Edit, Trash2 } from 'lucide-react';
import { useLocalizacoesEstoque, useCriarLocalizacao, useAtualizarLocalizacao, useExcluirLocalizacao } from '@/hooks/useEstoqueCRUD';
import { toast } from 'sonner';
interface LocalizacaoModalProps {
isOpen: boolean;
onClose: () => void;
}
export const LocalizacaoModal: React.FC<LocalizacaoModalProps> = ({ isOpen, onClose }) => {
const { data: localizacoes = [], isLoading } = useLocalizacoesEstoque();
const criarLocalizacao = useCriarLocalizacao();
const atualizarLocalizacao = useAtualizarLocalizacao();
const excluirLocalizacao = useExcluirLocalizacao();
const [novaLocalizacao, setNovaLocalizacao] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const handleAdd = async () => {
if (novaLocalizacao.trim() && !localizacoes.some(loc => loc.nome === novaLocalizacao.trim())) {
try {
await criarLocalizacao.mutateAsync({
nome: novaLocalizacao.trim(),
codigo: novaLocalizacao.trim().substring(0, 10).toUpperCase(),
descricao: `Localização ${novaLocalizacao.trim()}`,
ativo: true
});
setNovaLocalizacao('');
toast.success('Localização criada com sucesso!');
} catch (error) {
toast.error('Erro ao criar localização');
}
}
};
const handleEdit = (localizacao: any) => {
setEditingId(localizacao.id);
setEditValue(localizacao.nome);
};
const handleSaveEdit = async () => {
if (editValue.trim() && editingId && !localizacoes.some(loc => loc.nome === editValue.trim() && loc.id !== editingId)) {
try {
const localizacao = localizacoes.find(loc => loc.id === editingId);
if (localizacao) {
await atualizarLocalizacao.mutateAsync({
...localizacao,
nome: editValue.trim(),
codigo: editValue.trim().substring(0, 10).toUpperCase(),
descricao: `Localização ${editValue.trim()}`
});
setEditingId(null);
setEditValue('');
toast.success('Localização atualizada com sucesso!');
}
} catch (error) {
toast.error('Erro ao atualizar localização');
}
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditValue('');
};
const handleDelete = async (id: string) => {
if (window.confirm('Tem certeza que deseja excluir esta localização?')) {
try {
await excluirLocalizacao.mutateAsync(id);
toast.success('Localização excluída com sucesso!');
} catch (error) {
toast.error('Erro ao excluir localização');
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Localizações</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="nova-localizacao">Nova Localização</Label>
<Input
id="nova-localizacao"
value={novaLocalizacao}
onChange={(e) => setNovaLocalizacao(e.target.value)}
placeholder="Ex: Estoque A1, Galpão Principal"
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div className="flex items-end gap-2">
<Button onClick={handleAdd} disabled={!novaLocalizacao.trim()}>
<Plus className="w-4 h-4 mr-2" />
Adicionar
</Button>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium">Localizações Cadastradas ({localizacoes.length})</h3>
<Table>
<TableHeader>
<TableRow className="h-8">
<TableHead className="py-2">Localização</TableHead>
<TableHead className="w-24 py-2">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{localizacoes.map((localizacao) => (
<TableRow key={localizacao.id} className="h-8">
<TableCell className="py-1">
{editingId === localizacao.id ? (
<div className="flex gap-2">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
className="h-7"
/>
<Button size="sm" onClick={handleSaveEdit} className="h-7">
Salvar
</Button>
<Button size="sm" variant="outline" onClick={handleCancelEdit} className="h-7">
Cancelar
</Button>
</div>
) : (
<span className="font-medium">{localizacao.nome}</span>
)}
</TableCell>
<TableCell className="py-1">
{editingId !== localizacao.id && (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(localizacao)}
className="h-6 w-6 p-0"
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(localizacao.id)}
className="h-6 w-6 p-0 text-red-500"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Plus, TrendingUp, Trash2, Package } from 'lucide-react';
import { useMovimentacoesEstoque } from '@/hooks/useEstoque';
import { MovimentacaoModal } from './MovimentacaoModal';
import { MovimentacaoTableHeader } from './MovimentacaoTableHeader';
import { Skeleton } from '@/components/ui/skeleton';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
export const MovimentacaoEstoque = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: 'asc' | 'desc';
}>({ key: 'created_at', direction: 'desc' });
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
const queryClient = useQueryClient();
const deleteMovimentacao = useMutation({
mutationFn: async (movimentacaoId: string) => {
console.log('🗑️ Tentando excluir movimentação:', movimentacaoId);
// Buscar detalhes da movimentação primeiro
const { data: movimentacao, error: fetchError } = await supabase
.from('movimentacoes_estoque')
.select('*, estoque_materiais(codigo, descricao)')
.eq('id', movimentacaoId)
.single();
if (fetchError) {
console.error('Erro ao buscar movimentação:', fetchError);
throw new Error('Movimentação não encontrada');
}
console.log('📋 Movimentação encontrada:', movimentacao);
// Se for movimentação de empenho, precisa cancelar o empenho vinculado primeiro
if (movimentacao.tipo_movimentacao === 'empenho') {
console.log('🔗 Removendo empenho vinculado...');
// Buscar empenho vinculado a esta movimentação
const { data: empenhoVinculado, error: empenhoError } = await supabase
.from('empenhos_material')
.select('id')
.eq('movimentacao_empenho_id', movimentacaoId)
.maybeSingle();
if (empenhoError) {
console.error('Erro ao buscar empenho vinculado:', empenhoError);
}
if (empenhoVinculado) {
// Remover referência da movimentação no empenho
const { error: updateEmpenhoError } = await supabase
.from('empenhos_material')
.update({
movimentacao_empenho_id: null,
status: 'Cancelado'
})
.eq('id', empenhoVinculado.id);
if (updateEmpenhoError) {
console.error('Erro ao cancelar empenho:', updateEmpenhoError);
throw new Error('Erro ao cancelar empenho vinculado');
}
}
}
// Agora excluir a movimentação
const { error: deleteError } = await supabase
.from('movimentacoes_estoque')
.delete()
.eq('id', movimentacaoId);
if (deleteError) {
console.error('Erro ao excluir movimentação:', deleteError);
throw deleteError;
}
console.log('✅ Movimentação excluída com sucesso');
return movimentacao;
},
onSuccess: (movimentacao) => {
queryClient.invalidateQueries({ queryKey: ['movimentacoes-estoque'] });
queryClient.invalidateQueries({ queryKey: ['estoque-materiais'] });
queryClient.invalidateQueries({ queryKey: ['empenhos-material'] });
const materialInfo = movimentacao.estoque_materiais;
const materialDesc = materialInfo ? `${materialInfo.codigo} - ${materialInfo.descricao}` : 'Material';
toast.success(`Movimentação de ${movimentacao.tipo_movimentacao} para ${materialDesc} excluída com sucesso!`);
},
onError: (error: any) => {
console.error('Erro ao excluir movimentação:', error);
const errorMessage = error?.message || 'Erro ao excluir movimentação';
toast.error(errorMessage);
}
});
const sortData = (data: any[], key: string) => {
return data.sort((a: any, b: any) => {
if (a[key] < b[key]) {
return sortConfig.direction === 'asc' ? -1 : 1;
}
if (a[key] > b[key]) {
return sortConfig.direction === 'asc' ? 1 : -1;
}
return 0;
});
};
const handleSort = (key: string) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const sortedMovimentacoes = React.useMemo(() => {
if (!movimentacoes) return [];
return [...movimentacoes].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortConfig.key) {
case 'material':
aValue = a.estoque_materiais?.codigo || '';
bValue = b.estoque_materiais?.codigo || '';
break;
case 'data_movimentacao':
aValue = new Date(a.data_movimentacao);
bValue = new Date(b.data_movimentacao);
break;
case 'created_at':
aValue = new Date(a.created_at);
bValue = new Date(b.created_at);
break;
default:
aValue = a[sortConfig.key as keyof typeof a];
bValue = b[sortConfig.key as keyof typeof b];
}
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [movimentacoes, sortConfig]);
const getStatusColor = (tipo: string) => {
switch (tipo) {
case 'entrada':
return 'bg-green-500';
case 'saida':
return 'bg-red-500';
case 'empenho':
return 'bg-yellow-500';
case 'desempenho':
return 'bg-blue-500';
case 'ajuste':
return 'bg-purple-500';
case 'transferencia':
return 'bg-orange-500';
default:
return 'bg-gray-500';
}
};
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="w-full h-12" />
))}
</div>
);
}
return (
<>
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Histórico de Movimentações
</CardTitle>
<Button onClick={() => setIsModalOpen(true)} className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
Nova Movimentação
</Button>
</div>
</CardHeader>
<CardContent>
{sortedMovimentacoes.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<MovimentacaoTableHeader
label="Material"
sortKey="material"
currentSort={sortConfig}
onSort={handleSort}
/>
<MovimentacaoTableHeader
label="Tipo"
sortKey="tipo_movimentacao"
currentSort={sortConfig}
onSort={handleSort}
/>
<MovimentacaoTableHeader
label="Quantidade"
sortKey="quantidade"
currentSort={sortConfig}
onSort={handleSort}
/>
<TableHead>OF Vinculada</TableHead>
<MovimentacaoTableHeader
label="Data Movim."
sortKey="data_movimentacao"
currentSort={sortConfig}
onSort={handleSort}
/>
<TableHead>Lote</TableHead>
<TableHead>Observações</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedMovimentacoes.map((movimentacao) => (
<TableRow key={movimentacao.id}>
<TableCell>
<div>
<div className="font-medium">
{movimentacao.estoque_materiais?.codigo}
</div>
<div className="text-sm text-muted-foreground">
{movimentacao.estoque_materiais?.descricao}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`${getStatusColor(movimentacao.tipo_movimentacao)} text-white border-none`}
>
{movimentacao.tipo_movimentacao}
</Badge>
</TableCell>
<TableCell className="font-medium">
{movimentacao.quantidade.toFixed(2)}
</TableCell>
<TableCell>
{movimentacao.of_vinculada || '-'}
</TableCell>
<TableCell>
{new Date(movimentacao.data_movimentacao).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>{movimentacao.lote || '-'}</TableCell>
<TableCell>
<div className="max-w-xs truncate" title={movimentacao.observacoes || ''}>
{movimentacao.observacoes || '-'}
</div>
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={deleteMovimentacao.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir Movimentação</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir esta movimentação de{' '}
<strong>{movimentacao.tipo_movimentacao}</strong> para{' '}
<strong>{movimentacao.estoque_materiais?.codigo}</strong>?
{movimentacao.tipo_movimentacao === 'empenho' && (
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
<strong>Atenção:</strong> Esta ação também cancelará o empenho vinculado e reverterá as quantidades no estoque.
</div>
)}
<br />
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMovimentacao.mutate(movimentacao.id)}
className="bg-red-500 hover:bg-red-600"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Nenhuma movimentação registrada</p>
</div>
)}
</CardContent>
</Card>
<MovimentacaoModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
};
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Plus, TrendingUp, Trash2, Package } from 'lucide-react';
import { useMovimentacoesEstoque, useExcluirMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
import { MovimentacaoModal } from './MovimentacaoModal';
import { MovimentacaoTableHeader } from './MovimentacaoTableHeader';
import { Skeleton } from '@/components/ui/skeleton';
export const MovimentacaoEstoqueSimplificada = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: 'asc' | 'desc';
}>({ key: 'created_at', direction: 'desc' });
const { data: movimentacoes = [], isLoading } = useMovimentacoesEstoque();
const excluirMovimentacao = useExcluirMovimentacao();
const handleSort = (key: string) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const sortedMovimentacoes = React.useMemo(() => {
if (!movimentacoes) return [];
return [...movimentacoes].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortConfig.key) {
case 'material':
aValue = a.estoque_materiais?.codigo || '';
bValue = b.estoque_materiais?.codigo || '';
break;
case 'data_movimentacao':
aValue = new Date(a.data_movimentacao);
bValue = new Date(b.data_movimentacao);
break;
case 'created_at':
aValue = new Date(a.created_at);
bValue = new Date(b.created_at);
break;
default:
aValue = a[sortConfig.key as keyof typeof a];
bValue = b[sortConfig.key as keyof typeof b];
}
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
}, [movimentacoes, sortConfig]);
const getStatusColor = (tipo: string) => {
switch (tipo) {
case 'entrada':
return 'bg-green-500';
case 'saida':
return 'bg-red-500';
case 'empenho':
return 'bg-yellow-500';
case 'desempenho':
return 'bg-blue-500';
case 'ajuste':
return 'bg-purple-500';
case 'transferencia':
return 'bg-orange-500';
default:
return 'bg-gray-500';
}
};
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="w-full h-12" />
))}
</div>
);
}
return (
<>
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Histórico de Movimentações
</CardTitle>
<Button onClick={() => setIsModalOpen(true)} className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
Nova Movimentação
</Button>
</div>
</CardHeader>
<CardContent>
{sortedMovimentacoes.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<MovimentacaoTableHeader
label="Material"
sortKey="material"
currentSort={sortConfig}
onSort={handleSort}
/>
<MovimentacaoTableHeader
label="Tipo"
sortKey="tipo_movimentacao"
currentSort={sortConfig}
onSort={handleSort}
/>
<MovimentacaoTableHeader
label="Quantidade"
sortKey="quantidade"
currentSort={sortConfig}
onSort={handleSort}
/>
<TableHead>OF Vinculada</TableHead>
<MovimentacaoTableHeader
label="Data Movim."
sortKey="data_movimentacao"
currentSort={sortConfig}
onSort={handleSort}
/>
<TableHead>Lote</TableHead>
<TableHead>Observações</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedMovimentacoes.map((movimentacao) => (
<TableRow key={movimentacao.id}>
<TableCell>
<div>
<div className="font-medium">
{movimentacao.estoque_materiais?.codigo}
</div>
<div className="text-sm text-muted-foreground">
{movimentacao.estoque_materiais?.descricao}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`${getStatusColor(movimentacao.tipo_movimentacao)} text-white border-none`}
>
{movimentacao.tipo_movimentacao}
</Badge>
</TableCell>
<TableCell className="font-medium">
{movimentacao.quantidade.toFixed(2)}
</TableCell>
<TableCell>
{movimentacao.of_vinculada || '-'}
</TableCell>
<TableCell>
{new Date(movimentacao.data_movimentacao).toLocaleDateString('pt-BR')}
</TableCell>
<TableCell>{movimentacao.lote || '-'}</TableCell>
<TableCell>
<div className="max-w-xs truncate" title={movimentacao.observacoes || ''}>
{movimentacao.observacoes || '-'}
</div>
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
disabled={excluirMovimentacao.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Excluir Movimentação</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir esta movimentação de{' '}
<strong>{movimentacao.tipo_movimentacao}</strong> para{' '}
<strong>{movimentacao.estoque_materiais?.codigo}</strong>?
{movimentacao.tipo_movimentacao === 'empenho' && (
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
<strong>Atenção:</strong> Esta ação também cancelará o empenho vinculado e reverterá as quantidades no estoque.
</div>
)}
<br />
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => excluirMovimentacao.mutate(movimentacao.id)}
className="bg-red-500 hover:bg-red-600"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-16 w-16 mx-auto mb-4 opacity-50" />
<p>Nenhuma movimentação registrada</p>
</div>
)}
</CardContent>
</Card>
<MovimentacaoModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
};
@@ -0,0 +1,356 @@
import React, { useState, useEffect } 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 { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useEstoqueMateriais } from '@/hooks/useEstoque';
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
import { useOFs } from '@/hooks/useOFs';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
import { useEmpenhosAtivosPorMaterial } from '@/hooks/useEmpenhosMaterial';
interface MovimentacaoModalProps {
isOpen: boolean;
onClose: () => void;
}
const localizacoes = ['Galpão A', 'Galpão B', 'Pátio', 'Estoque Externo', 'Almoxarifado'];
export const MovimentacaoModal: React.FC<MovimentacaoModalProps> = ({
isOpen,
onClose
}) => {
const [formData, setFormData] = useState({
material_id: '',
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
quantidade: 0,
lote: '',
fornecedor: '',
nota_fiscal: '',
of_vinculada: '',
observacoes: '',
nova_localizacao: '',
data_movimentacao: new Date().toISOString().split('T')[0]
});
const [quantidadeMaximaDisponivel, setQuantidadeMaximaDisponivel] = useState(0);
const [empenhosAtivos, setEmpenhosAtivos] = useState<any[]>([]);
const { data: materiais } = useEstoqueMateriais();
const { data: lotes } = useRastreabilidadeMateriais();
const { data: ofs } = useOFs();
const { user } = useAuth();
const criarMovimentacao = useCriarMovimentacao();
// Buscar empenhos ativos quando material for selecionado e tipo for desempenho
useEffect(() => {
const fetchEmpenhosAtivos = async () => {
if (formData.material_id && formData.tipo_movimentacao === 'desempenho') {
const { data, error } = await supabase
.from('empenhos_material')
.select('*')
.eq('material_id', formData.material_id)
.eq('status', 'Empenhado');
if (!error && data) {
setEmpenhosAtivos(data);
}
} else {
setEmpenhosAtivos([]);
}
};
fetchEmpenhosAtivos();
}, [formData.material_id, formData.tipo_movimentacao]);
// Buscar informações do material e definir quantidade máxima
useEffect(() => {
if (formData.material_id && materiais) {
const materialSelecionado = materiais.find(m => m.id === formData.material_id);
if (materialSelecionado) {
// Definir quantidade máxima baseada no tipo de movimentação
if (formData.tipo_movimentacao === 'saida' || formData.tipo_movimentacao === 'empenho') {
setQuantidadeMaximaDisponivel(materialSelecionado.quantidade_disponivel || 0);
} else if (formData.tipo_movimentacao === 'desempenho') {
// Para desempenho, limitar pela quantidade empenhada disponível
const totalEmpenhado = empenhosAtivos.reduce((sum, emp) =>
sum + ((emp.quantidade_empenhada || 0) - (emp.quantidade_utilizada || 0)), 0);
setQuantidadeMaximaDisponivel(totalEmpenhado);
} else {
setQuantidadeMaximaDisponivel(99999); // Sem limite para entrada, ajuste, transferência
}
// Preencher lote automaticamente se disponível
if (materialSelecionado.lote_atual) {
setFormData(prev => ({
...prev,
lote: materialSelecionado.lote_atual || ''
}));
// Buscar informações do lote
if (lotes) {
const loteVinculado = lotes.find(l => l.lote === materialSelecionado.lote_atual);
if (loteVinculado) {
setFormData(prev => ({
...prev,
lote: loteVinculado.lote,
fornecedor: loteVinculado.fornecedor || '',
nota_fiscal: loteVinculado.nota_fiscal || ''
}));
}
}
}
}
}
}, [formData.material_id, formData.tipo_movimentacao, materiais, lotes, empenhosAtivos]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validações básicas
if (!formData.material_id) {
toast.error('Material é obrigatório');
return;
}
if (formData.quantidade <= 0) {
toast.error('Quantidade deve ser maior que zero');
return;
}
// Validação para empenho e desempenho
if (formData.tipo_movimentacao === 'empenho' && !formData.of_vinculada) {
toast.error('OF vinculada é obrigatória para movimentações de empenho');
return;
}
if (formData.tipo_movimentacao === 'desempenho' && !formData.of_vinculada) {
toast.error('OF vinculada é obrigatória para movimentações de desempenho');
return;
}
try {
const currentDate = new Date().toLocaleDateString('pt-BR');
const userName = user?.user_metadata?.full_name || user?.email || 'Usuário não identificado';
const observacoesComplementadas = `Movimentação realizada em ${currentDate} por ${userName}${formData.observacoes ? ' - ' + formData.observacoes : ''}`;
const dadosMovimentacao = {
material_id: formData.material_id,
tipo_movimentacao: formData.tipo_movimentacao,
quantidade: formData.quantidade,
lote: formData.lote,
fornecedor: formData.fornecedor,
nota_fiscal: formData.nota_fiscal,
of_vinculada: formData.of_vinculada,
observacoes: observacoesComplementadas,
data_movimentacao: formData.data_movimentacao,
user_name: userName
};
await criarMovimentacao.mutateAsync(dadosMovimentacao);
// Reset form
setFormData({
material_id: '',
tipo_movimentacao: 'entrada',
quantidade: 0,
lote: '',
fornecedor: '',
nota_fiscal: '',
of_vinculada: '',
observacoes: '',
nova_localizacao: '',
data_movimentacao: new Date().toISOString().split('T')[0]
});
toast.success('Movimentação criada com sucesso!');
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
toast.error(`Erro ao criar movimentação: ${errorMessage}`);
}
};
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Filtrar materiais que possuem lote atual
const materiaisComLote = materiais?.filter(material => material.lote_atual) || [];
const materialSelecionado = materiais?.find(m => m.id === formData.material_id);
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
const isTransferencia = formData.tipo_movimentacao === 'transferencia';
const showLoteInfo = formData.lote || materialSelecionado?.lote_atual;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Nova Movimentação de Estoque</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="material_id">Material (com Lote) *</Label>
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione o material" />
</SelectTrigger>
<SelectContent>
{materiaisComLote?.map((material) => (
<SelectItem key={material.id} value={material.id}>
{material.descricao} (Lote: {material.lote_atual})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="tipo_movimentacao">Tipo de Movimentação *</Label>
<Select value={formData.tipo_movimentacao} onValueChange={(value) => handleInputChange('tipo_movimentacao', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="entrada">Entrada</SelectItem>
<SelectItem value="saida">Saída</SelectItem>
<SelectItem value="transferencia">Transferência</SelectItem>
<SelectItem value="ajuste">Ajuste</SelectItem>
<SelectItem value="empenho">Empenho</SelectItem>
<SelectItem value="desempenho">Desempenho</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="quantidade">
Quantidade *
{quantidadeMaximaDisponivel > 0 && (
<span className="text-sm text-muted-foreground ml-2">
(Máx: {quantidadeMaximaDisponivel})
</span>
)}
</Label>
<Input
id="quantidade"
type="number"
step="0.01"
max={quantidadeMaximaDisponivel > 0 ? quantidadeMaximaDisponivel : undefined}
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', parseFloat(e.target.value) || 0)}
required
/>
</div>
<div>
<Label htmlFor="data_movimentacao">Data da Movimentação</Label>
<Input
id="data_movimentacao"
type="date"
value={formData.data_movimentacao}
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
/>
</div>
</div>
{showLoteInfo && (
<div className="bg-muted p-4 rounded-lg space-y-2">
<h4 className="font-medium">Informações do Lote</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="font-medium">Lote:</span> {formData.lote}
</div>
<div>
<span className="font-medium">Fornecedor:</span> {formData.fornecedor || '-'}
</div>
<div>
<span className="font-medium">NF:</span> {formData.nota_fiscal || '-'}
</div>
</div>
</div>
)}
{requiresOF && (
<div>
<Label htmlFor="of_vinculada">OF Vinculada *</Label>
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{ofs?.map((of) => (
<SelectItem key={of.id} value={of.num_of}>
{of.num_of} - {of.descritivo}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.tipo_movimentacao === 'desempenho' && empenhosAtivos.length === 0 && formData.material_id && (
<p className="text-sm text-yellow-600 mt-1">
Este material não possui empenhos ativos. Verifique se empenho para a OF selecionada.
</p>
)}
</div>
)}
{isTransferencia && (
<div>
<Label htmlFor="nova_localizacao">Nova Localização</Label>
<Select value={formData.nova_localizacao} onValueChange={(value) => handleInputChange('nova_localizacao', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a nova localização" />
</SelectTrigger>
<SelectContent>
{localizacoes.map((localizacao) => (
<SelectItem key={localizacao} value={localizacao}>
{localizacao}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="observacoes">Observações Adicionais</Label>
<Textarea
id="observacoes"
value={formData.observacoes}
onChange={(e) => handleInputChange('observacoes', e.target.value)}
rows={3}
placeholder="Observações adicionais (data e usuário serão adicionados automaticamente)"
/>
<p className="text-xs text-muted-foreground mt-1">
A data e o usuário da movimentação serão adicionados automaticamente às observações.
</p>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button
type="submit"
disabled={criarMovimentacao.isPending}
className="bg-primary hover:bg-primary/90"
>
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,350 @@
import React, { useState, useEffect } 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 { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useEstoqueMateriais } from '@/hooks/useEstoqueSimplificado';
import { useCriarMovimentacao } from '@/hooks/useEstoqueMovimentacoes';
import { useRastreabilidadeMateriais } from '@/hooks/useRastreabilidadeMateriais';
import { useOFs } from '@/hooks/useOFs';
import { useAuth } from '@/hooks/useAuth';
import { EstoqueMaterial } from '@/hooks/useEstoque';
interface MovimentacaoModalProps {
isOpen: boolean;
onClose: () => void;
selectedMaterials?: EstoqueMaterial[];
}
const localizacoes = ['Galpão A', 'Galpão B', 'Pátio', 'Estoque Externo', 'Almoxarifado'];
export const MovimentacaoModalSimplificada: React.FC<MovimentacaoModalProps> = ({
isOpen,
onClose,
selectedMaterials = []
}) => {
const [formData, setFormData] = useState({
material_id: '',
tipo_movimentacao: 'entrada' as 'entrada' | 'saida' | 'transferencia' | 'ajuste' | 'empenho' | 'desempenho',
quantidade: 0,
lote: '',
fornecedor: '',
nota_fiscal: '',
of_vinculada: '',
observacoes: '',
nova_localizacao: '',
data_movimentacao: new Date().toISOString().split('T')[0]
});
const [quantidadeMaximaDisponivel, setQuantidadeMaximaDisponivel] = useState(0);
const { data: materiais } = useEstoqueMateriais();
const { data: lotes } = useRastreabilidadeMateriais();
const { data: ofs } = useOFs();
const { user } = useAuth();
const criarMovimentacao = useCriarMovimentacao();
// Se há materiais selecionados, mostrar título diferente e permitir movimentação em lote
const isBatchMode = selectedMaterials.length > 0;
// Buscar informações do material e definir quantidade máxima
useEffect(() => {
if (formData.material_id && materiais) {
const materialSelecionado = materiais.find(m => m.id === formData.material_id);
if (materialSelecionado) {
// Definir quantidade máxima baseada no tipo de movimentação
if (formData.tipo_movimentacao === 'saida' || formData.tipo_movimentacao === 'empenho') {
setQuantidadeMaximaDisponivel(materialSelecionado.quantidade_disponivel || 0);
} else {
setQuantidadeMaximaDisponivel(99999); // Sem limite para entrada, ajuste, transferência
}
// Preencher lote automaticamente se disponível
if (materialSelecionado.lote_atual) {
setFormData(prev => ({
...prev,
lote: materialSelecionado.lote_atual || ''
}));
// Buscar informações do lote
if (lotes) {
const loteVinculado = lotes.find(l => l.lote === materialSelecionado.lote_atual);
if (loteVinculado) {
setFormData(prev => ({
...prev,
lote: loteVinculado.lote,
fornecedor: loteVinculado.fornecedor || '',
nota_fiscal: loteVinculado.nota_fiscal || ''
}));
}
}
}
}
}
}, [formData.material_id, formData.tipo_movimentacao, materiais, lotes]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validações específicas
if ((formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho') && !formData.of_vinculada) {
return;
}
if (formData.quantidade <= 0) {
return;
}
if (formData.quantidade > quantidadeMaximaDisponivel && quantidadeMaximaDisponivel > 0) {
return;
}
try {
const currentDate = new Date().toLocaleDateString('pt-BR');
const userName = user?.user_metadata?.full_name || user?.email || 'Usuário não identificado';
const observacoesComplementadas = `Movimentação realizada em ${currentDate} por ${userName}${formData.observacoes ? ' - ' + formData.observacoes : ''}`;
if (isBatchMode) {
// Movimentação em lote para materiais selecionados
for (const material of selectedMaterials) {
await criarMovimentacao.mutateAsync({
material_id: material.id,
tipo_movimentacao: formData.tipo_movimentacao,
quantidade: formData.quantidade,
lote: material.lote_atual || '',
fornecedor: formData.fornecedor,
nota_fiscal: formData.nota_fiscal,
of_vinculada: formData.of_vinculada,
observacoes: observacoesComplementadas,
data_movimentacao: formData.data_movimentacao,
user_name: userName
});
}
} else {
// Movimentação individual
await criarMovimentacao.mutateAsync({
material_id: formData.material_id,
tipo_movimentacao: formData.tipo_movimentacao,
quantidade: formData.quantidade,
lote: formData.lote,
fornecedor: formData.fornecedor,
nota_fiscal: formData.nota_fiscal,
of_vinculada: formData.of_vinculada,
observacoes: observacoesComplementadas,
data_movimentacao: formData.data_movimentacao,
user_name: userName
});
}
// Reset form
setFormData({
material_id: '',
tipo_movimentacao: 'entrada',
quantidade: 0,
lote: '',
fornecedor: '',
nota_fiscal: '',
of_vinculada: '',
observacoes: '',
nova_localizacao: '',
data_movimentacao: new Date().toISOString().split('T')[0]
});
onClose();
} catch (error) {
console.error('Erro ao criar movimentação:', error);
}
};
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Filtrar materiais que possuem lote atual
const materiaisComLote = materiais?.filter(material => material.lote_atual) || [];
const materialSelecionado = materiais?.find(m => m.id === formData.material_id);
const requiresOF = formData.tipo_movimentacao === 'empenho' || formData.tipo_movimentacao === 'desempenho';
const isTransferencia = formData.tipo_movimentacao === 'transferencia';
const showLoteInfo = formData.lote || materialSelecionado?.lote_atual;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isBatchMode
? `Nova Movimentação em Lote (${selectedMaterials.length} materiais)`
: 'Nova Movimentação de Estoque'
}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isBatchMode ? (
<div className="bg-muted p-4 rounded-lg">
<h4 className="font-medium mb-2">Materiais Selecionados:</h4>
<div className="text-sm space-y-1">
{selectedMaterials.slice(0, 3).map((material) => (
<div key={material.id}>
{material.descricao} - {material.lote_atual || 'Sem lote'}
</div>
))}
{selectedMaterials.length > 3 && (
<div>... e mais {selectedMaterials.length - 3} materiais</div>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="material_id">Material (com Lote) *</Label>
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione o material" />
</SelectTrigger>
<SelectContent>
{materiaisComLote?.map((material) => (
<SelectItem key={material.id} value={material.id}>
{material.descricao} (Lote: {material.lote_atual})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="tipo_movimentacao">Tipo de Movimentação *</Label>
<Select value={formData.tipo_movimentacao} onValueChange={(value) => handleInputChange('tipo_movimentacao', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="entrada">Entrada</SelectItem>
<SelectItem value="saida">Saída</SelectItem>
<SelectItem value="transferencia">Transferência</SelectItem>
<SelectItem value="ajuste">Ajuste</SelectItem>
<SelectItem value="empenho">Empenho</SelectItem>
<SelectItem value="desempenho">Desempenho</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="quantidade">
Quantidade *
{quantidadeMaximaDisponivel > 0 && !isBatchMode && (
<span className="text-sm text-muted-foreground ml-2">
(Máx: {quantidadeMaximaDisponivel})
</span>
)}
</Label>
<Input
id="quantidade"
type="number"
step="0.01"
max={quantidadeMaximaDisponivel > 0 && !isBatchMode ? quantidadeMaximaDisponivel : undefined}
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', parseFloat(e.target.value) || 0)}
required
/>
</div>
</div>
<div>
<Label htmlFor="data_movimentacao">Data da Movimentação</Label>
<Input
id="data_movimentacao"
type="date"
value={formData.data_movimentacao}
onChange={(e) => handleInputChange('data_movimentacao', e.target.value)}
/>
</div>
{!isBatchMode && showLoteInfo && (
<div className="bg-muted p-4 rounded-lg space-y-2">
<h4 className="font-medium">Informações do Lote</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="font-medium">Lote:</span> {formData.lote}
</div>
<div>
<span className="font-medium">Fornecedor:</span> {formData.fornecedor || '-'}
</div>
<div>
<span className="font-medium">NF:</span> {formData.nota_fiscal || '-'}
</div>
</div>
</div>
)}
{requiresOF && (
<div>
<Label htmlFor="of_vinculada">OF Vinculada *</Label>
<Select value={formData.of_vinculada} onValueChange={(value) => handleInputChange('of_vinculada', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{ofs?.map((of) => (
<SelectItem key={of.id} value={of.num_of}>
{of.num_of} - {of.descritivo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isTransferencia && (
<div>
<Label htmlFor="nova_localizacao">Nova Localização</Label>
<Select value={formData.nova_localizacao} onValueChange={(value) => handleInputChange('nova_localizacao', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a nova localização" />
</SelectTrigger>
<SelectContent>
{localizacoes.map((localizacao) => (
<SelectItem key={localizacao} value={localizacao}>
{localizacao}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Label htmlFor="observacoes">Observações Adicionais</Label>
<Textarea
id="observacoes"
value={formData.observacoes}
onChange={(e) => handleInputChange('observacoes', e.target.value)}
rows={3}
placeholder="Observações adicionais (data e usuário serão adicionados automaticamente)"
/>
<p className="text-xs text-muted-foreground mt-1">
A data e o usuário da movimentação serão adicionados automaticamente às observações.
</p>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={criarMovimentacao.isPending}>
{criarMovimentacao.isPending ? 'Criando...' : 'Criar Movimentação'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,42 @@
import React from 'react';
import { TableHead } from '@/components/ui/table';
import { ChevronUp, ChevronDown } from 'lucide-react';
interface MovimentacaoTableHeaderProps {
label: string;
sortKey: string;
currentSort: {
key: string;
direction: 'asc' | 'desc';
};
onSort: (key: string) => void;
}
export function MovimentacaoTableHeader({
label,
sortKey,
currentSort,
onSort
}: MovimentacaoTableHeaderProps) {
const getSortIcon = () => {
if (currentSort.key !== sortKey) {
return <ChevronUp className="h-3 w-3 opacity-30" />;
}
return currentSort.direction === 'asc' ?
<ChevronUp className="h-3 w-3" /> :
<ChevronDown className="h-3 w-3" />;
};
return (
<TableHead
className="cursor-pointer hover:bg-muted/50 select-none text-xs py-1 px-2"
onClick={() => onSort(sortKey)}
>
<div className="flex items-center gap-1">
{label}
{getSortIcon()}
</div>
</TableHead>
);
}
@@ -0,0 +1,175 @@
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 { Plus, Edit, Trash2 } from 'lucide-react';
import { useQualidadesAco, useCriarQualidadeAco, useAtualizarQualidadeAco, useExcluirQualidadeAco } from '@/hooks/useEstoqueCRUD';
import { toast } from 'sonner';
interface QualidadeAcoModalProps {
isOpen: boolean;
onClose: () => void;
}
export const QualidadeAcoModal: React.FC<QualidadeAcoModalProps> = ({ isOpen, onClose }) => {
const { data: qualidades = [], isLoading } = useQualidadesAco();
const criarQualidade = useCriarQualidadeAco();
const atualizarQualidade = useAtualizarQualidadeAco();
const excluirQualidade = useExcluirQualidadeAco();
const [novaQualidade, setNovaQualidade] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const handleAdd = async () => {
if (novaQualidade.trim() && !qualidades.some(qual => qual.nome === novaQualidade.trim())) {
try {
await criarQualidade.mutateAsync({
nome: novaQualidade.trim(),
descricao: `Qualidade ${novaQualidade.trim()}`,
ativo: true
});
setNovaQualidade('');
toast.success('Qualidade criada com sucesso!');
} catch (error) {
toast.error('Erro ao criar qualidade');
}
}
};
const handleEdit = (qualidade: any) => {
setEditingId(qualidade.id);
setEditValue(qualidade.nome);
};
const handleSaveEdit = async () => {
if (editValue.trim() && editingId && !qualidades.some(qual => qual.nome === editValue.trim() && qual.id !== editingId)) {
try {
const qualidade = qualidades.find(qual => qual.id === editingId);
if (qualidade) {
await atualizarQualidade.mutateAsync({
...qualidade,
nome: editValue.trim(),
descricao: `Qualidade ${editValue.trim()}`
});
setEditingId(null);
setEditValue('');
toast.success('Qualidade atualizada com sucesso!');
}
} catch (error) {
toast.error('Erro ao atualizar qualidade');
}
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditValue('');
};
const handleDelete = async (id: string) => {
if (window.confirm('Tem certeza que deseja excluir esta qualidade?')) {
try {
await excluirQualidade.mutateAsync(id);
toast.success('Qualidade excluída com sucesso!');
} catch (error) {
toast.error('Erro ao excluir qualidade');
}
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Qualidades do Material</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="nova-qualidade">Nova Qualidade</Label>
<Input
id="nova-qualidade"
value={novaQualidade}
onChange={(e) => setNovaQualidade(e.target.value)}
placeholder="Ex: A572G50"
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div className="flex items-end gap-2">
<Button onClick={handleAdd} disabled={!novaQualidade.trim()}>
<Plus className="w-4 h-4 mr-2" />
Adicionar
</Button>
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium">Qualidades Cadastradas ({qualidades.length})</h3>
<Table>
<TableHeader>
<TableRow className="h-8">
<TableHead className="py-2">Qualidade</TableHead>
<TableHead className="w-24 py-2">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{qualidades.map((qualidade) => (
<TableRow key={qualidade.id} className="h-8">
<TableCell className="py-1">
{editingId === qualidade.id ? (
<div className="flex gap-2">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit()}
className="h-7"
/>
<Button size="sm" onClick={handleSaveEdit} className="h-7">
Salvar
</Button>
<Button size="sm" variant="outline" onClick={handleCancelEdit} className="h-7">
Cancelar
</Button>
</div>
) : (
<span className="font-medium">{qualidade.nome}</span>
)}
</TableCell>
<TableCell className="py-1">
{editingId !== qualidade.id && (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(qualidade)}
className="h-6 w-6 p-0"
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(qualidade.id)}
className="h-6 w-6 p-0 text-red-500"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,21 @@
export const QUALIDADES_ACO = [
'ASTM A36',
'ASTM A572 Gr 50',
'ASTM A588',
'ASTM A992',
'SAE 1020',
'SAE 1045',
'AISI 304',
'AISI 316',
'AISI 430',
'ABNT NBR 7007',
'ABNT NBR 8800',
'DIN 17100 St37',
'DIN 17100 St52',
'EN 10025 S235',
'EN 10025 S275',
'EN 10025 S355'
];
export const getQualidadeAcoOptions = () => QUALIDADES_ACO;
@@ -0,0 +1,268 @@
import React, { useState, useEffect } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search } from 'lucide-react';
import { useEstoqueMateriais, useCriarRastreabilidade, useAtualizarRastreabilidade } from '@/hooks/useRastreabilidadeMateriais';
interface RastreabilidadeLoteModalProps {
isOpen: boolean;
onClose: () => void;
lote?: any | null;
}
export const RastreabilidadeLoteModal: React.FC<RastreabilidadeLoteModalProps> = ({
isOpen,
onClose,
lote
}) => {
const [formData, setFormData] = useState({
lote: '',
material_id: '',
quantidade: '',
data_entrada: '',
fornecedor: '',
certificado: '',
corrida: '',
data_validade: '',
nota_fiscal: '',
status: 'Ativo'
});
const [materialFilter, setMaterialFilter] = useState('');
const { data: materiais } = useEstoqueMateriais();
const criarRastreabilidade = useCriarRastreabilidade();
const atualizarRastreabilidade = useAtualizarRastreabilidade();
// Filtrar materiais baseado na busca
const materiaisFiltrados = materiais?.filter(material =>
material.descricao.toLowerCase().includes(materialFilter.toLowerCase()) ||
material.codigo.toLowerCase().includes(materialFilter.toLowerCase())
) || [];
useEffect(() => {
if (lote) {
setFormData({
lote: lote.lote || '',
material_id: lote.material_id || '',
quantidade: lote.quantidade?.toString() || '',
data_entrada: lote.data_entrada || '',
fornecedor: lote.fornecedor || '',
certificado: lote.certificado || '',
corrida: lote.corrida || '',
data_validade: lote.data_validade || '',
nota_fiscal: lote.nota_fiscal || '',
status: lote.status || 'Ativo'
});
} else {
// Resetar formulário para novo lote
const hoje = new Date();
const dataFormatada = hoje.toISOString().split('T')[0];
setFormData({
lote: '',
material_id: '',
quantidade: '',
data_entrada: dataFormatada,
fornecedor: '',
certificado: '',
corrida: '',
data_validade: '',
nota_fiscal: '',
status: 'Ativo'
});
}
// Limpar filtro ao abrir/fechar modal
setMaterialFilter('');
}, [lote, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.material_id || !formData.quantidade || !formData.nota_fiscal) {
alert('Por favor, preencha os campos obrigatórios: Material, Quantidade e Nota Fiscal.');
return;
}
try {
const dataToSubmit = {
...formData,
quantidade: parseFloat(formData.quantidade),
lote: formData.lote.trim() || null,
data_validade: formData.data_validade.trim() || null
};
if (lote) {
await atualizarRastreabilidade.mutateAsync({ id: lote.id, ...dataToSubmit });
} else {
await criarRastreabilidade.mutateAsync(dataToSubmit);
}
onClose();
} catch (error) {
console.error('Erro ao salvar rastreabilidade:', error);
}
};
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{lote ? 'Editar Lote' : 'Novo Lote'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="lote">Lote</Label>
<Input
id="lote"
value={formData.lote}
onChange={(e) => handleInputChange('lote', e.target.value)}
placeholder="Deixe vazio para gerar automaticamente"
/>
</div>
<div>
<Label htmlFor="material_id">Material *</Label>
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="filtro: Digite para buscar material..."
value={materialFilter}
onChange={(e) => setMaterialFilter(e.target.value)}
className="pl-8"
/>
</div>
<Select value={formData.material_id} onValueChange={(value) => handleInputChange('material_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione o material" />
</SelectTrigger>
<SelectContent>
{materiaisFiltrados?.map((material) => (
<SelectItem key={material.id} value={material.id}>
{material.descricao} ({material.comprimento ? `${material.comprimento}mm` : 'S/ compr.'})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="quantidade">Quantidade *</Label>
<Input
id="quantidade"
type="number"
step="0.01"
value={formData.quantidade}
onChange={(e) => handleInputChange('quantidade', e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="nota_fiscal">Nota Fiscal *</Label>
<Input
id="nota_fiscal"
value={formData.nota_fiscal}
onChange={(e) => handleInputChange('nota_fiscal', e.target.value)}
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="data_entrada">Data de Entrada</Label>
<Input
id="data_entrada"
type="date"
value={formData.data_entrada}
onChange={(e) => handleInputChange('data_entrada', e.target.value)}
/>
</div>
<div>
<Label htmlFor="fornecedor">Fornecedor</Label>
<Input
id="fornecedor"
value={formData.fornecedor}
onChange={(e) => handleInputChange('fornecedor', e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="certificado">Certificado</Label>
<Input
id="certificado"
value={formData.certificado}
onChange={(e) => handleInputChange('certificado', e.target.value)}
/>
</div>
<div>
<Label htmlFor="corrida">Corrida</Label>
<Input
id="corrida"
value={formData.corrida}
onChange={(e) => handleInputChange('corrida', e.target.value)}
/>
</div>
</div>
<div>
<Label htmlFor="data_validade">Data de Validade (Opcional)</Label>
<Input
id="data_validade"
type="date"
value={formData.data_validade}
onChange={(e) => handleInputChange('data_validade', e.target.value)}
placeholder="Campo opcional - pode ficar vazio"
/>
<p className="text-xs text-muted-foreground mt-1">
Este campo é opcional e pode ser deixado vazio
</p>
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select value={formData.status} onValueChange={(value) => handleInputChange('status', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Ativo">Ativo</SelectItem>
<SelectItem value="Inativo">Inativo</SelectItem>
<SelectItem value="Vencido">Vencido</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={criarRastreabilidade.isPending || atualizarRastreabilidade.isPending}>
{criarRastreabilidade.isPending || atualizarRastreabilidade.isPending ? 'Salvando...' : 'Salvar Lote'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Search, Plus, Edit, Trash2 } from 'lucide-react';
import { useRastreabilidadeMateriais, useExcluirRastreabilidade } from '@/hooks/useRastreabilidadeMateriais';
import { RastreabilidadeLoteModal } from './RastreabilidadeLoteModal';
export const RastreabilidadeMP: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedLote, setSelectedLote] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const { data: lotes, isLoading } = useRastreabilidadeMateriais();
const excluirRastreabilidade = useExcluirRastreabilidade();
const filteredLotes = lotes?.filter(lote =>
lote.lote.toLowerCase().includes(searchTerm.toLowerCase()) ||
(lote.fornecedor && lote.fornecedor.toLowerCase().includes(searchTerm.toLowerCase())) ||
(lote.certificado && lote.certificado.toLowerCase().includes(searchTerm.toLowerCase()))
) || [];
const handleOpenModal = () => {
setSelectedLote(null);
setIsModalOpen(true);
};
const handleEditLote = (lote: any) => {
setSelectedLote(lote);
setIsModalOpen(true);
};
const handleDeleteLote = async (lote: any) => {
if (window.confirm(`Tem certeza que deseja excluir o lote ${lote.lote}?`)) {
try {
await excluirRastreabilidade.mutateAsync(lote.id);
} catch (error) {
console.error('Erro ao excluir lote:', error);
}
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR');
};
if (isLoading) {
return (
<Card className="bg-card border-border">
<CardContent className="p-4 sm:p-6">
<div className="flex items-center justify-center h-32">
<div className="text-muted-foreground text-sm">Carregando...</div>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="bg-card border-border">
<CardHeader className="pb-3 sm:pb-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<CardTitle className="text-card-foreground text-sm sm:text-lg lg:text-xl">
Rastreabilidade do MP
</CardTitle>
<Button onClick={handleOpenModal} className="bg-primary hover:bg-primary/90 text-xs sm:text-sm px-3 py-2" size="sm">
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
Novo Lote
</Button>
</div>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<div className="space-y-3 mb-4">
<div className="relative max-w-md">
<Search className="absolute left-2 sm:left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-3 h-3 sm:w-4 sm:h-4" />
<Input
placeholder="Buscar por lote, fornecedor ou certificado..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8 sm:pl-10 text-xs sm:text-sm h-8 sm:h-10"
/>
</div>
</div>
<div className="overflow-x-auto -mx-2 sm:mx-0">
<Table>
<TableHeader>
<TableRow className="h-8">
<TableHead className="text-xs py-1 px-2">Lote</TableHead>
<TableHead className="text-xs py-1 px-2">Material</TableHead>
<TableHead className="text-xs py-1 px-2">Quantidade</TableHead>
<TableHead className="text-xs py-1 px-2">Fornecedor</TableHead>
<TableHead className="text-xs py-1 px-2">Nota Fiscal</TableHead>
<TableHead className="text-xs py-1 px-2">Certificado</TableHead>
<TableHead className="text-xs py-1 px-2">Corrida</TableHead>
<TableHead className="text-xs py-1 px-2">Data Entrada</TableHead>
<TableHead className="text-xs py-1 px-2">Data Validade</TableHead>
<TableHead className="text-xs py-1 px-2">Status</TableHead>
<TableHead className="text-xs py-1 px-2 w-20">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLotes.map((lote) => (
<TableRow key={lote.id} className="h-6">
<TableCell className="text-xs py-1 px-2 font-medium">{lote.lote}</TableCell>
<TableCell className="text-xs py-1 px-2">
{lote.estoque_materiais?.descricao || '-'}
</TableCell>
<TableCell className="text-xs py-1 px-2">{lote.quantidade}</TableCell>
<TableCell className="text-xs py-1 px-2">{lote.fornecedor || '-'}</TableCell>
<TableCell className="text-xs py-1 px-2">{lote.nota_fiscal || '-'}</TableCell>
<TableCell className="text-xs py-1 px-2">{lote.certificado || '-'}</TableCell>
<TableCell className="text-xs py-1 px-2">{lote.corrida || '-'}</TableCell>
<TableCell className="text-xs py-1 px-2">
{lote.data_entrada ? formatDate(lote.data_entrada) : '-'}
</TableCell>
<TableCell className="text-xs py-1 px-2">
{lote.data_validade ? formatDate(lote.data_validade) : '-'}
</TableCell>
<TableCell className="text-xs py-1 px-2">
<span className={`px-2 py-1 rounded text-xs ${
lote.status === 'Ativo' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{lote.status}
</span>
</TableCell>
<TableCell className="py-1 px-2">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditLote(lote)}
className="h-6 w-6 p-1"
title="Editar lote"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteLote(lote)}
className="h-6 w-6 p-1 text-red-600 hover:text-red-700 hover:bg-red-50"
title="Excluir lote"
disabled={excluirRastreabilidade.isPending}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{filteredLotes.length === 0 && (
<div className="text-center py-6 sm:py-8 text-muted-foreground text-sm">
Nenhum lote encontrado
</div>
)}
</CardContent>
</Card>
<RastreabilidadeLoteModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
lote={selectedLote}
/>
</>
);
};
@@ -0,0 +1,70 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useTiposMateriaPrima } from '@/hooks/useEstoque';
interface TiposFiltroButtonsProps {
tipoSelecionado: string;
onTipoChange: (tipo: string) => void;
}
export const TiposFiltroButtons: React.FC<TiposFiltroButtonsProps> = ({
tipoSelecionado,
onTipoChange
}) => {
const { data: tipos, isLoading } = useTiposMateriaPrima();
if (isLoading) {
return (
<div className="flex flex-wrap gap-2 mb-4 p-3 bg-muted/30 rounded-lg border">
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(18%_-_0.5rem)] min-w-[120px]"></div>
</div>
);
}
// Criar uma estrutura unificada para todos os tipos
const allTiposItem = { id: 'all', nome: 'Todos os Tipos', isSpecial: true };
const tipoItems = tipos?.map(tipo => ({ ...tipo, isSpecial: false })) || [];
const allItems = [allTiposItem, ...tipoItems];
// Dividir os tipos em duas linhas
const firstLine = allItems.slice(0, Math.ceil(allItems.length / 2));
const secondLine = allItems.slice(Math.ceil(allItems.length / 2));
return (
<div className="mb-4 p-3 bg-muted/30 rounded-lg border space-y-2">
<div className="flex flex-wrap gap-2">
{firstLine.map((item) => (
<Button
key={item.id}
variant={tipoSelecionado === (item.isSpecial ? 'all' : item.nome) ? 'default' : 'outline'}
size="sm"
onClick={() => onTipoChange(item.isSpecial ? 'all' : item.nome)}
className="h-8 text-xs flex-[0_0_calc(18%_-_0.5rem)] min-w-[180px] max-w-none"
title={item.nome}
>
{item.nome}
</Button>
))}
</div>
{secondLine.length > 0 && (
<div className="flex flex-wrap gap-2">
{secondLine.map((item) => (
<Button
key={item.id}
variant={tipoSelecionado === item.nome ? 'default' : 'outline'}
size="sm"
onClick={() => onTipoChange(item.nome)}
className="h-8 text-xs flex-[0_0_calc(18%_-_0.5rem)] min-w-[180px] max-w-none"
title={item.nome}
>
{item.nome}
</Button>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,98 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { useTiposMateriaPrima } from '@/hooks/useEstoque';
interface TiposFiltroButtonsOtimizadoProps {
tipoSelecionado: string;
onTipoChange: (tipo: string) => void;
categoriaFilter: string;
onCategoriaChange: (categoria: string) => void;
}
export const TiposFiltroButtonsOtimizado: React.FC<TiposFiltroButtonsOtimizadoProps> = ({
tipoSelecionado,
onTipoChange,
categoriaFilter,
onCategoriaChange
}) => {
const { data: tipos, isLoading } = useTiposMateriaPrima();
if (isLoading) {
return (
<div className="space-y-3 mb-4 p-3 bg-muted/30 rounded-lg border">
<div className="flex flex-wrap gap-2">
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
<div className="h-8 bg-gray-200 rounded animate-pulse flex-[0_0_calc(25%_-_0.5rem)] min-w-[120px]"></div>
</div>
</div>
);
}
// Separar tipos por categoria
const tiposDiretos = tipos?.filter(tipo => tipo.categoria === 'direto') || [];
const tiposIndiretos = tipos?.filter(tipo => tipo.categoria === 'indireto') || [];
const renderCategoriaSection = (titulo: string, tiposList: any[], categoria: string) => {
if (tiposList.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-muted-foreground">{titulo}</h4>
<div className="flex-1 h-px bg-border"></div>
</div>
<div className="flex flex-wrap gap-2">
{tiposList.map((tipo) => (
<Button
key={tipo.id}
variant={tipoSelecionado === tipo.nome ? 'default' : 'outline'}
size="sm"
onClick={() => onTipoChange(tipo.nome)}
className="h-8 text-xs min-w-[120px]"
title={tipo.nome}
>
{tipo.nome}
</Button>
))}
</div>
</div>
);
};
return (
<div className="space-y-3 mb-4 p-3 bg-muted/30 rounded-lg border">
{/* Filtros de categoria e "Todos" */}
<div className="flex flex-wrap gap-2">
<Button
variant={tipoSelecionado === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => onTipoChange('all')}
className="h-8 text-xs min-w-[120px]"
>
Todos os Tipos
</Button>
<div className="flex items-center gap-1 ml-4">
<span className="text-sm text-muted-foreground">Filtrar por:</span>
<select
value={categoriaFilter}
onChange={(e) => onCategoriaChange(e.target.value)}
className="h-8 text-xs border border-input bg-background rounded px-2"
>
<option value="all">Todas as Categorias</option>
<option value="direto">Material Direto</option>
<option value="indireto">Material Indireto</option>
</select>
</div>
</div>
{/* Seções por categoria */}
{(categoriaFilter === 'all' || categoriaFilter === 'direto') &&
renderCategoriaSection('Materiais Diretos', tiposDiretos, 'direto')}
{(categoriaFilter === 'all' || categoriaFilter === 'indireto') &&
renderCategoriaSection('Materiais Indiretos', tiposIndiretos, 'indireto')}
</div>
);
};
@@ -0,0 +1,253 @@
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 { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Edit, Trash } from 'lucide-react';
import {
useTiposMateriaPrima,
useCriarTipoMaterial,
useAtualizarTipoMaterial,
useExcluirTipoMaterial
} from '@/hooks/useEstoque';
import { toast } from 'sonner';
interface TiposMateriaModalProps {
isOpen: boolean;
onClose: () => void;
}
export const TiposMateriaModal: React.FC<TiposMateriaModalProps> = ({ isOpen, onClose }) => {
const [nome, setNome] = useState('');
const [descricao, setDescricao] = useState('');
const [categoria, setCategoria] = useState<'direto' | 'indireto'>('direto');
const [gestaoEstoqueCritico, setGestaoEstoqueCritico] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: tipos, isLoading } = useTiposMateriaPrima();
const criarTipo = useCriarTipoMaterial();
const atualizarTipo = useAtualizarTipoMaterial();
const excluirTipo = useExcluirTipoMaterial();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!nome.trim()) return;
try {
const tipoData = {
nome: nome.trim(),
descricao: descricao.trim(),
categoria,
gestao_estoque_critico: gestaoEstoqueCritico,
caracteristicas: {},
controles: {},
ativo: true
};
if (editingId) {
await atualizarTipo.mutateAsync({
id: editingId,
...tipoData
});
} else {
await criarTipo.mutateAsync(tipoData);
}
setNome('');
setDescricao('');
setCategoria('direto');
setGestaoEstoqueCritico(true);
setEditingId(null);
} catch (error) {
console.error('Erro ao salvar tipo de material:', error);
}
};
const handleEdit = (tipo: any) => {
setNome(tipo.nome);
setDescricao(tipo.descricao || '');
setCategoria(tipo.categoria || 'direto');
setGestaoEstoqueCritico(tipo.gestao_estoque_critico ?? true);
setEditingId(tipo.id);
};
const handleDelete = async (id: string) => {
if (window.confirm('Tem certeza que deseja excluir este tipo de material?')) {
await excluirTipo.mutateAsync(id);
}
};
const handleCancel = () => {
setNome('');
setDescricao('');
setCategoria('direto');
setGestaoEstoqueCritico(true);
setEditingId(null);
};
const handleToggleGestaoEstoque = async (tipo: any) => {
try {
const novoValor = !tipo.gestao_estoque_critico;
await atualizarTipo.mutateAsync({
id: tipo.id,
nome: tipo.nome,
descricao: tipo.descricao || '',
categoria: tipo.categoria || 'direto',
caracteristicas: tipo.caracteristicas || {},
controles: tipo.controles || {},
ativo: tipo.ativo ?? true,
gestao_estoque_critico: novoValor
});
toast.success(`Gestão de estoque crítico ${novoValor ? 'ativada' : 'desativada'} para ${tipo.nome}`);
} catch (error) {
console.error('Erro ao alterar gestão de estoque crítico:', error);
toast.error('Erro ao alterar configuração');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Tipos de Material</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<Label htmlFor="nome">Nome do Tipo *</Label>
<Input
id="nome"
value={nome}
onChange={(e) => setNome(e.target.value)}
placeholder="Ex: Perfis Laminados"
required
/>
</div>
<div>
<Label htmlFor="categoria">Categoria *</Label>
<Select value={categoria} onValueChange={(value: 'direto' | 'indireto') => setCategoria(value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a categoria" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direto">Material Direto</SelectItem>
<SelectItem value="indireto">Material Indireto</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-6">
<Checkbox
id="gestaoEstoqueCritico"
checked={gestaoEstoqueCritico}
onCheckedChange={(checked) => setGestaoEstoqueCritico(!!checked)}
/>
<Label htmlFor="gestaoEstoqueCritico" className="text-sm font-medium">
Gestão Estoque Crítico
</Label>
</div>
<div>
<Label htmlFor="descricao">Descrição</Label>
<Textarea
id="descricao"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Descrição do tipo de material"
rows={2}
/>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={criarTipo.isPending || atualizarTipo.isPending}>
{editingId ? 'Atualizar' : 'Adicionar'}
</Button>
{editingId && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancelar
</Button>
)}
</div>
</form>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Gestão Est. Crítico (S/N)</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-24">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center">Carregando...</TableCell>
</TableRow>
) : tipos?.length ? (
tipos.map((tipo) => (
<TableRow key={tipo.id}>
<TableCell className="font-medium">{tipo.nome}</TableCell>
<TableCell>
<Badge variant={tipo.categoria === 'direto' ? 'default' : 'secondary'}>
{tipo.categoria === 'direto' ? 'Direto' : 'Indireto'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={(tipo as any).gestao_estoque_critico !== false ? 'default' : 'outline'}
className="cursor-pointer hover:opacity-80 transition-opacity select-none"
onDoubleClick={() => handleToggleGestaoEstoque(tipo)}
title="Duplo clique para alterar"
>
{(tipo as any).gestao_estoque_critico !== false ? 'SIM' : 'NÃO'}
</Badge>
</TableCell>
<TableCell>{tipo.descricao || '-'}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(tipo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tipo.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center">Nenhum tipo encontrado</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,196 @@
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 { Textarea } from '@/components/ui/textarea';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Edit, Trash } from 'lucide-react';
import {
useUnidadesMedida,
useCriarUnidadeMedida,
useAtualizarUnidadeMedida,
useExcluirUnidadeMedida
} from '@/hooks/useEstoqueCRUD';
interface UnidadesMedidaModalProps {
isOpen: boolean;
onClose: () => void;
}
export const UnidadesMedidaModal: React.FC<UnidadesMedidaModalProps> = ({ isOpen, onClose }) => {
const [nome, setNome] = useState('');
const [abreviacao, setAbreviacao] = useState('');
const [descricao, setDescricao] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const { data: unidades, isLoading } = useUnidadesMedida();
const criarUnidade = useCriarUnidadeMedida();
const atualizarUnidade = useAtualizarUnidadeMedida();
const excluirUnidade = useExcluirUnidadeMedida();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!nome.trim() || !abreviacao.trim()) return;
try {
const unidadeData = {
nome: nome.trim(),
abreviacao: abreviacao.trim().toUpperCase(),
descricao: descricao.trim(),
ativo: true
};
if (editingId) {
await atualizarUnidade.mutateAsync({
id: editingId,
...unidadeData
});
} else {
await criarUnidade.mutateAsync(unidadeData);
}
setNome('');
setAbreviacao('');
setDescricao('');
setEditingId(null);
} catch (error) {
console.error('Erro ao salvar unidade de medida:', error);
}
};
const handleEdit = (unidade: any) => {
setNome(unidade.nome);
setAbreviacao(unidade.abreviacao);
setDescricao(unidade.descricao || '');
setEditingId(unidade.id);
};
const handleDelete = async (id: string) => {
if (confirm('Tem certeza que deseja excluir esta unidade de medida?')) {
await excluirUnidade.mutateAsync(id);
}
};
const handleCancel = () => {
setNome('');
setAbreviacao('');
setDescricao('');
setEditingId(null);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Gerenciar Unidades de Medida</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded-lg">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="nome">Nome da Unidade *</Label>
<Input
id="nome"
value={nome}
onChange={(e) => setNome(e.target.value)}
placeholder="Ex: Quilograma"
required
/>
</div>
<div>
<Label htmlFor="abreviacao">Abreviação *</Label>
<Input
id="abreviacao"
value={abreviacao}
onChange={(e) => setAbreviacao(e.target.value)}
placeholder="Ex: KG"
required
/>
</div>
<div>
<Label htmlFor="descricao">Descrição</Label>
<Textarea
id="descricao"
value={descricao}
onChange={(e) => setDescricao(e.target.value)}
placeholder="Descrição da unidade de medida"
rows={2}
/>
</div>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={criarUnidade.isPending || atualizarUnidade.isPending}>
{editingId ? 'Atualizar' : 'Adicionar'}
</Button>
{editingId && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancelar
</Button>
)}
</div>
</form>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Abreviação</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-24">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">Carregando...</TableCell>
</TableRow>
) : unidades?.length ? (
unidades.map((unidade) => (
<TableRow key={unidade.id}>
<TableCell className="font-medium">{unidade.nome}</TableCell>
<TableCell>
<span className="font-mono bg-muted px-2 py-1 rounded text-sm">
{unidade.abreviacao}
</span>
</TableCell>
<TableCell>{unidade.descricao || '-'}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(unidade)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(unidade.id)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="text-center">Nenhuma unidade encontrada</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,30 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { EstoqueMaterial } from '@/hooks/useEstoque';
interface MaterialAdditionalInfoProps {
formData: Partial<EstoqueMaterial>;
onInputChange: (field: string, value: any) => void;
}
export const MaterialAdditionalInfo: React.FC<MaterialAdditionalInfoProps> = ({
formData,
onInputChange
}) => {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Outras Informações</h3>
<div>
<Label htmlFor="observacoes">Observações</Label>
<Textarea
id="observacoes"
value={formData.observacoes || ''}
onChange={(e) => onInputChange('observacoes', e.target.value)}
rows={3}
/>
</div>
</div>
);
};
@@ -0,0 +1,187 @@
import React from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { EstoqueMaterial } from '@/hooks/useEstoque';
import { useUnidadesMedida, useLocalizacoesEstoque } from '@/hooks/useEstoqueCRUD';
interface MaterialBasicInfoProps {
formData: Partial<EstoqueMaterial>;
onInputChange: (field: string, value: any) => void;
tiposMaterial?: any[];
lotes?: any[];
isEditing?: boolean;
}
export const MaterialBasicInfo: React.FC<MaterialBasicInfoProps> = ({
formData,
onInputChange,
tiposMaterial,
lotes,
isEditing = false
}) => {
// Hooks para buscar dados das tabelas relacionadas
const { data: unidadesMedida, isLoading: loadingUnidades } = useUnidadesMedida();
const { data: localizacoes, isLoading: loadingLocalizacoes } = useLocalizacoesEstoque();
// Filtrar lotes disponíveis baseado na descrição e qualidade do aço
const lotesDisponiveis = React.useMemo(() => {
if (!lotes || !formData.descricao) return [];
return lotes.filter(lote => {
if (lote.status !== 'Ativo') return false;
// Se o material tem descrição e qualidade do aço, filtrar por ambos
if (formData.descricao && formData.qualidade_aco) {
return lote.estoque_materiais?.descricao === formData.descricao &&
lote.estoque_materiais?.qualidade_aco === formData.qualidade_aco;
}
// Se tem apenas descrição, filtrar só por descrição
if (formData.descricao) {
return lote.estoque_materiais?.descricao === formData.descricao;
}
return true;
});
}, [lotes, formData.descricao, formData.qualidade_aco]);
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Informações Básicas</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="descricao">Descrição *</Label>
<Input
id="descricao"
value={formData.descricao || ''}
onChange={(e) => onInputChange('descricao', e.target.value)}
required={!isEditing}
/>
</div>
<div>
<Label htmlFor="tipo_material_id">Tipo de Material</Label>
<Select value={formData.tipo_material_id || ''} onValueChange={(value) => onInputChange('tipo_material_id', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione o tipo" />
</SelectTrigger>
<SelectContent>
{tiposMaterial?.map((tipo) => (
<SelectItem key={tipo.id} value={tipo.id}>
{tipo.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="unidade">Unidade</Label>
<Select value={formData.unidade || ''} onValueChange={(value) => onInputChange('unidade', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a unidade" />
</SelectTrigger>
<SelectContent>
{loadingUnidades ? (
<SelectItem value="loading" disabled>
Carregando unidades...
</SelectItem>
) : unidadesMedida && unidadesMedida.length > 0 ? (
unidadesMedida.map((unidade) => (
<SelectItem key={unidade.id} value={unidade.abreviacao}>
{unidade.abreviacao} - {unidade.nome}
</SelectItem>
))
) : (
<SelectItem value="no-units" disabled>
Nenhuma unidade cadastrada
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="lote_atual">Lote Atual (Opcional)</Label>
<Select value={formData.lote_atual || ''} onValueChange={(value) => onInputChange('lote_atual', value === 'none' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione o lote (opcional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Nenhum lote</SelectItem>
{lotesDisponiveis.length > 0 ? (
lotesDisponiveis.map((lote) => (
<SelectItem key={lote.id} value={lote.lote}>
{lote.lote} - {lote.estoque_materiais?.descricao || 'Sem descrição'}
</SelectItem>
))
) : (
<SelectItem value="no-lots" disabled>
Nenhum lote disponível para este material
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="localizacao">Localização</Label>
<Select value={formData.localizacao || ''} onValueChange={(value) => onInputChange('localizacao', value)}>
<SelectTrigger>
<SelectValue placeholder="Selecione a localização" />
</SelectTrigger>
<SelectContent>
{loadingLocalizacoes ? (
<SelectItem value="loading" disabled>
Carregando localizações...
</SelectItem>
) : localizacoes && localizacoes.length > 0 ? (
localizacoes.map((localizacao) => (
<SelectItem key={localizacao.id} value={localizacao.nome}>
{localizacao.nome}
</SelectItem>
))
) : (
<SelectItem value="no-locations" disabled>
Nenhuma localização cadastrada
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="kg_por_metro">Kg/m ou Kg/m2</Label>
<Input
id="kg_por_metro"
type="number"
step="0.001"
value={formData.kg_por_metro || ''}
onChange={(e) => onInputChange('kg_por_metro', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Peso por metro/metro quadrado"
/>
</div>
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select value={formData.status || 'Normal'} onValueChange={(value) => onInputChange('status', value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Normal">Normal</SelectItem>
<SelectItem value="Crítico">Crítico</SelectItem>
<SelectItem value="Excesso">Excesso</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
};
@@ -0,0 +1,106 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { EstoqueMaterial } from '@/hooks/useEstoque';
interface MaterialQuantitiesValuesProps {
formData: Partial<EstoqueMaterial>;
onInputChange: (field: string, value: any) => void;
}
export const MaterialQuantitiesValues = ({ formData, onInputChange }: MaterialQuantitiesValuesProps) => {
// Calcular quantidade disponível automaticamente
const quantidadeTotal = formData.quantidade_total || 0;
const quantidadeEmpenhada = formData.quantidade_empenhada || 0;
const quantidadeDisponivel = quantidadeTotal - quantidadeEmpenhada;
// Atualizar quantidade disponível sempre que total ou empenhada mudarem
React.useEffect(() => {
onInputChange('quantidade_disponivel', quantidadeDisponivel);
}, [quantidadeTotal, quantidadeEmpenhada, quantidadeDisponivel, onInputChange]);
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Quantidades e Valores</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<Label htmlFor="quantidade_total">Quantidade Total *</Label>
<Input
id="quantidade_total"
type="number"
step="1"
min="0"
value={formData.quantidade_total || ''}
onChange={(e) => onInputChange('quantidade_total', e.target.value ? Math.floor(Number(e.target.value)) : 0)}
placeholder="Quantidade total"
/>
</div>
<div>
<Label htmlFor="quantidade_empenhada">Quantidade Empenhada</Label>
<Input
id="quantidade_empenhada"
type="number"
step="1"
min="0"
value={formData.quantidade_empenhada || ''}
onChange={(e) => onInputChange('quantidade_empenhada', e.target.value ? Math.floor(Number(e.target.value)) : 0)}
placeholder="Quantidade empenhada"
/>
</div>
<div>
<Label htmlFor="quantidade_disponivel">Quantidade Disponível</Label>
<Input
id="quantidade_disponivel"
type="number"
value={quantidadeDisponivel}
readOnly
className="bg-gray-100 cursor-not-allowed"
placeholder="Calculado automaticamente"
/>
</div>
<div>
<Label htmlFor="quantidade_minima">Quantidade Mínima</Label>
<Input
id="quantidade_minima"
type="number"
step="1"
min="0"
value={formData.quantidade_minima || ''}
onChange={(e) => onInputChange('quantidade_minima', e.target.value ? Math.floor(Number(e.target.value)) : null)}
placeholder="Estoque mínimo"
/>
</div>
<div>
<Label htmlFor="quantidade_maxima">Quantidade Máxima</Label>
<Input
id="quantidade_maxima"
type="number"
step="1"
min="0"
value={formData.quantidade_maxima || ''}
onChange={(e) => onInputChange('quantidade_maxima', e.target.value ? Math.floor(Number(e.target.value)) : null)}
placeholder="Estoque máximo"
/>
</div>
<div>
<Label htmlFor="valor_unitario">Preço Unitário (R$)</Label>
<Input
id="valor_unitario"
type="number"
step="0.01"
min="0"
value={formData.valor_unitario || ''}
onChange={(e) => onInputChange('valor_unitario', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="0,00"
/>
</div>
</div>
</div>
);
};
@@ -0,0 +1,81 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { EstoqueMaterial } from '@/hooks/useEstoque';
interface MaterialTechnicalSpecsProps {
formData: Partial<EstoqueMaterial>;
onInputChange: (field: string, value: any) => void;
}
export const MaterialTechnicalSpecs = ({ formData, onInputChange }: MaterialTechnicalSpecsProps) => {
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Especificações Técnicas</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="comprimento">Comprimento (mm)</Label>
<Input
id="comprimento"
type="number"
step="1"
min="0"
value={formData.comprimento || ''}
onChange={(e) => onInputChange('comprimento', e.target.value ? Math.floor(Number(e.target.value)) : null)}
placeholder="Comprimento em mm"
/>
</div>
<div>
<Label htmlFor="largura">Largura (mm)</Label>
<Input
id="largura"
type="number"
step="1"
min="0"
value={formData.largura || ''}
onChange={(e) => onInputChange('largura', e.target.value ? Math.floor(Number(e.target.value)) : null)}
placeholder="Largura em mm"
/>
</div>
<div>
<Label htmlFor="espessura">Espessura (mm)</Label>
<Input
id="espessura"
type="number"
step="1"
min="0"
value={formData.espessura || ''}
onChange={(e) => onInputChange('espessura', e.target.value ? Math.floor(Number(e.target.value)) : null)}
placeholder="Espessura em mm"
/>
</div>
</div>
<div>
<Label htmlFor="qualidade_aco">Qualidade do Aço</Label>
<Input
id="qualidade_aco"
value={formData.qualidade_aco || ''}
onChange={(e) => onInputChange('qualidade_aco', e.target.value)}
placeholder="Ex: SAE 1020, ASTM A36, etc."
/>
</div>
<div>
<Label htmlFor="peso_unitario">Peso Unitário (kg)</Label>
<Input
id="peso_unitario"
type="number"
step="0.001"
min="0"
value={formData.peso_unitario || ''}
onChange={(e) => onInputChange('peso_unitario', e.target.value ? parseFloat(e.target.value) : null)}
placeholder="Peso unitário em kg"
/>
</div>
</div>
);
};
@@ -0,0 +1,199 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { Skeleton } from '@/components/ui/skeleton';
interface EmpenhosPorOFReportProps {
filters: any;
}
export const EmpenhosPorOFReport: React.FC<EmpenhosPorOFReportProps> = ({ filters }) => {
const { data: empenhos = [], isLoading } = useQuery({
queryKey: ['relatorio-empenhos-of', filters],
queryFn: async () => {
let query = supabase
.from('empenhos_material')
.select(`
*,
estoque_materiais!inner(
codigo,
descricao,
unidade,
valor_unitario
)
`);
if (filters.of_vinculada) {
query = query.eq('of_number', filters.of_vinculada);
}
if (filters.status_empenho && filters.status_empenho !== 'todos') {
query = query.eq('status', filters.status_empenho);
}
if (filters.data_inicio) {
query = query.gte('data_empenho', filters.data_inicio);
}
if (filters.data_fim) {
query = query.lte('data_empenho', filters.data_fim);
}
const { data, error } = await query.order('data_empenho', { ascending: false });
if (error) throw error;
return data || [];
}
});
if (isLoading) {
return <Skeleton className="w-full h-96" />;
}
// Calcular totais
const totais = empenhos.reduce((acc, empenho) => {
acc.totalEmpenhado += empenho.quantidade_empenhada;
acc.totalUtilizado += empenho.quantidade_utilizada;
acc.valorTotal += empenho.quantidade_empenhada * (empenho.estoque_materiais?.valor_unitario || 0);
if (empenho.status === 'Empenhado') acc.ativos++;
else if (empenho.status === 'Finalizado') acc.finalizados++;
else if (empenho.status === 'Cancelado') acc.cancelados++;
return acc;
}, {
totalEmpenhado: 0,
totalUtilizado: 0,
valorTotal: 0,
ativos: 0,
finalizados: 0,
cancelados: 0
});
const totalRestante = totais.totalEmpenhado - totais.totalUtilizado;
return (
<div className="space-y-6">
<div className="text-center border-b pb-4">
<h1 className="text-2xl font-bold">Relatório de Empenhos por OF</h1>
<p className="text-gray-600">Gerado em: {new Date().toLocaleString('pt-BR')}</p>
{filters.of_vinculada && (
<p className="text-gray-600">OF: {filters.of_vinculada}</p>
)}
{filters.data_inicio && filters.data_fim && (
<p className="text-gray-600">Período: {filters.data_inicio} a {filters.data_fim}</p>
)}
</div>
{/* Cards de resumo */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-blue-800">Empenhos Ativos</h3>
<p className="text-2xl font-bold text-blue-900">{totais.ativos}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-green-800">Finalizados</h3>
<p className="text-2xl font-bold text-green-900">{totais.finalizados}</p>
</div>
<div className="bg-red-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-red-800">Cancelados</h3>
<p className="text-2xl font-bold text-red-900">{totais.cancelados}</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-purple-800">Valor Total</h3>
<p className="text-2xl font-bold text-purple-900">R$ {totais.valorTotal.toFixed(2)}</p>
</div>
</div>
{/* Resumo quantitativo */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-yellow-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-yellow-800">Total Empenhado</h3>
<p className="text-2xl font-bold text-yellow-900">{totais.totalEmpenhado.toFixed(2)}</p>
</div>
<div className="bg-orange-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-orange-800">Total Utilizado</h3>
<p className="text-2xl font-bold text-orange-900">{totais.totalUtilizado.toFixed(2)}</p>
</div>
<div className="bg-indigo-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-indigo-800">Restante</h3>
<p className="text-2xl font-bold text-indigo-900">{totalRestante.toFixed(2)}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Data</th>
<th className="border border-gray-300 p-2 text-left">OF</th>
<th className="border border-gray-300 p-2 text-left">Material</th>
<th className="border border-gray-300 p-2 text-left">Lote</th>
<th className="border border-gray-300 p-2 text-center">Qtd Empenhada</th>
<th className="border border-gray-300 p-2 text-center">Qtd Utilizada</th>
<th className="border border-gray-300 p-2 text-center">Restante</th>
<th className="border border-gray-300 p-2 text-center">Status</th>
<th className="border border-gray-300 p-2 text-right">Valor Unit.</th>
<th className="border border-gray-300 p-2 text-right">Valor Total</th>
</tr>
</thead>
<tbody>
{empenhos.map((empenho, index) => {
const valorUnitario = empenho.estoque_materiais?.valor_unitario || 0;
const valorTotal = empenho.quantidade_empenhada * valorUnitario;
const qtdRestante = empenho.quantidade_empenhada - empenho.quantidade_utilizada;
return (
<tr key={empenho.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="border border-gray-300 p-2">
{new Date(empenho.data_empenho).toLocaleDateString('pt-BR')}
</td>
<td className="border border-gray-300 p-2 font-medium">{empenho.of_number}</td>
<td className="border border-gray-300 p-2">
<div>
<p className="font-medium">{empenho.estoque_materiais?.descricao}</p>
<p className="text-sm text-gray-600">{empenho.estoque_materiais?.codigo}</p>
</div>
</td>
<td className="border border-gray-300 p-2">{empenho.lote || '-'}</td>
<td className="border border-gray-300 p-2 text-center">
{empenho.quantidade_empenhada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</td>
<td className="border border-gray-300 p-2 text-center">
{empenho.quantidade_utilizada.toFixed(2)} {empenho.estoque_materiais?.unidade}
</td>
<td className="border border-gray-300 p-2 text-center">
<span className={qtdRestante > 0 ? 'text-yellow-600 font-semibold' : 'text-green-600'}>
{qtdRestante.toFixed(2)} {empenho.estoque_materiais?.unidade}
</span>
</td>
<td className="border border-gray-300 p-2 text-center">
<span className={`px-2 py-1 rounded text-xs font-semibold ${
empenho.status === 'Empenhado' ? 'bg-yellow-100 text-yellow-800' :
empenho.status === 'Finalizado' ? 'bg-green-100 text-green-800' :
'bg-red-100 text-red-800'
}`}>
{empenho.status}
</span>
</td>
<td className="border border-gray-300 p-2 text-right">
R$ {valorUnitario.toFixed(2)}
</td>
<td className="border border-gray-300 p-2 text-right">
R$ {valorTotal.toFixed(2)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{empenhos.length === 0 && (
<div className="text-center py-8 text-gray-500">
Nenhum empenho encontrado com os filtros aplicados.
</div>
)}
</div>
);
};
@@ -0,0 +1,108 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { Skeleton } from '@/components/ui/skeleton';
interface MateriaisCriticosReportProps {
filters: any;
}
export const MateriaisCriticosReport: React.FC<MateriaisCriticosReportProps> = ({ filters }) => {
const { data: materiaisCriticos = [], isLoading } = useQuery({
queryKey: ['relatorio-materiais-criticos', filters],
queryFn: async () => {
let query = supabase
.from('estoque_materiais')
.select('*')
.or('status.eq.Crítico,quantidade_disponivel.lt.quantidade_minima');
if (filters.descricao_material) {
query = query.ilike('descricao', `%${filters.descricao_material}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
});
if (isLoading) {
return <Skeleton className="w-full h-96" />;
}
return (
<div className="space-y-6">
<div className="text-center border-b pb-4">
<h1 className="text-2xl font-bold text-red-800">Relatório de Materiais Críticos</h1>
<p className="text-gray-600">Gerado em: {new Date().toLocaleString('pt-BR')}</p>
<div className="bg-red-50 p-3 rounded-lg mt-4">
<p className="text-red-800 font-semibold">
{materiaisCriticos.length} materiais com estoque crítico identificados
</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-red-300">
<thead>
<tr className="bg-red-100">
<th className="border border-red-300 p-2 text-left">Código</th>
<th className="border border-red-300 p-2 text-left">Descrição</th>
<th className="border border-red-300 p-2 text-center">Qtd Atual</th>
<th className="border border-red-300 p-2 text-center">Estoque Mín.</th>
<th className="border border-red-300 p-2 text-center">Diferença</th>
<th className="border border-red-300 p-2 text-center">Status</th>
<th className="border border-red-300 p-2 text-center">Ação Necessária</th>
</tr>
</thead>
<tbody>
{materiaisCriticos.map((material, index) => {
const diferenca = (material.quantidade_disponivel || 0) - (material.quantidade_minima || 0);
const isUrgente = diferenca <= 0;
return (
<tr key={material.id} className={index % 2 === 0 ? 'bg-white' : 'bg-red-25'}>
<td className="border border-red-300 p-2">{material.codigo || '-'}</td>
<td className="border border-red-300 p-2 font-medium">{material.descricao}</td>
<td className="border border-red-300 p-2 text-center font-bold text-red-800">
{material.quantidade_disponivel?.toFixed(2) || '0'}
</td>
<td className="border border-red-300 p-2 text-center">
{material.quantidade_minima?.toFixed(2) || '0'}
</td>
<td className="border border-red-300 p-2 text-center">
<span className={`font-bold ${diferenca < 0 ? 'text-red-600' : 'text-orange-600'}`}>
{diferenca.toFixed(2)}
</span>
</td>
<td className="border border-red-300 p-2 text-center">
<span className={`px-2 py-1 rounded text-xs font-semibold ${
isUrgente ? 'bg-red-200 text-red-800' : 'bg-orange-200 text-orange-800'
}`}>
{isUrgente ? 'URGENTE' : 'CRÍTICO'}
</span>
</td>
<td className="border border-red-300 p-2 text-center">
<span className="text-sm font-medium">
Comprar {Math.abs(diferenca).toFixed(2)} {material.unidade || 'un'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{materiaisCriticos.length === 0 && (
<div className="text-center py-8">
<div className="bg-green-50 p-6 rounded-lg">
<p className="text-green-800 font-semibold text-lg"> Nenhum material crítico encontrado!</p>
<p className="text-green-600">Todos os materiais estão com estoque adequado.</p>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,121 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { Skeleton } from '@/components/ui/skeleton';
interface MovimentacoesReportProps {
filters: any;
}
export const MovimentacoesReport: React.FC<MovimentacoesReportProps> = ({ filters }) => {
const { data: movimentacoes = [], isLoading } = useQuery({
queryKey: ['relatorio-movimentacoes', filters],
queryFn: async () => {
let query = supabase
.from('movimentacoes_estoque')
.select(`
*,
estoque_materiais!inner(descricao, codigo, unidade)
`);
if (filters.data_inicio) {
query = query.gte('data_movimentacao', filters.data_inicio);
}
if (filters.data_fim) {
query = query.lte('data_movimentacao', filters.data_fim);
}
if (filters.descricao_material) {
query = query.ilike('estoque_materiais.descricao', `%${filters.descricao_material}%`);
}
const { data, error } = await query.order('data_movimentacao', { ascending: false });
if (error) throw error;
return data || [];
}
});
if (isLoading) {
return <Skeleton className="w-full h-96" />;
}
const totalEntradas = movimentacoes
.filter(mov => mov.tipo_movimentacao === 'entrada')
.reduce((sum, item) => sum + (item.quantidade || 0), 0);
const totalSaidas = movimentacoes
.filter(mov => mov.tipo_movimentacao === 'saida')
.reduce((sum, item) => sum + (item.quantidade || 0), 0);
return (
<div className="space-y-6">
<div className="text-center border-b pb-4">
<h1 className="text-2xl font-bold">Relatório de Movimentações</h1>
<p className="text-gray-600">Gerado em: {new Date().toLocaleString('pt-BR')}</p>
{filters.data_inicio && filters.data_fim && (
<p className="text-gray-600">Período: {filters.data_inicio} a {filters.data_fim}</p>
)}
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-green-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-green-800">Total Entradas</h3>
<p className="text-2xl font-bold text-green-900">{totalEntradas.toFixed(2)}</p>
</div>
<div className="bg-red-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-red-800">Total Saídas</h3>
<p className="text-2xl font-bold text-red-900">{totalSaidas.toFixed(2)}</p>
</div>
<div className="bg-blue-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-blue-800">Movimentações</h3>
<p className="text-2xl font-bold text-blue-900">{movimentacoes.length}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Data</th>
<th className="border border-gray-300 p-2 text-left">Material</th>
<th className="border border-gray-300 p-2 text-center">Tipo</th>
<th className="border border-gray-300 p-2 text-center">Quantidade</th>
<th className="border border-gray-300 p-2 text-left">Observações</th>
</tr>
</thead>
<tbody>
{movimentacoes.map((mov, index) => (
<tr key={mov.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="border border-gray-300 p-2">
{new Date(mov.data_movimentacao).toLocaleDateString('pt-BR')}
</td>
<td className="border border-gray-300 p-2">
{mov.estoque_materiais?.descricao || '-'}
</td>
<td className="border border-gray-300 p-2 text-center">
<span className={`px-2 py-1 rounded text-xs ${
mov.tipo_movimentacao === 'entrada' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{mov.tipo_movimentacao === 'entrada' ? 'Entrada' : 'Saída'}
</span>
</td>
<td className="border border-gray-300 p-2 text-center">
{mov.quantidade?.toFixed(2)} {mov.estoque_materiais?.unidade || ''}
</td>
<td className="border border-gray-300 p-2">{mov.observacoes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{movimentacoes.length === 0 && (
<div className="text-center py-8 text-gray-500">
Nenhuma movimentação encontrada com os filtros aplicados.
</div>
)}
</div>
);
};
@@ -0,0 +1,140 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
import { Skeleton } from '@/components/ui/skeleton';
interface PosicaoEstoqueReportProps {
filters: any;
}
export const PosicaoEstoqueReport: React.FC<PosicaoEstoqueReportProps> = ({ filters }) => {
const { data: materiais = [], isLoading } = useQuery({
queryKey: ['relatorio-posicao-estoque', filters],
queryFn: async () => {
let query = supabase.from('estoque_materiais').select('*');
if (filters.descricao_material) {
query = query.ilike('descricao', `%${filters.descricao_material}%`);
}
if (filters.status && filters.status !== 'todos') {
query = query.eq('status', filters.status);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
});
if (isLoading) {
return <Skeleton className="w-full h-96" />;
}
const totalQuantidade = materiais.reduce((sum, item) => sum + (item.quantidade_disponivel || 0), 0);
const totalValor = materiais.reduce((sum, item) => sum + ((item.quantidade_disponivel || 0) * (item.valor_unitario || 0)), 0);
// Calcular peso total geral e peso disponível
const pesoTotalGeral = materiais.reduce((sum, item) => {
const kgPorMetro = item.kg_por_metro || 0;
const comprimento = item.comprimento || 0;
const quantidadeTotal = item.quantidade_total || 0;
const pesoItem = (kgPorMetro * comprimento / 1000) * quantidadeTotal;
return sum + pesoItem;
}, 0);
const pesoDisponivelTotal = materiais.reduce((sum, item) => {
const kgPorMetro = item.kg_por_metro || 0;
const comprimento = item.comprimento || 0;
const quantidadeDisponivel = item.quantidade_disponivel || 0;
const pesoDisponivel = (kgPorMetro * comprimento / 1000) * quantidadeDisponivel;
return sum + pesoDisponivel;
}, 0);
return (
<div className="space-y-6">
<div className="text-center border-b pb-4">
<h1 className="text-2xl font-bold">Relatório de Posição de Estoque</h1>
<p className="text-gray-600">Gerado em: {new Date().toLocaleString('pt-BR')}</p>
{filters.data_inicio && filters.data_fim && (
<p className="text-gray-600">Período: {filters.data_inicio} a {filters.data_fim}</p>
)}
</div>
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-blue-800">Total de Materiais</h3>
<p className="text-2xl font-bold text-blue-900">{materiais.length}</p>
</div>
<div className="bg-green-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-green-800">Quantidade Total</h3>
<p className="text-2xl font-bold text-green-900">{totalQuantidade.toFixed(2)}</p>
</div>
<div className="bg-red-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-red-800">Peso Total Geral</h3>
<p className="text-2xl font-bold text-red-900">{pesoTotalGeral.toFixed(2)} kg</p>
</div>
<div className="bg-purple-50 p-4 rounded-lg text-center">
<h3 className="text-lg font-semibold text-purple-800">Peso Disp. (kg)</h3>
<p className="text-2xl font-bold text-purple-900">{pesoDisponivelTotal.toFixed(2)} kg</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Código</th>
<th className="border border-gray-300 p-2 text-left">Descrição</th>
<th className="border border-gray-300 p-2 text-center">Compr.</th>
<th className="border border-gray-300 p-2 text-center">Quantidade</th>
<th className="border border-gray-300 p-2 text-center">Unidade</th>
<th className="border border-gray-300 p-2 text-right">Peso Total (kg)</th>
<th className="border border-gray-300 p-2 text-right">Peso Disp. (kg)</th>
<th className="border border-gray-300 p-2 text-center">Status</th>
</tr>
</thead>
<tbody>
{materiais.map((material, index) => {
const kgPorMetro = material.kg_por_metro || 0;
const comprimento = material.comprimento || 0;
const quantidadeTotal = material.quantidade_total || 0;
const quantidadeDisponivel = material.quantidade_disponivel || 0;
const pesoTotal = (kgPorMetro * comprimento / 1000) * quantidadeTotal;
const pesoDisponivel = (kgPorMetro * comprimento / 1000) * quantidadeDisponivel;
return (
<tr key={material.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="border border-gray-300 p-2">{material.codigo || '-'}</td>
<td className="border border-gray-300 p-2">{material.descricao}</td>
<td className="border border-gray-300 p-2 text-center">{comprimento ? `${comprimento.toFixed(0)}mm` : '-'}</td>
<td className="border border-gray-300 p-2 text-center">{material.quantidade_disponivel?.toFixed(2) || '0'}</td>
<td className="border border-gray-300 p-2 text-center">{material.unidade || '-'}</td>
<td className="border border-gray-300 p-2 text-right">{pesoTotal.toFixed(2)}</td>
<td className="border border-gray-300 p-2 text-right">{pesoDisponivel.toFixed(2)}</td>
<td className="border border-gray-300 p-2 text-center">
<span className={`px-2 py-1 rounded text-xs ${
material.status === 'Crítico' ? 'bg-red-100 text-red-800' :
material.status === 'Excesso' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{material.status || 'Normal'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{materiais.length === 0 && (
<div className="text-center py-8 text-gray-500">
Nenhum material encontrado com os filtros aplicados.
</div>
)}
</div>
);
};
@@ -0,0 +1,73 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Printer, Download, X } from 'lucide-react';
import { generateProfessionalPDF, printProfessionalPDF } from '@/utils/pdfGenerator';
import { toast } from 'sonner';
interface ReportPreviewModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
reportId: string;
filters?: any;
}
export const ReportPreviewModal: React.FC<ReportPreviewModalProps> = ({
isOpen,
onClose,
title,
children,
reportId,
filters
}) => {
const handleDownloadPDF = async () => {
try {
await generateProfessionalPDF(reportId, `${title.toLowerCase().replace(/\s+/g, '_')}.pdf`);
toast.success('PDF baixado com sucesso!');
} catch (error) {
console.error('Erro ao gerar PDF:', error);
toast.error('Erro ao gerar PDF');
}
};
const handlePrint = async () => {
try {
await printProfessionalPDF(reportId);
toast.success('Enviado para impressão!');
} catch (error) {
console.error('Erro ao imprimir:', error);
toast.error('Erro ao imprimir');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden">
<DialogHeader className="flex flex-row items-center justify-between">
<DialogTitle>{title}</DialogTitle>
<div className="flex items-center gap-2">
<Button onClick={handlePrint} className="flex items-center gap-2">
<Printer className="w-4 h-4" />
Imprimir
</Button>
<Button onClick={handleDownloadPDF} variant="outline" className="flex items-center gap-2">
<Download className="w-4 h-4" />
Download PDF
</Button>
<Button onClick={onClose} variant="ghost" size="sm">
<X className="w-4 h-4" />
</Button>
</div>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
<div id={reportId} className="bg-white p-6 text-black">
{children}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,118 @@
import React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Copy } from 'lucide-react';
import { EstoqueMaterial, useDuplicarMaterial } from '@/hooks/useEstoque';
import { EstoqueTableHeader } from '../EstoqueTableHeader';
import { ScrollableTable } from '@/components/ui/ScrollableTable';
interface EstoqueDesktopTableProps {
materiais: EstoqueMaterial[];
selectedMaterials: EstoqueMaterial[];
onSelectMaterial: (material: EstoqueMaterial, isSelected: boolean) => void;
onEditMaterial: (material: EstoqueMaterial) => void;
onSelectAll: (e: React.ChangeEvent<HTMLInputElement>) => void;
isAllSelected: () => boolean;
sortField: any;
sortOrder: any;
onSort: (field: any) => void;
getStatusColor: (status: string) => string;
}
export const EstoqueDesktopTable: React.FC<EstoqueDesktopTableProps> = ({
materiais,
selectedMaterials,
onSelectMaterial,
onEditMaterial,
onSelectAll,
isAllSelected,
sortField,
sortOrder,
onSort,
getStatusColor
}) => {
const duplicarMaterial = useDuplicarMaterial();
const handleDuplicarMaterial = (material: EstoqueMaterial) => {
duplicarMaterial.mutate(material);
};
return (
<div className="hidden sm:block">
<div className="w-full overflow-x-auto">
<Table>
<TableHeader>
<EstoqueTableHeader
sortField={sortField}
sortOrder={sortOrder}
onSort={onSort}
isAllSelected={isAllSelected()}
onSelectAll={onSelectAll}
/>
</TableHeader>
<TableBody>
{materiais.map((material) => {
const pesoTotal = (material.quantidade_total || 0) * (material.peso_unitario || 0);
return (
<TableRow key={material.id} className="h-auto min-h-[3rem]">
<TableCell className="py-2 px-1 w-8">
<input
type="checkbox"
className="w-3 h-3"
checked={selectedMaterials.some(m => m.id === material.id)}
onChange={(e) => onSelectMaterial(material, e.target.checked)}
/>
</TableCell>
<TableCell className="text-xs py-2 px-2 w-80">
<div className="break-words whitespace-normal leading-tight max-w-full">
{material.descricao}
</div>
</TableCell>
<TableCell className="text-xs py-2 px-2 w-24">{material.lote_atual || '-'}</TableCell>
<TableCell className="text-xs py-2 px-2 w-32">{material.tipos_materia_prima?.nome || '-'}</TableCell>
<TableCell className="text-xs py-2 px-2 w-16">{material.unidade}</TableCell>
<TableCell className="text-xs py-2 px-2 w-16">{material.comprimento || '-'}</TableCell>
<TableCell className="text-xs py-2 px-2 font-medium w-20">{material.quantidade_total}</TableCell>
<TableCell className="text-xs py-2 px-2 font-medium text-blue-600 w-20">{pesoTotal.toFixed(1)}</TableCell>
<TableCell className="text-xs py-2 px-2 font-medium text-green-600 w-20">{material.quantidade_disponivel}</TableCell>
<TableCell className="text-xs py-2 px-2 font-medium text-orange-600 w-20">{material.quantidade_empenhada}</TableCell>
<TableCell className="py-2 px-2 w-24">
<Badge variant="outline" className={`${getStatusColor(material.status)} text-xs`}>
{material.status}
</Badge>
</TableCell>
<TableCell className="py-2 px-2 w-20">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEditMaterial(material)}
className="h-6 w-6 p-1"
title="Editar material"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDuplicarMaterial(material)}
className="h-6 w-6 p-1 text-blue-600 hover:text-blue-700"
title="Duplicar material"
disabled={duplicarMaterial.isPending}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
};
@@ -0,0 +1,124 @@
import React from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Copy } from 'lucide-react';
import { EstoqueMaterial, useDuplicarMaterial } from '@/hooks/useEstoque';
interface EstoqueMobileViewProps {
materiais: EstoqueMaterial[];
selectedMaterials: EstoqueMaterial[];
onSelectMaterial: (material: EstoqueMaterial, isSelected: boolean) => void;
onEditMaterial: (material: EstoqueMaterial) => void;
getStatusColor: (status: string) => string;
}
export const EstoqueMobileView: React.FC<EstoqueMobileViewProps> = ({
materiais,
selectedMaterials,
onSelectMaterial,
onEditMaterial,
getStatusColor
}) => {
const duplicarMaterial = useDuplicarMaterial();
const handleDuplicarMaterial = (material: EstoqueMaterial) => {
duplicarMaterial.mutate(material);
};
return (
<div className="sm:hidden space-y-4">
{materiais.map((material) => {
const pesoTotal = (material.quantidade_total || 0) * (material.peso_unitario || 0);
return (
<Card key={material.id} className="relative">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-2">
<input
type="checkbox"
className="w-4 h-4"
checked={selectedMaterials.some(m => m.id === material.id)}
onChange={(e) => onSelectMaterial(material, e.target.checked)}
/>
<div className="flex-1">
<h3 className="font-medium text-sm text-foreground leading-tight">
{material.descricao}
</h3>
<p className="text-xs text-muted-foreground mt-1">
Código: {material.codigo}
</p>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEditMaterial(material)}
className="h-7 w-7 p-1"
title="Editar material"
>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDuplicarMaterial(material)}
className="h-7 w-7 p-1 text-blue-600 hover:text-blue-700"
title="Duplicar material"
disabled={duplicarMaterial.isPending}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Lote:</span>
<span className="ml-1 font-medium">{material.lote_atual || '-'}</span>
</div>
<div>
<span className="text-muted-foreground">Tipo:</span>
<span className="ml-1 font-medium">{material.tipos_materia_prima?.nome || '-'}</span>
</div>
<div>
<span className="text-muted-foreground">Unidade:</span>
<span className="ml-1 font-medium">{material.unidade}</span>
</div>
<div>
<span className="text-muted-foreground">Comprimento:</span>
<span className="ml-1 font-medium">{material.comprimento || '-'}</span>
</div>
<div>
<span className="text-muted-foreground">Qtd Total:</span>
<span className="ml-1 font-medium">{material.quantidade_total}</span>
</div>
<div>
<span className="text-muted-foreground">Peso Total:</span>
<span className="ml-1 font-medium text-blue-600">{pesoTotal.toFixed(1)}</span>
</div>
<div>
<span className="text-muted-foreground">Disponível:</span>
<span className="ml-1 font-medium text-green-600">{material.quantidade_disponivel}</span>
</div>
<div>
<span className="text-muted-foreground">Empenhada:</span>
<span className="ml-1 font-medium text-orange-600">{material.quantidade_empenhada}</span>
</div>
</div>
<div className="flex justify-between items-center mt-3">
<Badge variant="outline" className={`${getStatusColor(material.status)} text-xs`}>
{material.status}
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
);
};