🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Edit } from 'lucide-react';
|
||||
import { ApontamentoPecaObra } from '@/hooks/useApontamentosPecaObra';
|
||||
import { PecaExpedida } from '@/hooks/usePecasExpedidas';
|
||||
|
||||
interface ApontamentoPecasListProps {
|
||||
apontamentos: ApontamentoPecaObra[];
|
||||
pecasExpedidas: PecaExpedida[];
|
||||
onEdit: (apontamento: ApontamentoPecaObra) => void;
|
||||
onDelete: (id: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ApontamentoPecasList: React.FC<ApontamentoPecasListProps> = ({
|
||||
apontamentos,
|
||||
pecasExpedidas,
|
||||
onEdit,
|
||||
onDelete,
|
||||
loading = false
|
||||
}) => {
|
||||
const getPecaInfo = (marcaPeca: string) => {
|
||||
return pecasExpedidas.find(p => p.marca === marcaPeca);
|
||||
};
|
||||
|
||||
const getTotalApontado = (marcaPeca: string) => {
|
||||
return apontamentos
|
||||
.filter(a => a.marca_peca === marcaPeca)
|
||||
.reduce((total, a) => total + a.quantidade, 0);
|
||||
};
|
||||
|
||||
if (apontamentos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
Nenhum apontamento de peça registrado ainda.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Agrupar apontamentos por peça
|
||||
const apontamentosAgrupados = apontamentos.reduce((acc, apontamento) => {
|
||||
const marca = apontamento.marca_peca;
|
||||
if (!acc[marca]) {
|
||||
acc[marca] = [];
|
||||
}
|
||||
acc[marca].push(apontamento);
|
||||
return acc;
|
||||
}, {} as Record<string, ApontamentoPecaObra[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-foreground">Apontamentos de Peças</h3>
|
||||
|
||||
{Object.entries(apontamentosAgrupados).map(([marca, apontamentosPeca]) => {
|
||||
const pecaInfo = getPecaInfo(marca);
|
||||
const totalApontado = getTotalApontado(marca);
|
||||
|
||||
return (
|
||||
<Card key={marca}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{marca}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{pecaInfo && (
|
||||
<>
|
||||
<Badge variant="outline">
|
||||
Expedido: {pecaInfo.quantidade_expedida}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
Apontado: {totalApontado}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pecaInfo.saldo_disponivel > 0 ? "default" : "destructive"}
|
||||
>
|
||||
Saldo: {pecaInfo.saldo_disponivel}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pecaInfo?.descricao && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pecaInfo.descricao}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{apontamentosPeca.map((apontamento) => (
|
||||
<div
|
||||
key={apontamento.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
Quantidade: {apontamento.quantidade}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(apontamento.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(apontamento)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(apontamento.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Users, Cloud, AlertTriangle, Edit, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
useRecursosObra,
|
||||
useCondicoesClimaticas,
|
||||
useMotivosImprodutivos,
|
||||
useCreateRecursoObra,
|
||||
useCreateMotivoImprodutivo,
|
||||
useUpdateMotivoImprodutivo,
|
||||
useDeleteMotivoImprodutivo
|
||||
} from '@/hooks/useObra';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const CadastrosObra: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Cadastros Gerais para RDO</h2>
|
||||
<p className="text-sm text-muted-foreground">Gerencie os cadastros auxiliares do módulo de obra</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="recursos" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="recursos" className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Recursos de Obra
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="clima" className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4" />
|
||||
Condições Climáticas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="motivos" className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Motivos Improdutivos
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recursos">
|
||||
<RecursosObraTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="clima">
|
||||
<CondicoesClimaticasTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="motivos">
|
||||
<MotivosImprodutivoTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RecursosObraTab: React.FC = () => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [novoRecurso, setNovoRecurso] = useState({
|
||||
tipo_recurso: '',
|
||||
nome_recurso: '',
|
||||
descricao: '',
|
||||
});
|
||||
|
||||
const { data: recursos, isLoading } = useRecursosObra();
|
||||
const createRecurso = useCreateRecursoObra();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!novoRecurso.tipo_recurso || !novoRecurso.nome_recurso) {
|
||||
toast.error('Tipo e nome do recurso são obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createRecurso.mutateAsync(novoRecurso);
|
||||
setNovoRecurso({ tipo_recurso: '', nome_recurso: '', descricao: '' });
|
||||
setIsAdding(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar recurso:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">Recursos de Obra</h3>
|
||||
<Button onClick={() => setIsAdding(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Recurso
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>Novo Recurso de Obra</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Tipo de Recurso *</label>
|
||||
<Select
|
||||
value={novoRecurso.tipo_recurso}
|
||||
onValueChange={(value) => setNovoRecurso(prev => ({ ...prev, tipo_recurso: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Mão de Obra">Mão de Obra</SelectItem>
|
||||
<SelectItem value="Equipamento">Equipamento</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Nome do Recurso *</label>
|
||||
<Input
|
||||
value={novoRecurso.nome_recurso}
|
||||
onChange={(e) => setNovoRecurso(prev => ({ ...prev, nome_recurso: e.target.value }))}
|
||||
placeholder="Ex: Montador, Guindaste 50t"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Descrição</label>
|
||||
<Textarea
|
||||
value={novoRecurso.descricao}
|
||||
onChange={(e) => setNovoRecurso(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição opcional do recurso"
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRecurso.isPending}>
|
||||
{createRecurso.isPending ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
{recursos?.map((recurso) => (
|
||||
<Card key={recurso.id} className="py-2">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-sm">{recurso.nome_recurso}</h4>
|
||||
<Badge variant="secondary" className="text-xs">{recurso.tipo_recurso}</Badge>
|
||||
</div>
|
||||
{recurso.descricao && (
|
||||
<p className="text-xs text-muted-foreground">{recurso.descricao}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 p-0">
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 p-0 text-destructive">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{(!recursos || recursos.length === 0) && !isLoading && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">Nenhum recurso cadastrado</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-md mb-4">
|
||||
Cadastre recursos como mão de obra e equipamentos para usar nos RDOs.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CondicoesClimaticasTab: React.FC = () => {
|
||||
const { data: condicoes, isLoading } = useCondicoesClimaticas();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Condições Climáticas</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{condicoes?.map((condicao) => (
|
||||
<Card key={condicao.id} className="py-2">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xl">
|
||||
{condicao.icone === 'sun' && '☀️'}
|
||||
{condicao.icone === 'cloud' && '☁️'}
|
||||
{condicao.icone === 'cloud-rain' && '🌧️'}
|
||||
{condicao.icone === 'wind' && '💨'}
|
||||
{condicao.icone === 'cloud-drizzle' && '🌦️'}
|
||||
</div>
|
||||
<h4 className="font-medium text-sm">{condicao.nome}</h4>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MotivosImprodutivoTab: React.FC = () => {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [editingMotivo, setEditingMotivo] = useState<string | null>(null);
|
||||
const [novoMotivo, setNovoMotivo] = useState({
|
||||
motivo: '',
|
||||
descricao: '',
|
||||
categoria: 'Empresa Montadora' as 'Cliente' | 'Empresa Montadora' | 'Contratada' | 'Terceiros Indiretos',
|
||||
});
|
||||
|
||||
const { data: motivos, isLoading } = useMotivosImprodutivos();
|
||||
const createMotivo = useCreateMotivoImprodutivo();
|
||||
const updateMotivo = useUpdateMotivoImprodutivo();
|
||||
const deleteMotivo = useDeleteMotivoImprodutivo();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!novoMotivo.motivo) {
|
||||
toast.error('O motivo é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingMotivo) {
|
||||
await updateMotivo.mutateAsync({ id: editingMotivo, ...novoMotivo });
|
||||
setEditingMotivo(null);
|
||||
} else {
|
||||
await createMotivo.mutateAsync(novoMotivo);
|
||||
}
|
||||
setNovoMotivo({ motivo: '', descricao: '', categoria: 'Empresa Montadora' });
|
||||
setIsAdding(false);
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar motivo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (motivo: any) => {
|
||||
setNovoMotivo({
|
||||
motivo: motivo.motivo,
|
||||
descricao: motivo.descricao || '',
|
||||
categoria: motivo.categoria,
|
||||
});
|
||||
setEditingMotivo(motivo.id);
|
||||
setIsAdding(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Deseja realmente remover este motivo improdutivo?')) {
|
||||
await deleteMotivo.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
const motivosPorCategoria = motivos?.reduce((acc, motivo) => {
|
||||
if (!acc[motivo.categoria]) {
|
||||
acc[motivo.categoria] = [];
|
||||
}
|
||||
acc[motivo.categoria].push(motivo);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof motivos>) || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">Motivos Improdutivos</h3>
|
||||
<Button onClick={() => setIsAdding(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Motivo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle>{editingMotivo ? 'Editar Motivo' : 'Novo Motivo Improdutivo'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Motivo *</label>
|
||||
<Input
|
||||
value={novoMotivo.motivo}
|
||||
onChange={(e) => setNovoMotivo(prev => ({ ...prev, motivo: e.target.value }))}
|
||||
placeholder="Ex: Chuva, Falta de Material"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Categoria *</label>
|
||||
<Select
|
||||
value={novoMotivo.categoria}
|
||||
onValueChange={(value: any) => setNovoMotivo(prev => ({ ...prev, categoria: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cliente">Cliente</SelectItem>
|
||||
<SelectItem value="Empresa Montadora">Empresa Montadora</SelectItem>
|
||||
<SelectItem value="Contratada">Contratada</SelectItem>
|
||||
<SelectItem value="Terceiros Indiretos">Terceiros Indiretos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Descrição</label>
|
||||
<Textarea
|
||||
value={novoMotivo.descricao}
|
||||
onChange={(e) => setNovoMotivo(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição detalhada do motivo"
|
||||
className="h-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setEditingMotivo(null);
|
||||
setNovoMotivo({ motivo: '', descricao: '', categoria: 'Empresa Montadora' });
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMotivo.isPending || updateMotivo.isPending}>
|
||||
{createMotivo.isPending || updateMotivo.isPending ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(motivosPorCategoria).map(([categoria, motivosCategoria]) => (
|
||||
<div key={categoria}>
|
||||
<h4 className="font-medium text-sm mb-2 text-muted-foreground">{categoria}</h4>
|
||||
<div className="grid gap-2">
|
||||
{motivosCategoria?.map((motivo) => (
|
||||
<Card key={motivo.id} className="py-1">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<h5 className="font-medium text-sm">{motivo.motivo}</h5>
|
||||
{motivo.descricao && (
|
||||
<p className="text-xs text-muted-foreground">{motivo.descricao}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleEdit(motivo)}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
onClick={() => handleDelete(motivo.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(!motivos || motivos.length === 0) && !isLoading && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">Nenhum motivo cadastrado</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-md mb-4">
|
||||
Cadastre motivos improdutivos para classificar paradas de trabalho nos RDOs.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCreateContratoObra, useOFsDisponiveis } from '@/hooks/useObra';
|
||||
|
||||
const contratoSchema = z.object({
|
||||
of_number: z.string().min(1, 'OF é obrigatória'),
|
||||
nome_obra: z.string().optional(),
|
||||
cliente: z.string().optional(),
|
||||
data_inicio_contratual: z.string().optional(),
|
||||
data_termino_prevista: z.string().optional(),
|
||||
status: z.enum(['Ativo', 'Pausado', 'Concluído']).default('Ativo'),
|
||||
});
|
||||
|
||||
type ContratoFormData = z.infer<typeof contratoSchema>;
|
||||
|
||||
interface ContratoObraModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ContratoObraModal: React.FC<ContratoObraModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const form = useForm<ContratoFormData>({
|
||||
resolver: zodResolver(contratoSchema),
|
||||
defaultValues: {
|
||||
status: 'Ativo',
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ofsDisponiveis, isLoading: isLoadingOFs } = useOFsDisponiveis();
|
||||
const createContrato = useCreateContratoObra();
|
||||
|
||||
const onSubmit = async (data: ContratoFormData) => {
|
||||
const ofSelecionada = ofsDisponiveis?.find(of => of.num_of === data.of_number);
|
||||
|
||||
const contratoData = {
|
||||
of_number: data.of_number,
|
||||
nome_obra: data.nome_obra || ofSelecionada?.descritivo || null,
|
||||
cliente: data.cliente || null,
|
||||
data_inicio_contratual: data.data_inicio_contratual || null,
|
||||
data_termino_prevista: data.data_termino_prevista || ofSelecionada?.data_prazo || null,
|
||||
status: data.status as 'Ativo' | 'Pausado' | 'Concluído',
|
||||
};
|
||||
|
||||
try {
|
||||
await createContrato.mutateAsync(contratoData);
|
||||
form.reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar contrato:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOFChange = (ofNumber: string) => {
|
||||
const ofSelecionada = ofsDisponiveis?.find(of => of.num_of === ofNumber);
|
||||
if (ofSelecionada) {
|
||||
form.setValue('nome_obra', ofSelecionada.descritivo || '');
|
||||
form.setValue('data_termino_prevista', ofSelecionada.data_prazo || '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Obra</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="of_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ordem de Fabricação *</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleOFChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
disabled={isLoadingOFs}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isLoadingOFs ? "Carregando..." : "Selecione uma OF"} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ofsDisponiveis?.map((of) => (
|
||||
<SelectItem key={of.num_of} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nome_obra"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome da Obra</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Nome da obra" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cliente"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cliente</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Nome do cliente" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data_inicio_contratual"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Data de Início</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(new Date(field.value), "PPP", { locale: ptBR })
|
||||
) : (
|
||||
<span>Selecione uma data</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value ? new Date(field.value) : undefined}
|
||||
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
|
||||
disabled={(date) =>
|
||||
date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data_termino_prevista"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Data de Término</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(new Date(field.value), "PPP", { locale: ptBR })
|
||||
) : (
|
||||
<span>Selecione uma data</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value ? new Date(field.value) : undefined}
|
||||
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
|
||||
disabled={(date) =>
|
||||
date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Ativo">Ativo</SelectItem>
|
||||
<SelectItem value="Pausado">Pausado</SelectItem>
|
||||
<SelectItem value="Concluído">Concluído</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createContrato.isPending}
|
||||
>
|
||||
{createContrato.isPending ? 'Criando...' : 'Criar Obra'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, FileText, Edit, Eye, Calendar, MapPin, Thermometer } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useContratosObra, useDiariosObra } from '@/hooks/useObra';
|
||||
import { RDOWizardModal } from './RDOWizardModal';
|
||||
import { RDODetailView } from './RDODetailView';
|
||||
|
||||
interface DiarioObraRDOProps {
|
||||
obraAtual: string | null;
|
||||
}
|
||||
|
||||
export const DiarioObraRDO: React.FC<DiarioObraRDOProps> = ({ obraAtual }) => {
|
||||
const [showWizardModal, setShowWizardModal] = useState(false);
|
||||
const [showDetailView, setShowDetailView] = useState(false);
|
||||
const [selectedRDO, setSelectedRDO] = useState<string | null>(null);
|
||||
const [rdoParaEdicao, setRdoParaEdicao] = useState<any>(null);
|
||||
|
||||
const { data: contratos } = useContratosObra();
|
||||
const { data: diariosData, isLoading } = useDiariosObra(obraAtual || '');
|
||||
|
||||
const contratoAtual = contratos?.find(c => c.of_number === obraAtual);
|
||||
const podecriarRDO = contratoAtual?.status === 'Ativo';
|
||||
|
||||
if (!obraAtual) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-2">Selecione uma obra</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Para acessar o Diário de Obra (RDO), primeiro selecione uma obra no Dashboard.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDetailRDO = (rdoId: string) => {
|
||||
setSelectedRDO(rdoId);
|
||||
setShowDetailView(true);
|
||||
};
|
||||
|
||||
const handleEditRDO = (rdoId: string) => {
|
||||
const rdo = diariosData?.find(r => r.id === rdoId);
|
||||
setRdoParaEdicao(rdo);
|
||||
setShowWizardModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowWizardModal(false);
|
||||
setShowDetailView(false);
|
||||
setRdoParaEdicao(null);
|
||||
setSelectedRDO(null);
|
||||
};
|
||||
|
||||
const handleCreateRDO = () => {
|
||||
if (!podecriarRDO) {
|
||||
return;
|
||||
}
|
||||
setShowWizardModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground">
|
||||
Diário de Obra - {obraAtual}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie os Relatórios Diários de Obra (RDO)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateRDO}
|
||||
disabled={!podecriarRDO}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo RDO
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status Warning */}
|
||||
{!podecriarRDO && (
|
||||
<Card className="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-300">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-sm">
|
||||
RDOs só podem ser criados quando a obra estiver com status "Ativo"
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">Carregando RDOs...</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* RDO List */}
|
||||
{diariosData && diariosData.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{diariosData.map((rdo) => (
|
||||
<Card key={rdo.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">
|
||||
{rdo.numero_rdo || `RDO - ${format(new Date(rdo.data), 'PPP', { locale: ptBR })}`}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{format(new Date(rdo.data), 'PPP', { locale: ptBR })}
|
||||
</div>
|
||||
{rdo.condicoes_climaticas && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{rdo.condicoes_climaticas.icone === 'sun' && '☀️'}
|
||||
{rdo.condicoes_climaticas.icone === 'cloud' && '☁️'}
|
||||
{rdo.condicoes_climaticas.icone === 'cloud-rain' && '🌧️'}
|
||||
{rdo.condicoes_climaticas.icone === 'wind' && '💨'}
|
||||
{rdo.condicoes_climaticas.icone === 'cloud-drizzle' && '🌦️'}
|
||||
</span>
|
||||
{rdo.condicoes_climaticas.nome}
|
||||
</div>
|
||||
)}
|
||||
{rdo.hora_inicio && rdo.hora_fim && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>🕐</span>
|
||||
{rdo.hora_inicio} - {rdo.hora_fim}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rdo.usuario_nome && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Responsável: <span className="font-medium">{rdo.usuario_nome}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={rdo.finalizado ? "default" : "secondary"}>
|
||||
{rdo.finalizado ? "Finalizado" : "Em andamento"}
|
||||
</Badge>
|
||||
{rdo.sincronizado && (
|
||||
<Badge variant="outline">
|
||||
Sincronizado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{rdo.condicoes_climaticas && (
|
||||
<div className="mb-3">
|
||||
<span className="text-sm text-muted-foreground">Condição Climática: </span>
|
||||
<span className="text-sm font-medium">{rdo.condicoes_climaticas.nome}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rdo.observacoes_gerais && (
|
||||
<div className="mb-4 p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-1">Observações:</p>
|
||||
<p className="text-sm">{rdo.observacoes_gerais}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDetailRDO(rdo.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Detalhar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditRDO(rdo.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-2">
|
||||
Nenhum RDO encontrado
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md mb-4">
|
||||
{podecriarRDO
|
||||
? "Comece criando seu primeiro Relatório Diário de Obra para esta obra."
|
||||
: "RDOs só podem ser criados quando a obra estiver com status 'Ativo'."
|
||||
}
|
||||
</p>
|
||||
{podecriarRDO && (
|
||||
<Button
|
||||
onClick={handleCreateRDO}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Criar primeiro RDO
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<RDOWizardModal
|
||||
isOpen={showWizardModal}
|
||||
onClose={handleCloseModal}
|
||||
obraAtual={obraAtual}
|
||||
rdoParaEdicao={rdoParaEdicao}
|
||||
/>
|
||||
|
||||
<RDODetailView
|
||||
isOpen={showDetailView}
|
||||
onClose={handleCloseModal}
|
||||
rdoId={selectedRDO}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertTriangle, Edit, Trash2, Clock } from 'lucide-react';
|
||||
import { ApontamentoImprodutivo, calculateDuration } from '@/hooks/useRDOImprodutivos';
|
||||
|
||||
interface ImprodutivosListProps {
|
||||
improdutivos: ApontamentoImprodutivo[];
|
||||
onEdit?: (improdutivo: ApontamentoImprodutivo) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ImproduitvosList: React.FC<ImprodutivosListProps> = ({
|
||||
improdutivos,
|
||||
onEdit,
|
||||
onDelete,
|
||||
loading = false,
|
||||
}) => {
|
||||
if (improdutivos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertTriangle className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">Nenhum tempo improdutivo registrado</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Calcular total de tempo improdutivo
|
||||
const totalMinutos = improdutivos.reduce((acc, item) => {
|
||||
const duracao = calculateDuration(item.hora_inicio, item.hora_fim);
|
||||
const [horas, minutos] = duracao.split(':').map(Number);
|
||||
return acc + (horas * 60) + minutos;
|
||||
}, 0);
|
||||
|
||||
const totalHoras = Math.floor(totalMinutos / 60);
|
||||
const restoMinutos = totalMinutos % 60;
|
||||
const totalFormatado = `${totalHoras}h ${restoMinutos.toString().padStart(2, '0')}m`;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Tempos Improdutivos
|
||||
</CardTitle>
|
||||
<Badge variant="destructive">
|
||||
Total: {totalFormatado}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{improdutivos.map((improdutivo) => (
|
||||
<div
|
||||
key={improdutivo.id}
|
||||
className="flex items-start justify-between p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="font-medium">
|
||||
{improdutivo.motivos_improdutivos?.motivo}
|
||||
</h5>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{improdutivo.motivos_improdutivos?.categoria}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{improdutivo.motivos_improdutivos?.descricao && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{improdutivo.motivos_improdutivos.descricao}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{improdutivo.hora_inicio} - {improdutivo.hora_fim}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Duração: {calculateDuration(improdutivo.hora_inicio, improdutivo.hora_fim)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{improdutivo.descricao && (
|
||||
<div className="text-sm bg-muted/50 p-2 rounded">
|
||||
<p className="text-muted-foreground text-xs mb-1">Descrição:</p>
|
||||
<p>{improdutivo.descricao}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(improdutivo)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(improdutivo.id)}
|
||||
disabled={loading}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, AlertTriangle } from 'lucide-react';
|
||||
import { MotivoImprodutivo } from '@/hooks/useObra';
|
||||
import { calculateDuration } from '@/hooks/useRDOImprodutivos';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ImproduviosSelectorProps {
|
||||
motivos: MotivoImprodutivo[];
|
||||
onSelect: (motivoId: string, horaInicio: string, horaFim: string, descricao?: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const ImprodutivosSelector: React.FC<ImproduviosSelectorProps> = ({
|
||||
motivos,
|
||||
onSelect,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selectedMotivoId, setSelectedMotivoId] = useState<string>('');
|
||||
const [horaInicio, setHoraInicio] = useState<string>('');
|
||||
const [horaFim, setHoraFim] = useState<string>('');
|
||||
const [descricao, setDescricao] = useState<string>('');
|
||||
|
||||
const handleAddImprodutivo = () => {
|
||||
if (!selectedMotivoId) {
|
||||
toast.error('Selecione um motivo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!horaInicio || !horaFim) {
|
||||
toast.error('Informe o horário de início e fim');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar que hora fim é posterior à hora início
|
||||
const inicio = new Date(`2000-01-01T${horaInicio}`);
|
||||
const fim = new Date(`2000-01-01T${horaFim}`);
|
||||
|
||||
if (fim <= inicio) {
|
||||
toast.error('Horário de fim deve ser posterior ao horário de início');
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(selectedMotivoId, horaInicio, horaFim, descricao);
|
||||
setSelectedMotivoId('');
|
||||
setHoraInicio('');
|
||||
setHoraFim('');
|
||||
setDescricao('');
|
||||
};
|
||||
|
||||
const motivosPorCategoria = motivos.reduce((acc, motivo) => {
|
||||
const categoria = motivo.categoria;
|
||||
if (!acc[categoria]) {
|
||||
acc[categoria] = [];
|
||||
}
|
||||
acc[categoria].push(motivo);
|
||||
return acc;
|
||||
}, {} as Record<string, MotivoImprodutivo[]>);
|
||||
|
||||
const duracao = horaInicio && horaFim ? calculateDuration(horaInicio, horaFim) : '';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Adicionar Tempo Improdutivo
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Motivo</Label>
|
||||
<Select
|
||||
value={selectedMotivoId}
|
||||
onValueChange={setSelectedMotivoId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar motivo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(motivosPorCategoria).map(([categoria, motivosCategoria]) => (
|
||||
<div key={categoria}>
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{categoria}
|
||||
</div>
|
||||
{motivosCategoria.map((motivo) => (
|
||||
<SelectItem key={motivo.id} value={motivo.id}>
|
||||
<div>
|
||||
<div className="font-medium">{motivo.motivo}</div>
|
||||
{motivo.descricao && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{motivo.descricao}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Hora Início</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={horaInicio}
|
||||
onChange={(e) => setHoraInicio(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hora Fim</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={horaFim}
|
||||
onChange={(e) => setHoraFim(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Duração</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-muted">
|
||||
<Badge variant="outline">
|
||||
{duracao || '00:00'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Descrição (Opcional)</Label>
|
||||
<Textarea
|
||||
value={descricao}
|
||||
onChange={(e) => setDescricao(e.target.value)}
|
||||
placeholder="Descreva detalhes específicos sobre o tempo improdutivo..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddImprodutivo}
|
||||
disabled={loading || !selectedMotivoId || !horaInicio || !horaFim}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar Tempo Improdutivo
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
|
||||
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 { Building2, Plus, Calendar, Users, FileText } from 'lucide-react';
|
||||
import { useContratosObra } from '@/hooks/useObra';
|
||||
import { ContratoObraModal } from './ContratoObraModal';
|
||||
|
||||
interface ObraDashboardProps {
|
||||
onSelectObra: (ofNumber: string) => void;
|
||||
}
|
||||
|
||||
export const ObraDashboard: React.FC<ObraDashboardProps> = ({ onSelectObra }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { data: contratos, isLoading } = useContratosObra();
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Ativo':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'Pausado':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'Concluído':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Ativo':
|
||||
return 'Em Andamento';
|
||||
case 'Pausado':
|
||||
return 'Pausado';
|
||||
case 'Concluído':
|
||||
return 'Concluída';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-center text-muted-foreground">Carregando obras...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Dashboard de Obras</h2>
|
||||
<Button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Nova Obra
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{contratos?.map((contrato) => (
|
||||
<Card
|
||||
key={contrato.id}
|
||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => onSelectObra(contrato.of_number)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-blue-600" />
|
||||
<CardTitle className="text-lg">{contrato.of_number}</CardTitle>
|
||||
</div>
|
||||
<Badge className={getStatusColor(contrato.status)}>
|
||||
{getStatusDisplay(contrato.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">
|
||||
{contrato.nome_obra || 'Nome não informado'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{contrato.cliente || 'Cliente não informado'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Início:</span>
|
||||
<div className="font-medium">
|
||||
{contrato.data_inicio_contratual
|
||||
? new Date(contrato.data_inicio_contratual).toLocaleDateString('pt-BR')
|
||||
: 'Não definido'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Prazo:</span>
|
||||
<div className="font-medium">
|
||||
{contrato.data_termino_prevista
|
||||
? new Date(contrato.data_termino_prevista).toLocaleDateString('pt-BR')
|
||||
: 'Não definido'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contrato.status === 'Ativo' && (
|
||||
<div className="flex justify-between items-center pt-2 border-t">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>RDO Hoje</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>Relatórios</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{(!contratos || contratos.length === 0) && (
|
||||
<Card className="col-span-full">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Building2 className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-2">Nenhuma obra cadastrada</h3>
|
||||
<p className="text-muted-foreground max-w-md mb-4">
|
||||
Comece criando sua primeira obra para começar a gerenciar os diários de obra (RDO).
|
||||
</p>
|
||||
<Button onClick={() => setIsModalOpen(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Criar primeira obra
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContratoObraModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar, FileText, Users, Building, Clock, AlertTriangle, CheckCircle2, TrendingUp, Camera, Edit } from 'lucide-react';
|
||||
import { useDiariosObra, useContratosObra } from '@/hooks/useObra';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { StatusObraModal } from './StatusObraModal';
|
||||
|
||||
interface ObraSpecificDashboardProps {
|
||||
obraAtual: string;
|
||||
onNavigateToRDO: () => void;
|
||||
onNavigateToRelatorios: () => void;
|
||||
}
|
||||
|
||||
export const ObraSpecificDashboard: React.FC<ObraSpecificDashboardProps> = ({
|
||||
obraAtual,
|
||||
onNavigateToRDO,
|
||||
onNavigateToRelatorios
|
||||
}) => {
|
||||
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
|
||||
const { data: rdos } = useDiariosObra(obraAtual);
|
||||
const { data: contratos } = useContratosObra();
|
||||
const { data: ofs } = useOFs();
|
||||
|
||||
// Buscar dados da obra atual
|
||||
const contratoAtual = contratos?.find(c => c.of_number === obraAtual);
|
||||
const ofAtual = ofs?.find(of => of.num_of === obraAtual);
|
||||
|
||||
const rdosRecentes = rdos?.slice(0, 5) || [];
|
||||
const totalRDOs = rdos?.length || 0;
|
||||
const rdosFinalizados = rdos?.filter(rdo => rdo.finalizado).length || 0;
|
||||
|
||||
// Verificar se pode criar RDO (só se status for "Em Andamento")
|
||||
const canCreateRDO = contratoAtual?.status === 'Ativo';
|
||||
|
||||
// Função para mapear status para exibição
|
||||
const getStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Ativo':
|
||||
return 'Em Andamento';
|
||||
case 'Pausado':
|
||||
return 'Pausada';
|
||||
case 'Concluído':
|
||||
return 'Concluída';
|
||||
case 'Arquivada':
|
||||
return 'Arquivada';
|
||||
default:
|
||||
return 'Aguardando Início';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Ativo':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'Pausado':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'Concluído':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'Arquivada':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
}
|
||||
};
|
||||
|
||||
// Calcular prazo decorrido
|
||||
const calcularPrazoDecorrido = () => {
|
||||
if (!contratoAtual?.data_inicio_contratual || !contratoAtual?.data_termino_prevista) {
|
||||
return { percentual: 0, diasDecorridos: 0, diasRestantes: 0, totalDias: 0 };
|
||||
}
|
||||
|
||||
const dataInicio = new Date(contratoAtual.data_inicio_contratual);
|
||||
const dataTermino = new Date(contratoAtual.data_termino_prevista);
|
||||
const dataAtual = new Date();
|
||||
|
||||
const totalDias = Math.ceil((dataTermino.getTime() - dataInicio.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const diasDecorridos = Math.ceil((dataAtual.getTime() - dataInicio.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const diasRestantes = totalDias - diasDecorridos;
|
||||
const percentual = Math.min(Math.max((diasDecorridos / totalDias) * 100, 0), 100);
|
||||
|
||||
return {
|
||||
percentual: Math.round(percentual),
|
||||
diasDecorridos: Math.max(diasDecorridos, 0),
|
||||
diasRestantes: Math.max(diasRestantes, 0),
|
||||
totalDias
|
||||
};
|
||||
};
|
||||
|
||||
const prazoInfo = calcularPrazoDecorrido();
|
||||
|
||||
const dashboardCards = [
|
||||
{
|
||||
title: "Relatórios",
|
||||
count: totalRDOs.toString(),
|
||||
icon: FileText,
|
||||
color: "text-orange-600",
|
||||
bgColor: "bg-orange-50",
|
||||
onClick: onNavigateToRelatorios
|
||||
},
|
||||
{
|
||||
title: "Atividades",
|
||||
count: "4",
|
||||
icon: Users,
|
||||
color: "text-blue-600",
|
||||
bgColor: "bg-blue-50"
|
||||
},
|
||||
{
|
||||
title: "Ocorrências",
|
||||
count: "2",
|
||||
icon: AlertTriangle,
|
||||
color: "text-yellow-600",
|
||||
bgColor: "bg-yellow-50"
|
||||
},
|
||||
{
|
||||
title: "Comentários",
|
||||
count: "3",
|
||||
icon: FileText,
|
||||
color: "text-gray-600",
|
||||
bgColor: "bg-gray-50"
|
||||
},
|
||||
{
|
||||
title: "Fotos",
|
||||
count: "1",
|
||||
icon: Camera,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-50"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Cards principais */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{dashboardCards.map((card, index) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className={`${card.bgColor} border-0 cursor-pointer hover:shadow-md transition-shadow`}
|
||||
onClick={card.onClick}
|
||||
>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className={`p-2 rounded-full ${card.bgColor}`}>
|
||||
<card.icon className={`w-6 h-6 ${card.color}`} />
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${card.color}`}>
|
||||
{card.count}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{card.title}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Relatórios Recentes */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-orange-600">Relatórios recentes</CardTitle>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-blue-600 p-0"
|
||||
onClick={onNavigateToRDO}
|
||||
disabled={!canCreateRDO}
|
||||
title={!canCreateRDO ? "RDOs só podem ser criados quando a obra estiver 'Em Andamento'" : ""}
|
||||
>
|
||||
Ver tudo
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2 text-sm font-medium text-gray-600 border-b pb-2">
|
||||
<span>Data</span>
|
||||
<span>N°</span>
|
||||
<span>Status</span>
|
||||
<span>Modelo de relatório</span>
|
||||
</div>
|
||||
{rdosRecentes.map((rdo) => (
|
||||
<div key={rdo.id} className="grid grid-cols-4 gap-2 text-sm items-center">
|
||||
<span>{new Date(rdo.data).toLocaleDateString('pt-BR')}</span>
|
||||
<span>{rdo.numero_rdo || '-'}</span>
|
||||
<Badge variant={rdo.finalizado ? "default" : "secondary"} className="text-xs">
|
||||
{rdo.finalizado ? "Aprovado" : "Pendente"}
|
||||
</Badge>
|
||||
<span className="text-xs text-gray-500">Relatório Diário de Obra (RDO)</span>
|
||||
</div>
|
||||
))}
|
||||
{rdosRecentes.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
{!canCreateRDO ?
|
||||
"RDOs só podem ser criados quando a obra estiver 'Em Andamento'" :
|
||||
"Nenhum RDO cadastrado ainda"
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fotos recentes */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-orange-600">Fotos recentes</CardTitle>
|
||||
<Button variant="link" className="text-blue-600 p-0">
|
||||
Ver tudo
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-32 bg-gray-50 rounded-lg">
|
||||
<div className="text-center text-gray-500">
|
||||
<Camera className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="text-sm">Nenhuma foto adicionada ainda</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Informações da obra */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-orange-600 flex items-center gap-2">
|
||||
<Building className="w-5 h-5" />
|
||||
Informações da obra
|
||||
<Button variant="link" className="text-blue-600 p-0 ml-auto">
|
||||
Editar
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Status</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getStatusColor(contratoAtual?.status || '')}>
|
||||
{getStatusDisplay(contratoAtual?.status || '')}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setIsStatusModalOpen(true)}
|
||||
title="Alterar status da obra"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">N° do contrato</div>
|
||||
<div className="font-semibold">{obraAtual}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Prazo decorrido</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${prazoInfo.percentual}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{prazoInfo.percentual}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">Prazo contratual</div>
|
||||
<div className="font-medium">{prazoInfo.totalDias} dias</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">Prazo decorrido</div>
|
||||
<div className="font-medium">{prazoInfo.diasDecorridos} dias</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">Prazo a vencer</div>
|
||||
<div className="font-medium">{prazoInfo.diasRestantes} dias</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mt-6 pt-6 border-t">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Nome da Obra</div>
|
||||
<div className="text-sm">{contratoAtual?.nome_obra || 'Não informado'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Contratante</div>
|
||||
<div className="text-sm">{contratoAtual?.cliente || 'Não informado'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Data início</div>
|
||||
<div className="text-sm">
|
||||
{contratoAtual?.data_inicio_contratual
|
||||
? new Date(contratoAtual.data_inicio_contratual).toLocaleDateString('pt-BR')
|
||||
: ofAtual?.data_abertura
|
||||
? new Date(ofAtual.data_abertura).toLocaleDateString('pt-BR')
|
||||
: 'Não informado'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-1">Previsão de término</div>
|
||||
<div className="text-sm">
|
||||
{contratoAtual?.data_termino_prevista
|
||||
? new Date(contratoAtual.data_termino_prevista).toLocaleDateString('pt-BR')
|
||||
: ofAtual?.data_prazo
|
||||
? new Date(ofAtual.data_prazo).toLocaleDateString('pt-BR')
|
||||
: 'Não informado'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="text-sm text-gray-600 mb-1">Descrição</div>
|
||||
<div className="text-sm">
|
||||
{ofAtual?.descritivo || contratoAtual?.nome_obra || 'Descrição não informada'}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Modal de Status */}
|
||||
{contratoAtual && (
|
||||
<StatusObraModal
|
||||
isOpen={isStatusModalOpen}
|
||||
onClose={() => setIsStatusModalOpen(false)}
|
||||
contratoId={contratoAtual.id}
|
||||
currentStatus={contratoAtual.status || 'Aguardando Inicio'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PecaExpedida } from '@/hooks/usePecasExpedidas';
|
||||
|
||||
interface PecaSelectorProps {
|
||||
pecasDisponiveis: PecaExpedida[];
|
||||
onSelect: (peca: PecaExpedida, quantidade: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const PecaSelector: React.FC<PecaSelectorProps> = ({
|
||||
pecasDisponiveis,
|
||||
onSelect,
|
||||
loading = false
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPeca, setSelectedPeca] = useState<PecaExpedida | null>(null);
|
||||
const [quantidade, setQuantidade] = useState<number>(1);
|
||||
|
||||
const handlePecaSelect = (peca: PecaExpedida) => {
|
||||
setSelectedPeca(peca);
|
||||
setQuantidade(Math.min(1, peca.saldo_disponivel));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddApontamento = () => {
|
||||
if (!selectedPeca || quantidade <= 0) return;
|
||||
|
||||
onSelect(selectedPeca, quantidade);
|
||||
setSelectedPeca(null);
|
||||
setQuantidade(1);
|
||||
};
|
||||
|
||||
const isValidQuantidade = selectedPeca
|
||||
? quantidade > 0 && quantidade <= selectedPeca.saldo_disponivel
|
||||
: false;
|
||||
|
||||
if (pecasDisponiveis.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
Nenhuma peça disponível para apontamento.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Verifique se há peças expedidas para esta OF que ainda não foram totalmente apontadas.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-card">
|
||||
<h3 className="font-medium text-foreground">Adicionar Apontamento de Peça</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="peca-selector">Peça</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
disabled={loading}
|
||||
>
|
||||
{selectedPeca
|
||||
? `${selectedPeca.marca} (Saldo: ${selectedPeca.saldo_disponivel})`
|
||||
: "Selecionar peça..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar peça..." />
|
||||
<CommandEmpty>Nenhuma peça encontrada.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{pecasDisponiveis.map((peca) => (
|
||||
<CommandItem
|
||||
key={peca.id}
|
||||
value={peca.marca}
|
||||
onSelect={() => handlePecaSelect(peca)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedPeca?.id === peca.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{peca.marca}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expedido: {peca.quantidade_expedida} |
|
||||
Apontado: {peca.quantidade_ja_apontada} |
|
||||
Saldo: {peca.saldo_disponivel}
|
||||
</span>
|
||||
{peca.descricao && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{peca.descricao}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantidade">Quantidade</Label>
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
value={quantidade}
|
||||
onChange={(e) => setQuantidade(Number(e.target.value))}
|
||||
min={1}
|
||||
max={selectedPeca?.saldo_disponivel || 1}
|
||||
disabled={!selectedPeca || loading}
|
||||
className={cn(
|
||||
!isValidQuantidade && selectedPeca && "border-destructive"
|
||||
)}
|
||||
/>
|
||||
{selectedPeca && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Máximo: {selectedPeca.saldo_disponivel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddApontamento}
|
||||
disabled={!selectedPeca || !isValidQuantidade || loading}
|
||||
className="w-full"
|
||||
>
|
||||
Adicionar Apontamento
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Printer, Download } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface RDODetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
rdoId: string;
|
||||
}
|
||||
|
||||
export const RDODetailModal: React.FC<RDODetailModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
rdoId,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('geral');
|
||||
|
||||
const { data: rdo, isLoading } = useQuery({
|
||||
queryKey: ['rdo_detail', rdoId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('diario_obra_rdo')
|
||||
.select(`
|
||||
*,
|
||||
condicoes_climaticas:condicao_climatica_id(*)
|
||||
`)
|
||||
.eq('id', rdoId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!rdoId,
|
||||
});
|
||||
|
||||
const { data: apontamentosPecas } = useQuery({
|
||||
queryKey: ['apontamentos_peca_obra', rdoId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('apontamentos_peca_obra')
|
||||
.select('*')
|
||||
.eq('rdo_id', rdoId);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!rdoId,
|
||||
});
|
||||
|
||||
const { data: apontamentosRecursos } = useQuery({
|
||||
queryKey: ['apontamentos_recursos_obra', rdoId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('apontamentos_recursos_obra')
|
||||
.select(`
|
||||
*,
|
||||
recursos_obra:recurso_id(*)
|
||||
`)
|
||||
.eq('rdo_id', rdoId);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!rdoId,
|
||||
});
|
||||
|
||||
const { data: apontamentosImprodutivos } = useQuery({
|
||||
queryKey: ['apontamentos_improdutivos', rdoId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('apontamentos_improdutivos')
|
||||
.select(`
|
||||
*,
|
||||
motivos_improdutivos:motivo_id(*)
|
||||
`)
|
||||
.eq('rdo_id', rdoId);
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
enabled: !!rdoId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
Carregando RDO...
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rdo) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<DialogTitle className="text-xl">
|
||||
{rdo.numero_rdo || `RDO - ${format(new Date(rdo.data), 'PPP', { locale: ptBR })}`}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
OF: {rdo.of_number} • Responsável: {rdo.usuario_nome || 'Não informado'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Printer className="w-4 h-4 mr-2" />
|
||||
Imprimir
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="geral">Informações Gerais</TabsTrigger>
|
||||
<TabsTrigger value="pecas">Apontamento de Peças</TabsTrigger>
|
||||
<TabsTrigger value="recursos">Recursos Humanos</TabsTrigger>
|
||||
<TabsTrigger value="improdutivos">Tempo Improdutivo</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="geral" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Dados Climáticos e Observações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Data</label>
|
||||
<p className="font-medium">{format(new Date(rdo.data), 'PPP', { locale: ptBR })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Temperatura</label>
|
||||
<p className="font-medium">
|
||||
{rdo.temperatura_aproximada ? `${rdo.temperatura_aproximada}°C` : 'Não informado'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Condição Climática</label>
|
||||
<p className="font-medium">
|
||||
{rdo.condicoes_climaticas?.nome || 'Não informado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rdo.observacoes_gerais && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Observações Gerais</label>
|
||||
<div className="mt-2 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm">{rdo.observacoes_gerais}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={rdo.finalizado ? "default" : "secondary"}>
|
||||
{rdo.finalizado ? 'Finalizado' : 'Em Andamento'}
|
||||
</Badge>
|
||||
<Badge variant={rdo.sincronizado ? "default" : "outline"}>
|
||||
{rdo.sincronizado ? 'Sincronizado' : 'Não Sincronizado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pecas" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Apontamento de Peças</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apontamentosPecas && apontamentosPecas.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{apontamentosPecas.map((apontamento) => (
|
||||
<div key={apontamento.id} className="p-3 border rounded-md">
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Marca:</span>
|
||||
<p>{apontamento.marca_peca}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Quantidade:</span>
|
||||
<p>{apontamento.quantidade}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Período:</span>
|
||||
<p>{apontamento.periodo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Status:</span>
|
||||
<Badge variant="outline">{apontamento.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
Nenhum apontamento de peça registrado
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recursos" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recursos Humanos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apontamentosRecursos && apontamentosRecursos.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{apontamentosRecursos.map((apontamento) => (
|
||||
<div key={apontamento.id} className="p-3 border rounded-md">
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Recurso:</span>
|
||||
<p>{apontamento.recursos_obra?.nome_recurso}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Tipo:</span>
|
||||
<p>{apontamento.recursos_obra?.tipo_recurso}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Horas Trabalhadas:</span>
|
||||
<p>{apontamento.horas_trabalhadas}h</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
Nenhum recurso registrado
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="improdutivos" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tempos Improdutivos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apontamentosImprodutivos && apontamentosImprodutivos.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{apontamentosImprodutivos.map((apontamento) => (
|
||||
<div key={apontamento.id} className="p-3 border rounded-md">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm mb-2">
|
||||
<div>
|
||||
<span className="font-medium">Motivo:</span>
|
||||
<p>{apontamento.motivos_improdutivos?.motivo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Categoria:</span>
|
||||
<Badge variant="outline">
|
||||
{apontamento.motivos_improdutivos?.categoria}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Início:</span>
|
||||
<p>{apontamento.hora_inicio}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Fim:</span>
|
||||
<p>{apontamento.hora_fim}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Duração:</span>
|
||||
<p>{String(apontamento.duracao_total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{apontamento.descricao && (
|
||||
<div className="mt-2">
|
||||
<span className="font-medium text-sm">Descrição:</span>
|
||||
<p className="text-sm text-muted-foreground">{apontamento.descricao}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-4">
|
||||
Nenhum tempo improdutivo registrado
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, Thermometer, FileText, Package } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { useDiariosObra } from '@/hooks/useObra';
|
||||
import { useApontamentosPecaObra } from '@/hooks/useApontamentosPecaObra';
|
||||
import { usePecasExpedidas } from '@/hooks/usePecasExpedidas';
|
||||
|
||||
interface RDODetailViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
rdoId: string | null;
|
||||
}
|
||||
|
||||
export const RDODetailView: React.FC<RDODetailViewProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
rdoId
|
||||
}) => {
|
||||
const { data: diariosData } = useDiariosObra();
|
||||
const { data: apontamentosPecas = [] } = useApontamentosPecaObra(rdoId || undefined);
|
||||
|
||||
const rdo = diariosData?.find(r => r.id === rdoId);
|
||||
const { data: pecasExpedidas = [] } = usePecasExpedidas(rdo?.of_number || '');
|
||||
|
||||
if (!rdo) return null;
|
||||
|
||||
// Calcular estatísticas das peças
|
||||
const totalApontadoHoje = apontamentosPecas.reduce((acc, a) => acc + a.quantidade, 0);
|
||||
const totalExpedido = pecasExpedidas.reduce((acc, p) => acc + p.quantidade_expedida, 0);
|
||||
const totalJaApontado = pecasExpedidas.reduce((acc, p) => acc + p.quantidade_ja_apontada, 0);
|
||||
const saldoDisponivel = pecasExpedidas.reduce((acc, p) => acc + p.saldo_disponivel, 0);
|
||||
|
||||
// Agrupar apontamentos por peça para exibição detalhada
|
||||
const apontamentosAgrupados = apontamentosPecas.reduce((acc, apontamento) => {
|
||||
const marca = apontamento.marca_peca;
|
||||
if (!acc[marca]) {
|
||||
acc[marca] = [];
|
||||
}
|
||||
acc[marca].push(apontamento);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof apontamentosPecas>);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
RDO {rdo.numero_rdo} - OF {rdo.of_number}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detalhes do Relatório Diário de Obra
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Informações Gerais */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Informações Gerais</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Data</p>
|
||||
<p className="font-medium">
|
||||
{format(new Date(rdo.data), 'PPP', { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rdo.hora_inicio && rdo.hora_fim && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 text-muted-foreground">🕐</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Horário de Trabalho</p>
|
||||
<p className="font-medium">{rdo.hora_inicio} - {rdo.hora_fim}</p>
|
||||
{rdo.total_horas_trabalhadas && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total: {rdo.total_horas_trabalhadas}h
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rdo.condicoes_climaticas && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 text-muted-foreground">☀️</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Condição Climática</p>
|
||||
<p className="font-medium">{rdo.condicoes_climaticas.nome}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rdo.observacoes_gerais && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">Observações Gerais</p>
|
||||
<p className="text-sm bg-muted p-3 rounded-lg">{rdo.observacoes_gerais}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge variant={rdo.finalizado ? "default" : "secondary"}>
|
||||
{rdo.finalizado ? "Finalizado" : "Em andamento"}
|
||||
</Badge>
|
||||
<Badge variant={rdo.sincronizado ? "default" : "outline"}>
|
||||
{rdo.sincronizado ? "Sincronizado" : "Não sincronizado"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resumo de Peças */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
Resumo de Peças
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{totalExpedido}</div>
|
||||
<p className="text-xs text-muted-foreground">Total Expedido</p>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{totalJaApontado}</div>
|
||||
<p className="text-xs text-muted-foreground">Total Apontado (Geral)</p>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-primary">{totalApontadoHoje}</div>
|
||||
<p className="text-xs text-muted-foreground">Apontado Hoje</p>
|
||||
</div>
|
||||
<div className="text-center p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">{saldoDisponivel}</div>
|
||||
<p className="text-xs text-muted-foreground">Saldo Disponível</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detalhes por peça */}
|
||||
{Object.keys(apontamentosAgrupados).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Peças Apontadas Hoje</h4>
|
||||
{Object.entries(apontamentosAgrupados).map(([marca, apontamentos]) => {
|
||||
const totalMarca = apontamentos.reduce((acc, a) => acc + a.quantidade, 0);
|
||||
const pecaInfo = pecasExpedidas.find(p => p.marca === marca);
|
||||
|
||||
return (
|
||||
<div key={marca} className="border rounded-lg p-4 bg-muted/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h5 className="font-medium">{marca}</h5>
|
||||
<Badge variant="outline">
|
||||
Total: {totalMarca} peças
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{pecaInfo && (
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-3">
|
||||
<span>Expedido: {pecaInfo.quantidade_expedida}</span>
|
||||
<span>Já apontado: {pecaInfo.quantidade_ja_apontada}</span>
|
||||
<span>Saldo: {pecaInfo.saldo_disponivel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{apontamentos.map((apontamento, index) => (
|
||||
<div key={apontamento.id} className="flex justify-between text-sm">
|
||||
<span>Apontamento #{index + 1}</span>
|
||||
<span className="font-medium">{apontamento.quantidade} peças</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhuma peça foi apontada neste RDO</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Informações do Sistema */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Informações do Sistema</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Responsável:</span>
|
||||
<span className="ml-2 font-medium">{rdo.usuario_nome || 'Não informado'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Criado em:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{format(new Date(rdo.created_at), 'PPp', { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
{rdo.updated_at !== rdo.created_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Última modificação:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{format(new Date(rdo.updated_at), 'PPp', { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCreateDiarioObra, useUpdateDiarioObra, useCondicoesClimaticas, useDiariosObra } from '@/hooks/useObra';
|
||||
|
||||
const rdoSchema = z.object({
|
||||
data: z.string().min(1, 'Data é obrigatória'),
|
||||
condicao_climatica_id: z.string().optional(),
|
||||
temperatura_aproximada: z.number().optional(),
|
||||
observacoes_gerais: z.string().optional(),
|
||||
});
|
||||
|
||||
type RDOFormData = z.infer<typeof rdoSchema>;
|
||||
|
||||
interface RDOFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
obraAtual: string;
|
||||
editingRDOId?: string | null;
|
||||
}
|
||||
|
||||
export const RDOFormModal: React.FC<RDOFormModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
obraAtual,
|
||||
editingRDOId,
|
||||
}) => {
|
||||
const form = useForm<RDOFormData>({
|
||||
resolver: zodResolver(rdoSchema),
|
||||
defaultValues: {
|
||||
data: format(new Date(), 'yyyy-MM-dd'),
|
||||
},
|
||||
});
|
||||
|
||||
const { data: condicoes } = useCondicoesClimaticas();
|
||||
const { data: rdos } = useDiariosObra(obraAtual);
|
||||
const createRDO = useCreateDiarioObra();
|
||||
const updateRDO = useUpdateDiarioObra();
|
||||
|
||||
const editingRDO = editingRDOId ? rdos?.find(rdo => rdo.id === editingRDOId) : null;
|
||||
const isEditing = !!editingRDO;
|
||||
|
||||
// Carregar dados para edição
|
||||
useEffect(() => {
|
||||
if (editingRDO) {
|
||||
form.reset({
|
||||
data: editingRDO.data,
|
||||
condicao_climatica_id: editingRDO.condicao_climatica_id || undefined,
|
||||
temperatura_aproximada: editingRDO.temperatura_aproximada || undefined,
|
||||
observacoes_gerais: editingRDO.observacoes_gerais || undefined,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
data: format(new Date(), 'yyyy-MM-dd'),
|
||||
});
|
||||
}
|
||||
}, [editingRDO, form]);
|
||||
|
||||
const onSubmit = async (data: RDOFormData) => {
|
||||
try {
|
||||
if (isEditing && editingRDO) {
|
||||
await updateRDO.mutateAsync({
|
||||
id: editingRDO.id,
|
||||
condicao_climatica_id: data.condicao_climatica_id || null,
|
||||
temperatura_aproximada: data.temperatura_aproximada || null,
|
||||
observacoes_gerais: data.observacoes_gerais || null,
|
||||
data: data.data,
|
||||
});
|
||||
} else {
|
||||
await createRDO.mutateAsync({
|
||||
of_number: obraAtual,
|
||||
data: data.data,
|
||||
condicao_climatica_id: data.condicao_climatica_id || null,
|
||||
temperatura_aproximada: data.temperatura_aproximada || null,
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
total_horas_trabalhadas: null,
|
||||
observacoes_gerais: data.observacoes_gerais || null,
|
||||
finalizado: false,
|
||||
sincronizado: false,
|
||||
usuario_rdo: null,
|
||||
usuario_nome: null, // Será preenchido automaticamente no hook
|
||||
});
|
||||
}
|
||||
form.reset();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar RDO:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? `Editar RDO - ${obraAtual}` : `Novo RDO - ${obraAtual}`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Data do RDO *</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(new Date(field.value), "PPP", { locale: ptBR })
|
||||
) : (
|
||||
<span>Selecione uma data</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value ? new Date(field.value) : undefined}
|
||||
onSelect={(date) => field.onChange(date ? format(date, 'yyyy-MM-dd') : '')}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="condicao_climatica_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Condição Climática</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{condicoes?.map((condicao) => (
|
||||
<SelectItem key={condicao.id} value={condicao.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
{condicao.icone === 'sun' && '☀️'}
|
||||
{condicao.icone === 'cloud' && '☁️'}
|
||||
{condicao.icone === 'cloud-rain' && '🌧️'}
|
||||
{condicao.icone === 'wind' && '💨'}
|
||||
{condicao.icone === 'cloud-drizzle' && '🌦️'}
|
||||
</span>
|
||||
{condicao.nome}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="temperatura_aproximada"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Temperatura (°C)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Ex: 25"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="observacoes_gerais"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Observações Gerais</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Observações sobre o dia de trabalho..."
|
||||
{...field}
|
||||
className="h-24"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createRDO.isPending || updateRDO.isPending}
|
||||
>
|
||||
{createRDO.isPending || updateRDO.isPending
|
||||
? (isEditing ? 'Salvando...' : 'Criando...')
|
||||
: (isEditing ? 'Salvar' : 'Criar RDO')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,641 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
DiarioObraRDO,
|
||||
useCondicoesClimaticas,
|
||||
useCreateDiarioObra,
|
||||
useUpdateDiarioObra,
|
||||
useCheckExistingRDO,
|
||||
} from '@/hooks/useObra';
|
||||
import { usePecasExpedidas } from '@/hooks/usePecasExpedidas';
|
||||
import {
|
||||
useApontamentosPecaObra,
|
||||
useCreateApontamentoPeca,
|
||||
useUpdateApontamentoPeca,
|
||||
useDeleteApontamentoPeca,
|
||||
NovoApontamentoPeca,
|
||||
} from '@/hooks/useApontamentosPecaObra';
|
||||
import {
|
||||
useRecursosObra,
|
||||
useMotivosImprodutivos,
|
||||
} from '@/hooks/useObra';
|
||||
import {
|
||||
useApontamentosRecursosObra,
|
||||
useCreateApontamentoRecurso,
|
||||
useDeleteApontamentoRecurso,
|
||||
} from '@/hooks/useRDORecursos';
|
||||
import {
|
||||
useApontamentosImprodutivos,
|
||||
useCreateApontamentoImprodutivo,
|
||||
useDeleteApontamentoImprodutivo,
|
||||
} from '@/hooks/useRDOImprodutivos';
|
||||
import { PecaSelector } from './PecaSelector';
|
||||
import { ApontamentoPecasList } from './ApontamentoPecasList';
|
||||
import { RecursoSelector } from './RecursoSelector';
|
||||
import { RecursosList } from './RecursosList';
|
||||
import { ImprodutivosSelector } from './ImprodutivosSelector';
|
||||
import { ImproduitvosList } from './ImproditivosList';
|
||||
|
||||
interface RDOWizardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
obraAtual: string;
|
||||
rdoParaEdicao?: DiarioObraRDO | null;
|
||||
}
|
||||
|
||||
type WizardStep = 'info' | 'pecas' | 'resumo';
|
||||
|
||||
export const RDOWizardModal: React.FC<RDOWizardModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
obraAtual,
|
||||
rdoParaEdicao
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('info');
|
||||
const [formData, setFormData] = useState({
|
||||
data: new Date(),
|
||||
condicao_climatica_id: '',
|
||||
temperatura_aproximada: '',
|
||||
hora_inicio: '',
|
||||
hora_fim: '',
|
||||
observacoes_gerais: '',
|
||||
});
|
||||
|
||||
const { data: condicoesClimaticas } = useCondicoesClimaticas();
|
||||
const { data: recursosObra } = useRecursosObra();
|
||||
const { data: motivosImprodutivos } = useMotivosImprodutivos();
|
||||
const { data: pecasExpedidas = [], isLoading: loadingPecas } = usePecasExpedidas(obraAtual);
|
||||
const createRDO = useCreateDiarioObra();
|
||||
const updateRDO = useUpdateDiarioObra();
|
||||
const checkExistingRDO = useCheckExistingRDO();
|
||||
|
||||
// Estados para peças, recursos e improdutivos (só carrega quando necessário)
|
||||
const [rdoId, setRdoId] = useState<string | null>(rdoParaEdicao?.id || null);
|
||||
const { data: apontamentosPecas = [], refetch: refetchApontamentos } = useApontamentosPecaObra(rdoId);
|
||||
const { data: apontamentosRecursos = [], refetch: refetchRecursos } = useApontamentosRecursosObra(rdoId);
|
||||
const { data: apontamentosImprodutivos = [], refetch: refetchImprodutivos } = useApontamentosImprodutivos(rdoId);
|
||||
|
||||
const createApontamento = useCreateApontamentoPeca();
|
||||
const updateApontamento = useUpdateApontamentoPeca();
|
||||
const deleteApontamento = useDeleteApontamentoPeca();
|
||||
|
||||
const createRecurso = useCreateApontamentoRecurso();
|
||||
const deleteRecurso = useDeleteApontamentoRecurso();
|
||||
|
||||
const createImprodutivo = useCreateApontamentoImprodutivo();
|
||||
const deleteImprodutivo = useDeleteApontamentoImprodutivo();
|
||||
|
||||
const [editingApontamento, setEditingApontamento] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (rdoParaEdicao) {
|
||||
setFormData({
|
||||
data: new Date(rdoParaEdicao.data),
|
||||
condicao_climatica_id: rdoParaEdicao.condicao_climatica_id || '',
|
||||
temperatura_aproximada: rdoParaEdicao.temperatura_aproximada?.toString() || '',
|
||||
hora_inicio: rdoParaEdicao.hora_inicio || '',
|
||||
hora_fim: rdoParaEdicao.hora_fim || '',
|
||||
observacoes_gerais: rdoParaEdicao.observacoes_gerais || '',
|
||||
});
|
||||
setRdoId(rdoParaEdicao.id);
|
||||
} else {
|
||||
setFormData({
|
||||
data: new Date(),
|
||||
condicao_climatica_id: '',
|
||||
temperatura_aproximada: '',
|
||||
hora_inicio: '',
|
||||
hora_fim: '',
|
||||
observacoes_gerais: '',
|
||||
});
|
||||
setRdoId(null);
|
||||
}
|
||||
}, [rdoParaEdicao, isOpen]);
|
||||
|
||||
const steps = [
|
||||
{ id: 'info' as WizardStep, title: 'Informações Gerais', description: 'Dados básicos, recursos e tempos improdutivos' },
|
||||
{ id: 'pecas' as WizardStep, title: 'Apontamento de Peças', description: 'Registro de peças montadas' },
|
||||
{ id: 'resumo' as WizardStep, title: 'Resumo', description: 'Confirmação dos dados' }
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(step => step.id === currentStep);
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep === 'info') {
|
||||
// Validar e salvar RDO se necessário
|
||||
handleSaveRDO().then(() => {
|
||||
setCurrentStep('pecas');
|
||||
});
|
||||
} else if (currentStep === 'pecas') {
|
||||
setCurrentStep('resumo');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevStep = () => {
|
||||
if (currentStep === 'pecas') {
|
||||
setCurrentStep('info');
|
||||
} else if (currentStep === 'resumo') {
|
||||
setCurrentStep('pecas');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRDO = async () => {
|
||||
try {
|
||||
// Calculate total work hours
|
||||
let totalHours = null;
|
||||
if (formData.hora_inicio && formData.hora_fim) {
|
||||
const inicio = new Date(`2000-01-01T${formData.hora_inicio}`);
|
||||
const fim = new Date(`2000-01-01T${formData.hora_fim}`);
|
||||
if (fim > inicio) {
|
||||
totalHours = (fim.getTime() - inicio.getTime()) / (1000 * 60 * 60);
|
||||
}
|
||||
}
|
||||
|
||||
const rdoData = {
|
||||
of_number: obraAtual,
|
||||
data: format(formData.data, 'yyyy-MM-dd'),
|
||||
condicao_climatica_id: formData.condicao_climatica_id || null,
|
||||
temperatura_aproximada: formData.temperatura_aproximada ? parseInt(formData.temperatura_aproximada) : null,
|
||||
hora_inicio: formData.hora_inicio || null,
|
||||
hora_fim: formData.hora_fim || null,
|
||||
total_horas_trabalhadas: totalHours,
|
||||
observacoes_gerais: formData.observacoes_gerais,
|
||||
finalizado: false,
|
||||
sincronizado: false,
|
||||
usuario_rdo: null,
|
||||
usuario_nome: null,
|
||||
};
|
||||
|
||||
if (rdoParaEdicao && rdoId) {
|
||||
// Editando RDO existente
|
||||
const resultado = await updateRDO.mutateAsync({ id: rdoId, ...rdoData });
|
||||
setRdoId(resultado.id);
|
||||
} else {
|
||||
// Verificar se já existe RDO para esta OF e data
|
||||
const existingRDO = await checkExistingRDO.mutateAsync({
|
||||
ofNumber: obraAtual,
|
||||
data: format(formData.data, 'yyyy-MM-dd')
|
||||
});
|
||||
|
||||
if (existingRDO) {
|
||||
// Se já existe, atualizar o existente
|
||||
const resultado = await updateRDO.mutateAsync({ id: existingRDO.id, ...rdoData });
|
||||
setRdoId(existingRDO.id);
|
||||
} else {
|
||||
// Se não existe, criar novo
|
||||
const resultado = await createRDO.mutateAsync(rdoData);
|
||||
setRdoId(resultado.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro detalhado:', error);
|
||||
toast.error('Erro ao salvar RDO');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRecurso = async (recursoId: string, horasTrabalhadas: number) => {
|
||||
if (!rdoId) return;
|
||||
|
||||
try {
|
||||
await createRecurso.mutateAsync({
|
||||
rdo_id: rdoId,
|
||||
recurso_id: recursoId,
|
||||
horas_trabalhadas: horasTrabalhadas
|
||||
});
|
||||
refetchRecursos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao adicionar recurso:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecurso = async (id: string) => {
|
||||
try {
|
||||
await deleteRecurso.mutateAsync(id);
|
||||
refetchRecursos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar recurso:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImprodutivo = async (id: string) => {
|
||||
try {
|
||||
await deleteImprodutivo.mutateAsync(id);
|
||||
refetchImprodutivos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar tempo improdutivo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddImprodutivo = async (motivoId: string, horaInicio: string, horaFim: string, descricao?: string) => {
|
||||
if (!rdoId) return;
|
||||
|
||||
try {
|
||||
await createImprodutivo.mutateAsync({
|
||||
rdo_id: rdoId,
|
||||
motivo_id: motivoId,
|
||||
hora_inicio: horaInicio,
|
||||
hora_fim: horaFim,
|
||||
descricao
|
||||
});
|
||||
refetchImprodutivos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao adicionar tempo improdutivo:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddApontamento = async (peca: any, quantidade: number) => {
|
||||
if (!rdoId) return;
|
||||
|
||||
try {
|
||||
const novoApontamento: NovoApontamentoPeca = {
|
||||
rdo_id: rdoId,
|
||||
marca_peca: peca.marca,
|
||||
quantidade
|
||||
};
|
||||
|
||||
await createApontamento.mutateAsync(novoApontamento);
|
||||
refetchApontamentos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao adicionar apontamento:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditApontamento = (apontamento: any) => {
|
||||
setEditingApontamento(apontamento);
|
||||
};
|
||||
|
||||
const handleDeleteApontamento = async (id: string) => {
|
||||
try {
|
||||
await deleteApontamento.mutateAsync(id);
|
||||
refetchApontamentos();
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar apontamento:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
try {
|
||||
if (rdoId) {
|
||||
await updateRDO.mutateAsync({
|
||||
id: rdoId,
|
||||
finalizado: true,
|
||||
sincronizado: true
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error('Erro ao finalizar RDO');
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 'info':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Informações Básicas */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações Básicas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Data do RDO</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"justify-start text-left font-normal",
|
||||
!formData.data && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.data ? format(formData.data, "PPP", { locale: ptBR }) : "Selecionar data"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.data}
|
||||
onSelect={(date) => date && setFormData(prev => ({ ...prev, data: date }))}
|
||||
initialFocus
|
||||
className="p-3 pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Condição Climática</Label>
|
||||
<Select
|
||||
value={formData.condicao_climatica_id}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, condicao_climatica_id: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar condição" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{condicoesClimaticas?.map((condicao) => (
|
||||
<SelectItem key={condicao.id} value={condicao.id}>
|
||||
{condicao.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hora de Início</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.hora_inicio}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, hora_inicio: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Hora de Fim</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.hora_fim}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, hora_fim: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label>Observações Gerais</Label>
|
||||
<Textarea
|
||||
value={formData.observacoes_gerais}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, observacoes_gerais: e.target.value }))}
|
||||
placeholder="Descreva observações importantes sobre o dia..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recursos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<RecursoSelector
|
||||
recursos={recursosObra || []}
|
||||
onSelect={handleAddRecurso}
|
||||
loading={createRecurso.isPending}
|
||||
/>
|
||||
<RecursosList
|
||||
recursos={apontamentosRecursos}
|
||||
onDelete={handleDeleteRecurso}
|
||||
loading={deleteRecurso.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tempos Improdutivos */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ImprodutivosSelector
|
||||
motivos={motivosImprodutivos || []}
|
||||
onSelect={handleAddImprodutivo}
|
||||
loading={createImprodutivo.isPending}
|
||||
/>
|
||||
<ImproduitvosList
|
||||
improdutivos={apontamentosImprodutivos}
|
||||
onDelete={handleDeleteImprodutivo}
|
||||
loading={deleteImprodutivo.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pecas':
|
||||
const pecasComSaldo = pecasExpedidas.filter(p => p.saldo_disponivel > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Total Expedido</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{pecasExpedidas.reduce((acc, p) => acc + p.quantidade_expedida, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">peças</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Total Apontado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{pecasExpedidas.reduce((acc, p) => acc + p.quantidade_ja_apontada, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">peças</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Saldo Disponível</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{pecasExpedidas.reduce((acc, p) => acc + p.saldo_disponivel, 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">peças</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PecaSelector
|
||||
pecasDisponiveis={pecasComSaldo}
|
||||
onSelect={handleAddApontamento}
|
||||
loading={createApontamento.isPending || loadingPecas}
|
||||
/>
|
||||
|
||||
<ApontamentoPecasList
|
||||
apontamentos={apontamentosPecas}
|
||||
pecasExpedidas={pecasExpedidas}
|
||||
onEdit={handleEditApontamento}
|
||||
onDelete={handleDeleteApontamento}
|
||||
loading={deleteApontamento.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'resumo':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Resumo com Recursos e Improdutivos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumo Completo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Data</Label>
|
||||
<p className="font-medium">{format(formData.data, "PPP", { locale: ptBR })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Horário de Trabalho</Label>
|
||||
<p className="font-medium">
|
||||
{formData.hora_inicio && formData.hora_fim
|
||||
? `${formData.hora_inicio} - ${formData.hora_fim}`
|
||||
: 'Não informado'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resumo de recursos e improdutivos */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div className="p-3 bg-muted/30 rounded">
|
||||
<p className="font-medium">Recursos: {apontamentosRecursos.length}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{apontamentosRecursos.reduce((acc, r) => acc + r.horas_trabalhadas, 0)}h total
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/30 rounded">
|
||||
<p className="font-medium">Tempos Improdutivos: {apontamentosImprodutivos.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.observacoes_gerais && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Observações</Label>
|
||||
<p className="text-sm">{formData.observacoes_gerais}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumo de Peças</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-4">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
{apontamentosPecas.reduce((acc, a) => acc + a.quantidade, 0)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">Total de peças apontadas hoje</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{rdoParaEdicao ? 'Editar RDO' : 'Novo RDO'} - OF {obraAtual}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{steps[currentStepIndex]?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium",
|
||||
index <= currentStepIndex
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="ml-2 hidden sm:block">
|
||||
<p className="text-sm font-medium">{step.title}</p>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
"w-8 sm:w-12 h-0.5 mx-2",
|
||||
index < currentStepIndex ? "bg-primary" : "bg-muted"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="min-h-[400px]">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{currentStepIndex < steps.length - 1 ? (
|
||||
<Button
|
||||
onClick={handleNextStep}
|
||||
disabled={createRDO.isPending || updateRDO.isPending || checkExistingRDO.isPending}
|
||||
>
|
||||
{(createRDO.isPending || updateRDO.isPending || checkExistingRDO.isPending) ? 'Salvando...' : 'Próximo'}
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleFinalize}
|
||||
disabled={updateRDO.isPending}
|
||||
>
|
||||
{rdoParaEdicao ? 'Atualizar RDO' : 'Finalizar RDO'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { Plus, Clock } from 'lucide-react';
|
||||
import { RecursoObra } from '@/hooks/useObra';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RecursoSelectorProps {
|
||||
recursos: RecursoObra[];
|
||||
onSelect: (recursoId: string, horasTrabalhadas: number) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const RecursoSelector: React.FC<RecursoSelectorProps> = ({
|
||||
recursos,
|
||||
onSelect,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selectedRecursoId, setSelectedRecursoId] = useState<string>('');
|
||||
const [horasTrabalhadas, setHorasTrabalhadas] = useState<string>('');
|
||||
|
||||
const handleAddRecurso = () => {
|
||||
if (!selectedRecursoId) {
|
||||
toast.error('Selecione um recurso');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!horasTrabalhadas || parseFloat(horasTrabalhadas) <= 0) {
|
||||
toast.error('Informe as horas trabalhadas');
|
||||
return;
|
||||
}
|
||||
|
||||
const horas = parseFloat(horasTrabalhadas);
|
||||
if (horas > 24) {
|
||||
toast.error('Horas trabalhadas não pode exceder 24 horas');
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(selectedRecursoId, horas);
|
||||
setSelectedRecursoId('');
|
||||
setHorasTrabalhadas('');
|
||||
};
|
||||
|
||||
const recursosPorTipo = recursos.reduce((acc, recurso) => {
|
||||
const tipo = recurso.tipo_recurso;
|
||||
if (!acc[tipo]) {
|
||||
acc[tipo] = [];
|
||||
}
|
||||
acc[tipo].push(recurso);
|
||||
return acc;
|
||||
}, {} as Record<string, RecursoObra[]>);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Adicionar Recurso
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recurso</Label>
|
||||
<Select
|
||||
value={selectedRecursoId}
|
||||
onValueChange={setSelectedRecursoId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar recurso" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(recursosPorTipo).map(([tipo, recursosDoTipo]) => (
|
||||
<div key={tipo}>
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{tipo}
|
||||
</div>
|
||||
{recursosDoTipo.map((recurso) => (
|
||||
<SelectItem key={recurso.id} value={recurso.id}>
|
||||
<div>
|
||||
<div className="font-medium">{recurso.nome_recurso}</div>
|
||||
{recurso.descricao && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{recurso.descricao}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Horas Trabalhadas</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
step="0.5"
|
||||
value={horasTrabalhadas}
|
||||
onChange={(e) => setHorasTrabalhadas(e.target.value)}
|
||||
placeholder="Ex: 8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddRecurso}
|
||||
disabled={loading || !selectedRecursoId || !horasTrabalhadas}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar Recurso
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Clock, Edit, Trash2 } from 'lucide-react';
|
||||
import { ApontamentoRecursoObra } from '@/hooks/useRDORecursos';
|
||||
|
||||
interface RecursosListProps {
|
||||
recursos: ApontamentoRecursoObra[];
|
||||
onEdit?: (recurso: ApontamentoRecursoObra) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const RecursosList: React.FC<RecursosListProps> = ({
|
||||
recursos,
|
||||
onEdit,
|
||||
onDelete,
|
||||
loading = false,
|
||||
}) => {
|
||||
if (recursos.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">Nenhum recurso apontado</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalHoras = recursos.reduce((acc, recurso) => acc + recurso.horas_trabalhadas, 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
Recursos Utilizados
|
||||
</CardTitle>
|
||||
<Badge variant="outline">
|
||||
Total: {totalHoras.toFixed(1)}h
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{recursos.map((recurso) => (
|
||||
<div
|
||||
key={recurso.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="font-medium">
|
||||
{recurso.recursos_obra?.nome_recurso}
|
||||
</h5>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{recurso.recursos_obra?.tipo_recurso}
|
||||
</Badge>
|
||||
</div>
|
||||
{recurso.recursos_obra?.descricao && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{recurso.recursos_obra.descricao}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{recurso.horas_trabalhadas}h trabalhadas
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(recurso)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(recurso.id)}
|
||||
disabled={loading}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, Download, Calendar, TrendingUp } from 'lucide-react';
|
||||
|
||||
export const RelatoriosObra: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Relatórios de Obra</h2>
|
||||
<p className="text-sm text-muted-foreground">Gere relatórios consolidados dos RDOs</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Relatório Diário
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gere um resumo do RDO diário em formato PDF com fotos e informações consolidadas.
|
||||
</p>
|
||||
<Button className="w-full flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Gerar Relatório Diário
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Relatório Semanal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Consolidado semanal com total de peças montadas, horas trabalhadas e motivos de parada.
|
||||
</p>
|
||||
<Button className="w-full flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Gerar Relatório Semanal
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Relatório Mensal
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Relatório mensal com gráficos de progresso, análise de produtividade e indicadores.
|
||||
</p>
|
||||
<Button className="w-full flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Gerar Relatório Mensal
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Relatório Customizado
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure um relatório personalizado com período e filtros específicos.
|
||||
</p>
|
||||
<Button className="w-full flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
Configurar Relatório
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<TrendingUp className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold text-card-foreground mb-2">Relatórios em Desenvolvimento</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Os relatórios estão sendo desenvolvidos e estarão disponíveis em breve com funcionalidades completas de exportação e compartilhamento.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUpdateContratoObra } from '@/hooks/useObra';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
const statusSchema = z.object({
|
||||
status: z.enum(['Aguardando Inicio', 'Ativo', 'Pausado', 'Concluído', 'Arquivada']),
|
||||
});
|
||||
|
||||
type StatusFormData = z.infer<typeof statusSchema>;
|
||||
|
||||
interface StatusObraModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
contratoId: string;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export const StatusObraModal: React.FC<StatusObraModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
contratoId,
|
||||
currentStatus,
|
||||
}) => {
|
||||
const form = useForm<StatusFormData>({
|
||||
resolver: zodResolver(statusSchema),
|
||||
defaultValues: {
|
||||
status: currentStatus as any,
|
||||
},
|
||||
});
|
||||
|
||||
const updateContrato = useUpdateContratoObra();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const onSubmit = async (data: StatusFormData) => {
|
||||
try {
|
||||
await updateContrato.mutateAsync({
|
||||
id: contratoId,
|
||||
status: data.status,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar status da obra:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'Aguardando Inicio', label: 'Aguardando Início' },
|
||||
{ value: 'Ativo', label: 'Em Andamento' },
|
||||
{ value: 'Pausado', label: 'Pausada' },
|
||||
{ value: 'Concluído', label: 'Concluída' },
|
||||
];
|
||||
|
||||
// Adicionar opção "Arquivada" apenas para admins
|
||||
if (isAdmin) {
|
||||
statusOptions.push({ value: 'Arquivada', label: 'Arquivada' });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Alterar Status da Obra</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status da Obra</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateContrato.isPending}
|
||||
>
|
||||
{updateContrato.isPending ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user