🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Paperclip, X, FileText, Image, FileSpreadsheet } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
onFilesChange: (files: File[], urls: string[]) => void;
|
||||
initialFiles?: string[];
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 300 * 1024; // 300KB
|
||||
const ALLOWED_TYPES = [
|
||||
'text/plain',
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv'
|
||||
];
|
||||
|
||||
export function FileUploadSection({ onFilesChange, initialFiles = [] }: FileUploadSectionProps) {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [uploadedUrls, setUploadedUrls] = useState<string[]>(initialFiles);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith('image/')) return <Image className="h-4 w-4" />;
|
||||
if (type.includes('sheet') || type.includes('excel') || type.includes('csv')) return <FileSpreadsheet className="h-4 w-4" />;
|
||||
return <FileText className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
// Validar arquivos
|
||||
const validFiles = files.filter(file => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(`Arquivo ${file.name} excede 300KB`);
|
||||
return false;
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
toast.error(`Tipo de arquivo ${file.type} não permitido`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const newFiles = [...selectedFiles, ...validFiles];
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesChange(newFiles, uploadedUrls);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = selectedFiles.filter((_, i) => i !== index);
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesChange(newFiles, uploadedUrls);
|
||||
};
|
||||
|
||||
const uploadFiles = async () => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
const newUrls: string[] = [];
|
||||
|
||||
try {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
if (!userData.user) throw new Error('Usuário não autenticado');
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
const fileName = `${userData.user.id}/${Date.now()}-${file.name}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('solicitacao-compras-anexos')
|
||||
.upload(fileName, file);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('solicitacao-compras-anexos')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
newUrls.push(publicUrl);
|
||||
}
|
||||
|
||||
const allUrls = [...uploadedUrls, ...newUrls];
|
||||
setUploadedUrls(allUrls);
|
||||
setSelectedFiles([]);
|
||||
onFilesChange([], allUrls);
|
||||
toast.success('Arquivos enviados com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro no upload:', error);
|
||||
toast.error('Erro ao enviar arquivos');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.pdf,.jpg,.jpeg,.png,.gif,.xls,.xlsx,.csv"
|
||||
onChange={handleFileSelect}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={uploadFiles}
|
||||
disabled={selectedFiles.length === 0 || uploading}
|
||||
>
|
||||
<Paperclip className="h-4 w-4 mr-2" />
|
||||
{uploading ? 'Enviando...' : 'Anexar'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Arquivos selecionados:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1 pr-1">
|
||||
{getFileIcon(file.type)}
|
||||
<span className="text-xs">{file.name}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadedUrls.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Arquivos anexados:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{uploadedUrls.map((url, index) => (
|
||||
<Badge key={index} variant="default" className="flex items-center gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
<span className="text-xs">Anexo {index + 1}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
|
||||
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 { useMateriaisEstoque } from '@/hooks/useMateriaisEstoque';
|
||||
import { useTiposMateriais } from '@/hooks/useTiposMateriais';
|
||||
|
||||
interface SeletorMateriaisModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddItem: (item: {
|
||||
material_id: string;
|
||||
descricao: string;
|
||||
unidade: string;
|
||||
quantidade: number;
|
||||
prazo_recebimento: string;
|
||||
}) => void;
|
||||
editingItem?: {
|
||||
material_id: string;
|
||||
descricao: string;
|
||||
unidade: string;
|
||||
quantidade: number;
|
||||
prazo_recebimento: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const SeletorMateriaisModal: React.FC<SeletorMateriaisModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAddItem,
|
||||
editingItem
|
||||
}) => {
|
||||
const [filtros, setFiltros] = useState({
|
||||
tipo: '',
|
||||
busca: ''
|
||||
});
|
||||
|
||||
const [selectedMaterial, setSelectedMaterial] = useState('');
|
||||
const [quantidade, setQuantidade] = useState('');
|
||||
const [prazoRecebimento, setPrazoRecebimento] = useState('');
|
||||
|
||||
const { materiais, isLoading: isLoadingMateriais } = useMateriaisEstoque(filtros);
|
||||
const { tiposMateriais, isLoading: isLoadingTipos } = useTiposMateriais();
|
||||
|
||||
// Carregar dados do item em edição
|
||||
useEffect(() => {
|
||||
if (editingItem) {
|
||||
setSelectedMaterial(editingItem.material_id);
|
||||
setQuantidade(editingItem.quantidade.toString());
|
||||
setPrazoRecebimento(editingItem.prazo_recebimento);
|
||||
} else {
|
||||
// Reset form apenas quando não estiver editando
|
||||
setSelectedMaterial('');
|
||||
setQuantidade('');
|
||||
setPrazoRecebimento('');
|
||||
}
|
||||
}, [editingItem, isOpen]);
|
||||
|
||||
// Reset form quando modal fechar
|
||||
useEffect(() => {
|
||||
if (!isOpen && !editingItem) {
|
||||
setSelectedMaterial('');
|
||||
setQuantidade('');
|
||||
setPrazoRecebimento('');
|
||||
}
|
||||
}, [isOpen, editingItem]);
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!selectedMaterial || !quantidade || !prazoRecebimento) {
|
||||
alert('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const materialData = materiais.find(m => m.id === selectedMaterial);
|
||||
if (!materialData) {
|
||||
alert('Material não encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantidadeNum = parseFloat(quantidade);
|
||||
if (isNaN(quantidadeNum) || quantidadeNum <= 0) {
|
||||
alert('Quantidade deve ser um número válido maior que zero');
|
||||
return;
|
||||
}
|
||||
|
||||
onAddItem({
|
||||
material_id: selectedMaterial,
|
||||
descricao: materialData.descricao,
|
||||
unidade: materialData.unidade,
|
||||
quantidade: quantidadeNum,
|
||||
prazo_recebimento: prazoRecebimento
|
||||
});
|
||||
|
||||
// Reset form apenas se não estiver editando
|
||||
if (!editingItem) {
|
||||
setSelectedMaterial('');
|
||||
setQuantidade('');
|
||||
setPrazoRecebimento('');
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectedMaterialData = materiais.find(m => m.id === selectedMaterial);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? 'Editar Material' : 'Adicionar Material'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Filtros */}
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<h3 className="text-lg font-medium mb-4">Filtros</h3>
|
||||
<div className="flex flex-col sm:flex-row space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="tipo-filter">Tipo de Material</Label>
|
||||
<Select value={filtros.tipo} onValueChange={(value) => setFiltros(prev => ({ ...prev, tipo: value === 'todos' ? '' : value }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos os Tipos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos os Tipos</SelectItem>
|
||||
{tiposMateriais.map((tipo) => (
|
||||
<SelectItem key={tipo.id} value={tipo.nome}>
|
||||
{tipo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="busca-filter">Buscar Material</Label>
|
||||
<Input
|
||||
id="busca-filter"
|
||||
placeholder="Digite para buscar..."
|
||||
value={filtros.busca}
|
||||
onChange={(e) => setFiltros(prev => ({ ...prev, busca: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seleção de Material */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="material">Material *</Label>
|
||||
<Select value={selectedMaterial} onValueChange={setSelectedMaterial}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione um material" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingMateriais ? (
|
||||
<SelectItem value="loading" disabled>Carregando materiais...</SelectItem>
|
||||
) : materiais.length === 0 ? (
|
||||
<SelectItem value="empty" disabled>Nenhum material encontrado</SelectItem>
|
||||
) : (
|
||||
materiais.map((material) => (
|
||||
<SelectItem key={material.id} value={material.id}>
|
||||
{material.codigo} - {material.descricao}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedMaterialData && (
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong>Código:</strong> {selectedMaterialData.codigo}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Unidade:</strong> {selectedMaterialData.unidade}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Tipo:</strong> {selectedMaterialData.tipo_material_nome || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disponível:</strong> {selectedMaterialData.quantidade_disponivel || 0}
|
||||
</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"
|
||||
min="0.01"
|
||||
placeholder="Digite a quantidade"
|
||||
value={quantidade}
|
||||
onChange={(e) => setQuantidade(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="prazo">Prazo de Recebimento *</Label>
|
||||
<Input
|
||||
id="prazo"
|
||||
type="date"
|
||||
value={prazoRecebimento}
|
||||
onChange={(e) => setPrazoRecebimento(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões */}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
disabled={!selectedMaterial || !quantidade || !prazoRecebimento}
|
||||
>
|
||||
{editingItem ? 'Atualizar Item' : 'Adicionar Item'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
|
||||
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 { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useSolicitacoesCompra } from '@/hooks/useSolicitacoesCompra';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { SeletorMateriaisModal } from './SeletorMateriaisModal';
|
||||
import { FileUploadSection } from './FileUploadSection';
|
||||
|
||||
interface ItemSolicitacao {
|
||||
material_id: string;
|
||||
quantidade: number;
|
||||
prazo_recebimento: string;
|
||||
material?: {
|
||||
descricao: string;
|
||||
unidade: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SolicitacaoComprasModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
solicitacao?: any;
|
||||
}
|
||||
|
||||
export function SolicitacaoComprasModal({ isOpen, onClose, solicitacao }: SolicitacaoComprasModalProps) {
|
||||
const [vinculo, setVinculo] = useState<'of' | 'objetivo'>('of');
|
||||
const [formData, setFormData] = useState({
|
||||
data_solicitacao: new Date().toISOString().split('T')[0],
|
||||
of_number: '',
|
||||
objetivo: '',
|
||||
justificativa: '',
|
||||
});
|
||||
|
||||
const [itens, setItens] = useState<ItemSolicitacao[]>([]);
|
||||
const [isSeletorOpen, setIsSeletorOpen] = useState(false);
|
||||
const [anexosFiles, setAnexosFiles] = useState<File[]>([]);
|
||||
const [anexosUrls, setAnexosUrls] = useState<string[]>([]);
|
||||
|
||||
const { createSolicitacao, isCreating } = useSolicitacoesCompra();
|
||||
const { ofs, loading } = useOFs();
|
||||
|
||||
useEffect(() => {
|
||||
if (solicitacao) {
|
||||
setFormData({
|
||||
data_solicitacao: solicitacao.data_solicitacao,
|
||||
of_number: solicitacao.of_number || '',
|
||||
objetivo: solicitacao.objetivo || '',
|
||||
justificativa: solicitacao.justificativa || '',
|
||||
});
|
||||
setVinculo(solicitacao.of_number ? 'of' : 'objetivo');
|
||||
setItens(solicitacao.itens || []);
|
||||
setAnexosUrls(solicitacao.anexos_urls || []);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}, [solicitacao]);
|
||||
|
||||
const resetForm = () => {
|
||||
setVinculo('of');
|
||||
setFormData({
|
||||
data_solicitacao: new Date().toISOString().split('T')[0],
|
||||
of_number: '',
|
||||
objetivo: '',
|
||||
justificativa: '',
|
||||
});
|
||||
setItens([]);
|
||||
setAnexosFiles([]);
|
||||
setAnexosUrls([]);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.justificativa.trim()) {
|
||||
toast.error('Justificativa é obrigatória');
|
||||
return;
|
||||
}
|
||||
|
||||
if (vinculo === 'of' && !formData.of_number.trim()) {
|
||||
toast.error('Selecione uma OF');
|
||||
return;
|
||||
}
|
||||
|
||||
if (vinculo === 'objetivo' && !formData.objetivo.trim()) {
|
||||
toast.error('Informe o objetivo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (itens.length === 0) {
|
||||
toast.error('Adicione pelo menos um item à solicitação');
|
||||
return;
|
||||
}
|
||||
|
||||
const solicitacaoData = {
|
||||
...formData,
|
||||
of_number: vinculo === 'of' ? formData.of_number : null,
|
||||
objetivo: vinculo === 'objetivo' ? formData.objetivo : null,
|
||||
itens: itens.map(item => ({
|
||||
material_id: item.material_id,
|
||||
quantidade: item.quantidade,
|
||||
prazo_recebimento: item.prazo_recebimento,
|
||||
})),
|
||||
anexos_urls: anexosUrls,
|
||||
};
|
||||
|
||||
createSolicitacao(solicitacaoData);
|
||||
onClose();
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const adicionarItem = (item: {
|
||||
material_id: string;
|
||||
descricao: string;
|
||||
unidade: string;
|
||||
quantidade: number;
|
||||
prazo_recebimento: string;
|
||||
}) => {
|
||||
const novoItem: ItemSolicitacao = {
|
||||
material_id: item.material_id,
|
||||
quantidade: item.quantidade,
|
||||
prazo_recebimento: item.prazo_recebimento,
|
||||
material: {
|
||||
descricao: item.descricao,
|
||||
unidade: item.unidade,
|
||||
},
|
||||
};
|
||||
|
||||
setItens([...itens, novoItem]);
|
||||
};
|
||||
|
||||
const removerItem = (index: number) => {
|
||||
setItens(itens.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const atualizarItem = (index: number, campo: string, valor: any) => {
|
||||
const novosItens = [...itens];
|
||||
novosItens[index] = { ...novosItens[index], [campo]: valor };
|
||||
setItens(novosItens);
|
||||
};
|
||||
|
||||
const handleFilesChange = (files: File[], urls: string[]) => {
|
||||
setAnexosFiles(files);
|
||||
setAnexosUrls(urls);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{solicitacao ? 'Editar Solicitação de Compra' : 'Nova Solicitação de Compra'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data_solicitacao">Data da Solicitação</Label>
|
||||
<Input
|
||||
id="data_solicitacao"
|
||||
type="date"
|
||||
value={formData.data_solicitacao}
|
||||
onChange={(e) => setFormData({ ...formData, data_solicitacao: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Vínculo</Label>
|
||||
<RadioGroup
|
||||
value={vinculo}
|
||||
onValueChange={(value: 'of' | 'objetivo') => {
|
||||
setVinculo(value);
|
||||
setFormData({ ...formData, of_number: '', objetivo: '' });
|
||||
}}
|
||||
className="flex space-x-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="of" id="vinculo-of" />
|
||||
<Label htmlFor="vinculo-of">Vincular a uma OF</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="objetivo" id="vinculo-objetivo" />
|
||||
<Label htmlFor="vinculo-objetivo">Objetivo (ADM, Fábrica, Estoque)</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vinculo === 'of' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="of_number">Número da OF</Label>
|
||||
<Select
|
||||
value={formData.of_number}
|
||||
onValueChange={(value) => setFormData({ ...formData, of_number: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.num_of} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo || 'Sem descrição'}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vinculo === 'objetivo' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="objetivo">Objetivo</Label>
|
||||
<Input
|
||||
id="objetivo"
|
||||
value={formData.objetivo}
|
||||
onChange={(e) => setFormData({ ...formData, objetivo: e.target.value })}
|
||||
placeholder="Descreva o objetivo da solicitação"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="justificativa">Justificativa / Comentários *</Label>
|
||||
<Textarea
|
||||
id="justificativa"
|
||||
value={formData.justificativa}
|
||||
onChange={(e) => setFormData({ ...formData, justificativa: e.target.value })}
|
||||
placeholder="Digite a justificativa para a solicitação..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Anexos</Label>
|
||||
</div>
|
||||
<FileUploadSection
|
||||
onFilesChange={handleFilesChange}
|
||||
initialFiles={anexosUrls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Adicione os materiais necessários para a sua solicitação de compra.</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsSeletorOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Adicionar Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{itens.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-12 gap-2 text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
<div className="col-span-4">DESCRIÇÃO DO MATERIAL</div>
|
||||
<div className="col-span-2">QUANTIDADE</div>
|
||||
<div className="col-span-2">UNIDADE</div>
|
||||
<div className="col-span-3">PRAZO DE RECEBIMENTO</div>
|
||||
<div className="col-span-1">AÇÕES</div>
|
||||
</div>
|
||||
{itens.map((item, index) => (
|
||||
<div key={index} className="grid grid-cols-12 gap-2 items-center p-2 border rounded">
|
||||
<div className="col-span-4">
|
||||
<p className="text-sm">{item.material?.descricao}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={item.quantidade}
|
||||
onChange={(e) => atualizarItem(index, 'quantidade', parseFloat(e.target.value) || 0)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm">{item.material?.unidade}</p>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.prazo_recebimento}
|
||||
onChange={(e) => atualizarItem(index, 'prazo_recebimento', e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removerItem(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{isCreating ? 'Salvando...' : solicitacao ? 'Atualizar' : 'Salvar Solicitação'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<SeletorMateriaisModal
|
||||
isOpen={isSeletorOpen}
|
||||
onClose={() => setIsSeletorOpen(false)}
|
||||
onAddItem={adicionarItem}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface SolicitacaoComprasPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
solicitacao: any;
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
'Em planejamento': 'bg-yellow-500',
|
||||
'Revisado': 'bg-orange-500',
|
||||
'Solicitado': 'bg-blue-500',
|
||||
'Comprado': 'bg-green-500',
|
||||
'Recebido': 'bg-lime-500',
|
||||
'Arquivado': 'bg-red-500',
|
||||
};
|
||||
|
||||
export function SolicitacaoComprasPreviewModal({ isOpen, onClose, solicitacao }: SolicitacaoComprasPreviewModalProps) {
|
||||
if (!solicitacao) return null;
|
||||
|
||||
const downloadAnexo = async (url: string) => {
|
||||
try {
|
||||
// Extrair o caminho do arquivo da URL
|
||||
const filePath = url.split('/').slice(-2).join('/'); // pega bucket/path
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('solicitacao-compras-anexos')
|
||||
.download(filePath);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao baixar arquivo:', error);
|
||||
toast.error('Erro ao baixar arquivo');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = url.split('/').pop() || 'anexo';
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(data);
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
toast.success('Arquivo baixado com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao baixar anexo:', error);
|
||||
toast.error('Erro ao baixar arquivo');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="flex flex-row items-center justify-between">
|
||||
<DialogTitle>Preview - Solicitação de Compra {solicitacao.numero_sc}</DialogTitle>
|
||||
<Button onClick={onClose} variant="ghost" size="sm">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Informações básicas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Informações Gerais</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="font-medium">Número SC: </span>
|
||||
{solicitacao.numero_sc}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Data: </span>
|
||||
{format(new Date(solicitacao.data_solicitacao), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Status: </span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${statusColors[solicitacao.status as keyof typeof statusColors]} text-white ml-2`}
|
||||
>
|
||||
{solicitacao.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Revisão: </span>
|
||||
{solicitacao.revisao}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Solicitante</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
imageUrl={solicitacao.creator?.profile_image_url}
|
||||
name={solicitacao.creator?.full_name}
|
||||
email={solicitacao.creator?.email}
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{solicitacao.creator?.full_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{solicitacao.creator?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OF/Objetivo */}
|
||||
{(solicitacao.of_number || solicitacao.objetivo) && (
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h3 className="font-semibold mb-2">
|
||||
{solicitacao.of_number ? 'Ordem de Fabricação' : 'Objetivo'}
|
||||
</h3>
|
||||
<p>{solicitacao.of_number || solicitacao.objetivo}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Justificativa */}
|
||||
{solicitacao.justificativa && (
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Justificativa</h3>
|
||||
<p>{solicitacao.justificativa}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Itens */}
|
||||
{solicitacao.itens && solicitacao.itens.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Itens Solicitados</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Descrição
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Unidade
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Quantidade
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Prazo Recebimento
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background divide-y divide-border">
|
||||
{solicitacao.itens.map((item: any, index: number) => (
|
||||
<tr key={index}>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{item.material?.descricao || 'Material não encontrado'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{item.material?.unidade || 'UN'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{item.quantidade}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{format(new Date(item.prazo_recebimento), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anexos */}
|
||||
{solicitacao.anexos_urls && solicitacao.anexos_urls.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Anexos</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{solicitacao.anexos_urls.map((url: string, index: number) => {
|
||||
const fileName = url.split('/').pop();
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<span className="text-sm truncate">{fileName}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadAnexo(url)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Baixar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user