🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions
@@ -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>
);
};
+415
View File
@@ -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>
);
};
+292
View File
@@ -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>
);
};
+253
View File
@@ -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 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>
);
};
+124
View File
@@ -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>
);
};
+154
View File
@@ -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>
);
};
+162
View File
@@ -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 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>
);
};
+330
View File
@@ -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>
);
};
+234
View File
@@ -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> 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>
);
};
+270
View File
@@ -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>
);
};
+641
View File
@@ -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>
);
};
+130
View File
@@ -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>
);
};
+103
View File
@@ -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>
);
};
+100
View File
@@ -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>
);
};
+134
View File
@@ -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>
);
};