🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -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 já 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 há 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user