Restauração do código oficial do GPI-JWT-V3

This commit is contained in:
2026-03-18 21:55:33 +00:00
commit 405d121b0e
208 changed files with 38123 additions and 0 deletions

View File

@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Select } from '../Select';
import { Button } from '../Button';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Project, PaintingScheme } from '../../types';
interface CloneSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
schemeToClone?: PaintingScheme;
}
export const CloneSchemeModal: React.FC<CloneSchemeModalProps> = ({ isOpen, onClose, onSuccess, schemeToClone }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [targetProjectId, setTargetProjectId] = useState('');
useEffect(() => {
if (isOpen) {
api.get('/projects').then(res => {
// Filter out the project where the scheme currently resides
const validProjects = res.data.filter((p: Project) => p.id !== schemeToClone?.projectId);
setProjects(validProjects);
}).catch(err => console.error("Error loading projects", err));
setTargetProjectId('');
}
}, [isOpen, schemeToClone]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!schemeToClone || !targetProjectId) return;
setLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, projectId, ...schemeData } = schemeToClone;
await api.post('/painting-schemes', {
...schemeData,
projectId: targetProjectId,
name: schemeData.name // Keep same name or add suffix? Usually same name is fine for new project.
});
onSuccess();
onClose();
} catch (error) {
console.error('Error cloning scheme', error);
alert('Erro ao clonar esquema');
} finally {
setLoading(false);
}
};
if (!schemeToClone) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} title="Clonar Esquema de Pintura">
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-surface-soft p-4 rounded-lg text-sm text-text-secondary border border-border">
<p className="font-bold text-text-main mb-1">Esquema Original:</p>
<p>{schemeToClone.name}</p>
<p className="text-xs text-text-muted mt-1">{schemeToClone.type} {schemeToClone.manufacturer}</p>
</div>
<Select
name="targetProject"
label="Copiar para a Obra/Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={targetProjectId}
onChange={(e) => setTargetProjectId(e.target.value)}
required
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !targetProjectId}>{loading ? 'Clonando...' : 'Confirmar Cópia'}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,463 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { Trash2, Plus, Box, Calculator } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import { type ApplicationRecord, type Part, type Inspection } from '../../types';
interface CreateControlRecordModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId: string;
initialData?: ApplicationRecord;
availableParts: Part[];
existingRecords?: ApplicationRecord[];
availableBatches?: Inspection[];
}
interface BatchItem {
partId: string;
quantity: number; // This is now WEIGHT in KG
}
export const CreateControlRecordModal: React.FC<CreateControlRecordModalProps> = ({
isOpen, onClose, onSuccess, projectId, initialData, availableParts, existingRecords = [], availableBatches = []
}) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
// Batch Composition State
const [items, setItems] = useState<BatchItem[]>([]);
const [selectedPartId, setSelectedPartId] = useState('');
const [quantity, setQuantity] = useState('');
const [formData, setFormData] = useState({
coatStage: '',
pieceDescription: '',
date: '',
operator: '',
realWeight: '',
volumeUsed: '',
areaPainted: '',
wetThicknessAvg: '',
dryThicknessCalc: '',
method: '',
diluentUsed: '',
notes: ''
});
useEffect(() => {
if (initialData) {
setFormData({
coatStage: initialData.coatStage || '',
pieceDescription: initialData.pieceDescription || '',
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
operator: initialData.operator || '',
realWeight: initialData.realWeight?.toString() || '',
volumeUsed: initialData.volumeUsed?.toString() || '',
areaPainted: initialData.areaPainted?.toString() || '',
wetThicknessAvg: initialData.wetThicknessAvg?.toString() || '',
dryThicknessCalc: initialData.dryThicknessCalc?.toString() || '',
method: initialData.method || '',
diluentUsed: initialData.diluentUsed?.toString() || '',
notes: initialData.notes || ''
});
// Use existing items if available
setItems(initialData.items || []);
} else {
setFormData({
coatStage: '',
pieceDescription: '',
date: '',
operator: '',
realWeight: '',
volumeUsed: '',
areaPainted: '',
wetThicknessAvg: '',
dryThicknessCalc: '',
method: '',
diluentUsed: '',
notes: ''
});
setItems([]);
}
setSelectedPartId('');
setQuantity('');
}, [initialData, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const getPartBalance = (partId: string): number => {
const part = availableParts.find(p => p.id === partId);
if (!part) return 0;
// Total estimated weight of the part/lot
const totalEstimatedWeight = part.weight || 0;
// Sum of weight already used in OTHER records (exclude current record if editing)
let usedWeight = 0;
existingRecords.forEach(record => {
// If we are editing, ignore the current record's usage from the "existing" sum
// so we don't double count or block the user from keeping the same value.
if (initialData && record.id === initialData.id) return;
const recordItems = record.items || [];
recordItems.forEach(item => {
if (item.partId === partId) {
usedWeight += item.quantity;
}
});
});
// Also subtract weight currently in the staging list (items) BUT exclude the one we might be adding/editing?
// Actually, `items` state reflects the *current* session.
// If we add multiple chunks of the same part in one session (unlikely but possible), we should sum them up.
// For simple validation of "Next Add", we check: (Used + CurrentItems + NewAmount) <= Total
const currentSessionWeight = items
.filter(i => i.partId === partId)
.reduce((sum, i) => sum + i.quantity, 0);
return totalEstimatedWeight - (usedWeight + currentSessionWeight);
};
const addItem = () => {
if (!selectedPartId || !quantity || Number(quantity) <= 0) return;
const weightToAdd = Number(quantity);
const part = availableParts.find(p => p.id === selectedPartId);
if (!part) return;
// Validation Logic
const balance = getPartBalance(selectedPartId);
// Allow a small margin of error (e.g. 1%) or strict?
// User requested: "se extrapolar ... o sistema nao aceita"
if (weightToAdd > balance) {
alert(`Quantidade excede o saldo disponível para esta peça.\n\nEstimado Total: ${part.weight} kg\nSaldo Disponível: ${balance.toFixed(2)} kg`);
return;
}
const newItem = { partId: selectedPartId, quantity: weightToAdd };
const newItems = [...items, newItem];
setItems(newItems);
// Auto-calc totals
updateTotals(newItems);
setSelectedPartId('');
setQuantity('');
};
const removeItem = (index: number) => {
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
updateTotals(newItems);
};
const updateTotals = (currentItems: BatchItem[]) => {
let totalWeight = 0;
let totalArea = 0;
currentItems.forEach(item => {
const part = availableParts.find(p => p.id === item.partId);
if (part && part.weight && part.weight > 0) {
// Item quantity IS the weight now
const weightUsed = item.quantity;
totalWeight += weightUsed;
// Calculate area proportional to weight used based on part definition
// Area Ratio = Total Area / Total Weight
// Used Area = Weight Used * Ratio
const areaRatio = (part.area || 0) / part.weight;
totalArea += weightUsed * areaRatio;
}
});
setFormData(prev => ({
...prev,
realWeight: totalWeight > 0 ? totalWeight.toFixed(1) : prev.realWeight,
areaPainted: totalArea > 0 ? totalArea.toFixed(1) : prev.areaPainted
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
try {
const payload = {
...formData,
projectId,
realWeight: parseFloat(formData.realWeight),
volumeUsed: parseFloat(formData.volumeUsed),
areaPainted: parseFloat(formData.areaPainted),
wetThicknessAvg: parseFloat(formData.wetThicknessAvg),
dryThicknessCalc: parseFloat(formData.dryThicknessCalc),
diluentUsed: parseFloat(formData.diluentUsed),
items: items.map(i => ({ partId: i.partId, quantity: Number(i.quantity) }))
};
if (initialData) {
await api.put(`/application-records/${initialData.id}`, payload);
} else {
await api.post('/application-records', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving record', error);
alert('Erro ao salvar registro');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Lote de Pintura" : "Novo Lote de Pintura"}>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Batch Identification */}
<div className="p-4 bg-surface-soft rounded-xl border border-border/40 space-y-4">
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-border/20">
<Box className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Identificação do Lote</span>
</div>
{availableBatches && availableBatches.length > 0 ? (
<div className="space-y-2">
<Select
name="batchSelect"
label="Selecionar Lote (Inspeção)"
options={[
{ label: 'Selecione um lote...', value: '' },
...availableBatches.map(b => ({ label: (b.batch || '').split('(')[0].trim(), value: b.id })),
{ label: 'Outro / Manual', value: 'manual' }
]}
onChange={(e) => {
const val = e.target.value;
if (val === 'manual' || val === '') {
setFormData(prev => ({ ...prev, pieceDescription: '' }));
} else {
const selectedBatch = availableBatches.find(b => b.id === val);
if (selectedBatch) {
// Extract "Lote-XX" from "Lote-XX (date...)" if needed, or just use label
// Calculate Avg EPS
let avgEPS = '';
if (selectedBatch.epsPoints && selectedBatch.epsPoints.length > 0) {
// epsPoints are (number | null)[]
const validPoints = selectedBatch.epsPoints.filter(p => p !== null && p > 0) as number[];
if (validPoints.length > 0) {
const sum = validPoints.reduce((a, b) => a + b, 0);
avgEPS = (sum / validPoints.length).toFixed(1);
}
}
setFormData(prev => ({
...prev,
pieceDescription: selectedBatch.batch || '',
dryThicknessCalc: avgEPS
// wetThicknessAvg: ... Need solidsVolume etc.
}));
}
}
}}
value={availableBatches.find(b => b.batch === formData.pieceDescription)?.id || (formData.pieceDescription ? 'manual' : '')}
/>
{((!availableBatches.some(b => b.batch === formData.pieceDescription) && formData.pieceDescription !== '') || (availableBatches.find(b => b.batch === formData.pieceDescription)?.id === undefined && formData.pieceDescription === '') || formData.pieceDescription === '') && (
<div className={availableBatches.some(b => b.batch === formData.pieceDescription) ? 'hidden' : ''}>
<Input
name="pieceDescription"
placeholder="Ou digite manualmente..."
value={formData.pieceDescription}
onChange={handleChange}
/>
</div>
)}
</div>
) : (
<Input
name="pieceDescription"
label="Nome/Número do Lote"
placeholder="Ex: Lote 01/2024 - Estrutura Principal"
value={formData.pieceDescription}
onChange={handleChange}
/>
)}
<div className="grid grid-cols-2 gap-4">
<Select
name="coatStage"
label="Demão Aplicada"
options={[
{ label: 'Primer / Fundo', value: 'primer' },
{ label: 'Intermediária', value: 'intermediate' },
{ label: 'Acabamento', value: 'finish' },
{ label: 'Stripe Coat', value: 'stripe_coat' }
]}
value={formData.coatStage}
onChange={handleChange}
/>
<Input name="date" label="Data de Aplicação" type="date" value={formData.date} onChange={handleChange} />
</div>
</div>
{/* Batch Composition */}
<div className="p-4 bg-primary/5 rounded-xl border border-primary/10 space-y-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Calculator className="w-4 h-4 text-primary" />
<span className="text-xs font-bold text-text-muted uppercase tracking-widest">Composição do Lote</span>
</div>
<span className="text-[10px] text-text-muted bg-white/50 px-2 py-1 rounded-md">
{items.length} itens adicionados
</span>
</div>
<div className="grid grid-cols-[1fr_80px_auto] gap-2 items-end">
<Select
name="partSelector"
label="Adicionar Peça / Geometria"
options={availableParts.map(p => {
// Calculate balance for display if needed, but keeping it simple for select
return {
label: `${p.description} (Total: ${p.weight}kg)`,
value: p.id
};
})}
value={selectedPartId}
onChange={(e) => setSelectedPartId(e.target.value)}
/>
<Input
name="qty"
label="Qtd (Kg)"
type="number"
min="0.1"
step="0.1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Button type="button" onClick={addItem} size="sm" className="h-10 w-10 mb-0.5 px-0" title="Adicionar Item" aria-label="Adicionar Item">
<Plus size={18} />
</Button>
</div>
{/* Items List */}
{items.length > 0 && (
<div className="bg-surface rounded-lg border border-border/40 overflow-hidden max-h-40 overflow-y-auto">
<table className="w-full text-xs text-left">
<thead className="bg-surface-soft text-text-muted font-bold uppercase sticky top-0">
<tr>
<th className="px-3 py-2">Peça</th>
<th className="px-3 py-2 text-center">Peso Lançado</th>
<th className="px-3 py-2 text-right">Área Calc.</th>
<th className="w-8"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
{items.map((item, idx) => {
const part = availableParts.find(p => p.id === item.partId);
if (!part) return null;
const areaRatio = (part.weight && part.weight > 0) ? ((part.area || 0) / part.weight) : 0;
const areaCalculated = item.quantity * areaRatio;
return (
<tr key={idx} className="hover:bg-surface-hover/50">
<td className="px-3 py-2 truncate max-w-[150px]">{part.description}</td>
<td className="px-3 py-2 text-center font-bold text-primary">{item.quantity.toFixed(1)} kg</td>
<td className="px-3 py-2 text-right text-text-muted">
{areaCalculated.toFixed(2)} m²
</td>
<td className="px-3 py-2 text-right">
<button
type="button"
onClick={() => removeItem(idx)}
className="text-text-muted hover:text-error transition-colors"
title="Remover Item"
aria-label="Remover Item"
>
<Trash2 size={12} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Calculated Results / Manual Override */}
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<Input name="areaPainted" label="Área Total (m²)" type="number" step="0.1" value={formData.areaPainted} onChange={handleChange} />
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
</div>
<div className="relative">
<Input name="realWeight" label="Peso Total (kg)" type="number" step="0.1" value={formData.realWeight} onChange={handleChange} />
{items.length > 0 && <div className="absolute top-0 right-0 text-[9px] text-green-600 font-bold bg-green-100 px-1.5 py-0.5 rounded">Calculado</div>}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="volumeUsed" label="Volume de Tinta Gasto (L)" type="number" step="0.1" value={formData.volumeUsed} onChange={handleChange} />
<Input name="diluentUsed" label="Diluente (L)" type="number" step="0.1" value={formData.diluentUsed} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="operator" label="Pintor Responsável" value={formData.operator} onChange={handleChange} />
<Select
name="method"
label="Método de Aplicação"
options={[
{ label: 'Pistola Airless', value: 'airless' },
{ label: 'Pistola Convencional', value: 'conventional' },
{ label: 'Rolo / Trincha', value: 'roller' },
]}
value={formData.method}
onChange={handleChange}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="wetThicknessAvg" label="Esp. Úmida (μm)" type="number" value={formData.wetThicknessAvg} onChange={handleChange} />
<Input name="dryThicknessCalc" label="Esp. Seca Calc (μm)" type="number" value={formData.dryThicknessCalc} onChange={handleChange} />
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Edição' : 'Criar Lote')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,444 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { PaintingInspectionForm } from '../forms/PaintingInspectionForm';
import { SurfaceTreatmentForm } from '../forms/SurfaceTreatmentForm';
import { PhotoUpload } from '../PhotoUpload';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Inspection, ApplicationRecord } from '../../types';
import { Paintbrush, Hammer } from 'lucide-react';
import { clsx } from 'clsx';
import { stockService } from '../../services/stockService';
import type { IInstrument } from '../../types/Instrument';
interface CreateInspectionModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: Inspection;
availableBatches?: ApplicationRecord[];
existingInspections?: Inspection[];
}
interface InspectionFormData {
date: string;
inspector: string;
pieceDescription: string;
appearance: string;
defects: string;
photos: string[];
applicationRecordId: string;
partTemperature: string;
weightKg: string;
instrumentId: string;
// Painting
epsPoints: string[];
adhesionTest: string;
stockItemId: string;
// Surface Treatment
batch: string;
treatmentExecutor: string;
treatmentType: string;
cleaningDegree: string;
roughnessReadings: string[];
flashRust: string;
temperature: string;
relativeHumidity: string;
period: string;
}
const EMPTY_BATCHES: ApplicationRecord[] = [];
const EMPTY_INSPECTIONS: Inspection[] = [];
export const CreateInspectionModal: React.FC<CreateInspectionModalProps> = ({
isOpen, onClose, onSuccess, projectId, initialData, availableBatches = EMPTY_BATCHES, existingInspections = EMPTY_INSPECTIONS
}) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
const [stockItems, setStockItems] = useState<{ _id: string, rrNumber: string, batchNumber: string, quantity: number, unit: string }[]>([]);
const [instruments, setInstruments] = useState<IInstrument[]>([]);
useEffect(() => {
stockService.getAll().then(data => setStockItems(data));
api.get('/instruments').then(res => setInstruments(res.data.filter((i: IInstrument) => i.status === 'active')));
}, []);
const [type, setType] = useState<'painting' | 'surface_treatment'>('painting');
const [formData, setFormData] = useState<InspectionFormData>({
date: '',
inspector: '',
pieceDescription: '',
appearance: '',
defects: '',
photos: [],
applicationRecordId: '',
partTemperature: '',
weightKg: '',
instrumentId: '',
// Painting
epsPoints: Array(20).fill(''),
adhesionTest: '',
stockItemId: '',
// Surface Treatment
batch: '',
treatmentExecutor: '',
treatmentType: '',
cleaningDegree: '',
roughnessReadings: Array(5).fill(''),
flashRust: '',
temperature: '',
relativeHumidity: '',
period: ''
});
useEffect(() => {
if (!projectId) {
api.get('/projects').then(response => {
setProjects(response.data);
}).catch(err => console.error("Error loading projects", err));
} else {
setSelectedProjectId(projectId);
}
}, [projectId, isOpen]);
useEffect(() => {
if (initialData) {
const initialEps = Array(20).fill('');
if (initialData.epsPoints) {
initialData.epsPoints.forEach((p, i) => { if (i < 20) initialEps[i] = p?.toString() || ''; });
}
const initialRoughness = Array(5).fill('');
if (initialData.roughnessReadings) {
initialData.roughnessReadings.forEach((p, i) => { if (i < 5) initialRoughness[i] = p?.toString() || ''; });
}
setType(initialData.type || 'painting');
setFormData({
date: initialData.date ? new Date(initialData.date).toISOString().split('T')[0] : '',
inspector: initialData.inspector || '',
pieceDescription: initialData.pieceDescription || '',
appearance: initialData.appearance || '',
defects: initialData.defects || '',
photos: initialData.photos || [],
applicationRecordId: initialData.applicationRecordId || '',
epsPoints: initialEps,
adhesionTest: initialData.adhesionTest || '',
stockItemId: typeof initialData.stockItemId === 'object' ? initialData.stockItemId._id : (initialData.stockItemId || ''),
batch: initialData.batch || '',
treatmentExecutor: initialData.treatmentExecutor || '',
treatmentType: initialData.treatmentType || '',
cleaningDegree: initialData.cleaningDegree || '',
roughnessReadings: initialRoughness,
flashRust: initialData.flashRust || '',
temperature: initialData.temperature?.toString() || '',
relativeHumidity: initialData.relativeHumidity?.toString() || '',
period: initialData.period || '',
partTemperature: initialData.partTemperature?.toString() || '',
weightKg: initialData.weightKg?.toString() || '',
instrumentId: typeof initialData.instrumentId === 'object' && initialData.instrumentId ? (initialData.instrumentId as IInstrument)._id : (initialData.instrumentId as string || '')
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
// Auto-calculate next batch number logic
let nextBatch = '';
if (existingInspections && existingInspections.length > 0) {
const nums = existingInspections
.map(i => {
const match = (i.batch || '').match(/Lote-(\d+)/i);
return match ? parseInt(match[1]) : 0;
})
.filter(n => n > 0);
const maxNum = nums.length > 0 ? Math.max(...nums) : 0;
nextBatch = `Lote-${String(maxNum + 1).padStart(2, '0')}`;
} else {
nextBatch = 'Lote-01';
}
// Reset form
setType('painting');
setFormData({
date: new Date().toISOString().split('T')[0],
inspector: '',
pieceDescription: '',
appearance: '',
defects: '',
photos: [],
applicationRecordId: '',
epsPoints: Array(20).fill(''),
adhesionTest: '',
stockItemId: '',
batch: nextBatch, // Auto-filled
treatmentExecutor: '',
treatmentType: '',
cleaningDegree: '',
roughnessReadings: Array(5).fill(''),
flashRust: '',
temperature: '',
relativeHumidity: '',
period: '',
partTemperature: '',
weightKg: '',
instrumentId: ''
});
if (projectId) setSelectedProjectId(projectId);
}
// eslint-disable-next-line
}, [initialData, isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleEpsChange = (index: number, value: string) => {
const newPoints = [...formData.epsPoints];
newPoints[index] = value;
setFormData({ ...formData, epsPoints: newPoints });
};
const handleRoughnessChange = (index: number, value: string) => {
const newPoints = [...formData.roughnessReadings];
newPoints[index] = value;
setFormData({ ...formData, roughnessReadings: newPoints });
};
const handlePhotoAdd = (url: string) => {
setFormData((prev) => ({ ...prev, photos: [...prev.photos, url] }));
};
const handlePhotoRemove = (index: number) => {
setFormData((prev) => ({ ...prev, photos: prev.photos.filter((_, i) => i !== index) }));
};
// Validation
const numericEps = formData.epsPoints.map(p => parseFloat(p)).filter(p => !isNaN(p));
// const isValidPainting = type === 'painting' && numericEps.length >= 10; // Relaxed
// const isValidTreatment = type === 'surface_treatment' && formData.treatmentType && formData.cleaningDegree; // Relaxed
// Basic validation
const canSubmit = formData.date && formData.inspector && formData.pieceDescription;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!selectedProjectId) {
alert("Selecione um projeto");
return;
}
setLoading(true);
try {
const payload = {
projectId: selectedProjectId,
type,
date: formData.date,
inspector: formData.inspector,
pieceDescription: formData.pieceDescription,
appearance: formData.appearance,
defects: formData.defects,
photos: formData.photos,
applicationRecordId: formData.applicationRecordId || null,
// Fields are sent regardless, backend optionality handles it
epsPoints: formData.epsPoints.map(p => p !== '' ? parseFloat(p) : null),
adhesionTest: formData.adhesionTest,
stockItemId: formData.stockItemId || null,
batch: formData.batch,
treatmentExecutor: formData.treatmentExecutor,
treatmentType: formData.treatmentType,
cleaningDegree: formData.cleaningDegree,
roughnessReadings: formData.roughnessReadings.map(p => p !== '' ? parseFloat(p) : null),
flashRust: formData.flashRust,
temperature: formData.temperature ? parseFloat(formData.temperature) : null,
relativeHumidity: formData.relativeHumidity ? parseFloat(formData.relativeHumidity) : null,
period: formData.period,
partTemperature: formData.partTemperature ? parseFloat(formData.partTemperature) : null,
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null,
instrumentId: formData.instrumentId || null
};
if (initialData) {
await api.put(`/inspections/${initialData.id}`, payload);
} else {
await api.post('/inspections', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving inspection', error);
alert('Erro ao salvar inspeção');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Inspeção" : "Nova Inspeção"}>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Tabs */}
<div className="flex p-1 bg-surface-soft rounded-xl border border-border/40 mb-6">
<button
type="button"
onClick={() => setType('surface_treatment')}
className={clsx(
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
type === 'surface_treatment'
? "bg-amber-600 text-white shadow-lg shadow-amber-600/20"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Hammer size={16} />
Tratamento
</button>
<button
type="button"
onClick={() => setType('painting')}
className={clsx(
"flex-1 flex items-center justify-center gap-2 py-2.5 text-sm font-bold rounded-lg transition-all",
type === 'painting'
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-text-muted hover:text-text-main hover:bg-surface-hover"
)}
>
<Paintbrush size={16} />
Pintura
</button>
</div>
{/* Common Fields */}
<div className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
{/* Batch Selection (Painting Only) */}
{type === 'painting' && availableBatches.length > 0 && (
<Select
name="applicationRecordId"
label="Vincular ao Lote de Pintura (Opcional)"
options={[
{ label: 'Selecione um lote...', value: '' },
...availableBatches.map(b => ({
label: `${b.pieceDescription || 'Lote sem nome'} (${b.coatStage})`,
value: b.id
}))
]}
value={formData.applicationRecordId}
onChange={handleChange}
/>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input name="date" label="Data da Inspeção" type="date" value={formData.date} onChange={handleChange} required />
<Input name="inspector" label="Inspetor" value={formData.inspector} onChange={handleChange} required />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input name="pieceDescription" label="Peça/Área Inspecionada" value={formData.pieceDescription} onChange={handleChange} required />
<Input name="weightKg" label="Peso Inspecionado (kg)" type="number" value={formData.weightKg} onChange={handleChange} required placeholder="Ex: 688" />
</div>
<Select
name="instrumentId"
label="Instrumento Utilizado"
options={[
{ label: 'Selecione um instrumento...', value: '' },
...instruments.map(i => ({ label: `${i.name} - ${i.serialNumber} (${i.type})`, value: i._id }))
]}
value={formData.instrumentId}
onChange={handleChange}
/>
</div>
{/* Specific Forms */}
{type === 'painting' ? (
<PaintingInspectionForm
formData={formData}
handleChange={handleChange}
handleEpsChange={handleEpsChange}
numericPoints={numericEps}
stockItems={stockItems}
handleRoughnessChange={handleRoughnessChange}
/>
) : (
<SurfaceTreatmentForm
formData={formData}
handleChange={handleChange}
handleReadingChange={handleRoughnessChange}
/>
)}
{/* Common Footer (Photos, Observation, Status) */}
<div className="space-y-4 pt-4 border-t border-border/40">
<PhotoUpload
photos={formData.photos}
onPhotosChange={handlePhotoAdd}
onRemovePhoto={handlePhotoRemove}
/>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Defeitos / Observações</label>
<textarea
name="defects"
className="flex min-h-[80px] w-full rounded-xl border border-border bg-[var(--input-bg)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all placeholder:text-text-muted/50"
value={formData.defects}
onChange={handleChange}
placeholder="Descreva observações, falhas encontradas ou detalhes adicionais..."
/>
</div>
<Select
name="appearance"
label="Resultado Final"
options={[
{ label: 'Aprovada', value: 'approved' },
{ label: 'Reprovada', value: 'rejected' },
{ label: 'Com Ressalvas', value: 'notes' }
]}
value={formData.appearance}
onChange={handleChange}
required
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !canSubmit}>
{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Registrar Inspeção')}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,374 @@
import React from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { PaintingScheme, TechnicalDataSheet } from '../../types';
interface CreatePaintingSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: PaintingScheme;
}
export const CreatePaintingSchemeModal: React.FC<CreatePaintingSchemeModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = React.useState(false);
const [projects, setProjects] = React.useState<{ id: string, name: string }[]>([]);
const [dataSheets, setDataSheets] = React.useState<TechnicalDataSheet[]>([]);
const [selectedProjectId, setSelectedProjectId] = React.useState(projectId || '');
const [formData, setFormData] = React.useState({
name: '',
type: '',
coat: '',
solidsVolume: '',
yieldTheoretical: '',
epsMin: '',
epsMax: '',
dilution: '',
manufacturer: '',
color: '',
notes: '',
paintConsumption: '',
thinnerConsumption: '',
paintId: '',
thinnerId: '',
thinnerSymbol: '',
colorHex: '#ffffff'
});
React.useEffect(() => {
if (!projectId) {
api.get('/projects').then(response => {
setProjects(response.data);
}).catch(err => console.error("Error loading projects", err));
} else {
setSelectedProjectId(projectId);
}
api.get('/datasheets').then(response => {
console.log('Frontend: Datasheets received:', response.data.length);
setDataSheets(response.data);
}).catch(err => {
console.error("Frontend: Error loading datasheets:", err);
});
}, [projectId, isOpen]);
React.useEffect(() => {
if (initialData) {
setFormData({
name: initialData.name || '',
type: initialData.type || '',
coat: initialData.coat || '',
solidsVolume: initialData.solidsVolume?.toString() || '',
yieldTheoretical: initialData.yieldTheoretical?.toString() || '',
epsMin: initialData.epsMin?.toString() || '',
epsMax: initialData.epsMax?.toString() || '',
dilution: initialData.dilution?.toString() || '',
manufacturer: initialData.manufacturer || '',
color: initialData.color || '',
notes: initialData.notes || '',
paintConsumption: initialData.paintConsumption?.toString() || '',
thinnerConsumption: initialData.thinnerConsumption?.toString() || '',
paintId: typeof initialData.paintId === 'object' ? (initialData.paintId?._id || '') : (initialData.paintId as string) || '',
thinnerId: typeof initialData.thinnerId === 'object' ? (initialData.thinnerId?._id || '') : (initialData.thinnerId as string) || '',
thinnerSymbol: initialData.thinnerSymbol || '',
colorHex: initialData.colorHex || '#ffffff'
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
setFormData({
name: '', type: '', coat: '', solidsVolume: '', yieldTheoretical: '', epsMin: '', epsMax: '',
dilution: '', manufacturer: '', color: '', notes: '',
paintConsumption: '', thinnerConsumption: '', paintId: '', thinnerId: '',
thinnerSymbol: '',
colorHex: '#ffffff'
});
if (projectId) setSelectedProjectId(projectId);
}
}, [initialData, isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
// Mantém a atualização básica do estado
setFormData(prev => {
const newState = { ...prev, [name]: value };
// Lógica Proativa: Se mudar o NOME do produto no topo, tenta preencher tudo
if (name === 'name' && value) {
const ds = dataSheets.find(d => d.name === value);
if (ds) {
let mappedType = '';
const dsTypeNormalized = ds.type?.toLowerCase() || '';
if (dsTypeNormalized.includes('epóxi')) mappedType = 'epoxy';
else if (dsTypeNormalized.includes('poliuretano')) mappedType = 'polyurethane';
else if (dsTypeNormalized.includes('zinco')) mappedType = 'silicate-zinc';
else if (dsTypeNormalized.includes('acríl')) mappedType = 'acrylic';
else if (dsTypeNormalized.includes('alquíd')) mappedType = 'alkyd';
// Se a tinta tem um redutor na ficha, tenta achar o ID dele na biblioteca
let thinnerId = ''; // Resetar redutor ao trocar a tinta
if (ds.reducer) {
const reducerCode = ds.reducer.trim().toLowerCase();
console.log(`Auto-fill: Looking for reducer "${reducerCode}" for paint "${ds.name}"`);
// Busca flexível: exata ou contém
const matchingReducer = dataSheets.find(d =>
d.name.toLowerCase() === reducerCode ||
d.name.toLowerCase().includes(reducerCode) ||
reducerCode.includes(d.name.toLowerCase())
);
if (matchingReducer) {
thinnerId = matchingReducer._id || matchingReducer.id || '';
console.log(`Auto-fill: Found matching reducer: ${matchingReducer.name}`);
} else {
console.log(`Auto-fill: No matching reducer found in library for "${reducerCode}"`);
}
}
return {
...newState,
type: mappedType || newState.type,
solidsVolume: ds.solidsVolume?.toString() || newState.solidsVolume,
yieldTheoretical: ds.yieldTheoretical?.toString() || newState.yieldTheoretical,
epsMin: ds.dftMin?.toString() || newState.epsMin,
epsMax: ds.dftMax?.toString() || newState.epsMax,
manufacturer: ds.manufacturer || newState.manufacturer,
dilution: ds.dilution?.toString() || newState.dilution,
paintId: ds._id || ds.id || newState.paintId,
thinnerId: thinnerId,
thinnerSymbol: ds.reducer || ''
};
}
}
return newState;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("Submitting form with data:", formData);
if (isGuest()) {
showGuestWarning();
return;
}
if (!selectedProjectId) {
alert("Selecione um projeto");
return;
}
setLoading(true);
try {
const payload = {
...formData,
projectId: selectedProjectId,
solidsVolume: parseInt(formData.solidsVolume),
yieldTheoretical: parseFloat(formData.yieldTheoretical),
epsMin: parseFloat(formData.epsMin),
epsMax: parseFloat(formData.epsMax),
dilution: parseInt(formData.dilution),
paintConsumption: parseFloat(formData.paintConsumption),
thinnerConsumption: parseFloat(formData.thinnerConsumption),
paintId: formData.paintId || null,
thinnerId: formData.thinnerId || null,
thinnerSymbol: formData.thinnerSymbol
};
if (initialData) {
await api.put(`/painting-schemes/${initialData.id}`, payload);
} else {
await api.post('/painting-schemes', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving scheme', error);
alert('Erro ao salvar esquema');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Esquema" : "Novo Esquema / Demão"}>
<form onSubmit={handleSubmit} className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
<Select
name="name"
label="Nome/Descrição (Produto)"
options={[
{ label: 'Outro (Manual)', value: '' },
...dataSheets.map(ds => ({ label: ds.name, value: ds.name }))
]}
value={formData.name}
onChange={handleChange}
required
/>
{formData.name === '' && (
<Input name="name" label="Descrição Manual" placeholder="Ex: Pintura Interna" value={formData.name} onChange={handleChange} required />
)}
<Select
name="coat"
label="Demão (Etapa)"
options={[
{ label: 'Primer / Selador', value: 'Primer' },
{ label: 'Stripe Coat', value: 'Stripe Coat' },
{ label: 'Intermediário', value: 'Intermediario' },
{ label: 'Acabamento', value: 'Acabamento' },
{ label: 'Retoque', value: 'Retoque' }
]}
value={formData.coat}
onChange={handleChange}
required
/>
<div className="grid grid-cols-2 gap-4">
<Select
name="type"
label="Tipo de Tinta"
options={[
{ label: 'Epóxi', value: 'epoxy' },
{ label: 'Poliuretano', value: 'polyurethane' },
{ label: 'Silicato Zinco', value: 'silicate-zinc' },
{ label: 'Acrílica', value: 'acrylic' },
{ label: 'Alquídica', value: 'alkyd' }
]}
value={formData.type}
onChange={handleChange}
/>
<Input name="solidsVolume" label="Sólidos Vol. (%)" type="number" value={formData.solidsVolume} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="yieldTheoretical" label="Rendimento (m²/L)" type="number" step="0.01" value={formData.yieldTheoretical} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="epsMin" label="EPS Mín (μm)" type="number" value={formData.epsMin} onChange={handleChange} />
<Input name="epsMax" label="EPS Máx (μm)" type="number" value={formData.epsMax} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="dilution" label="Diluição (%)" type="number" value={formData.dilution} onChange={handleChange} />
<Input name="manufacturer" label="Fabricante" value={formData.manufacturer} onChange={handleChange} />
</div>
<div className="grid grid-cols-2 gap-4">
<Input name="color" label="Cor (Munsell/RAL)" placeholder="Ex: N6.5 ou RAL 7035" value={formData.color} onChange={handleChange} />
<div className="flex flex-col gap-1">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Cor Representativa</label>
<div className="flex items-center gap-3 bg-surface-soft/50 p-2 rounded-xl border border-border/40">
<input
type="color"
name="colorHex"
value={formData.colorHex}
onChange={handleChange}
title="Cor Representativa"
className="w-10 h-10 rounded-lg cursor-pointer bg-transparent"
/>
<span className="text-xs font-mono font-bold text-text-muted">{formData.colorHex}</span>
</div>
</div>
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mt-4">
<h3 className="text-sm font-bold text-text-main mb-3">Planejamento de Consumo (Opcional)</h3>
<div className="grid grid-cols-2 gap-4">
<Select
name="paintId"
label="Produto (Tinta)"
options={[
{ label: 'Selecione...', value: '' },
...dataSheets.map(ds => ({ label: ds.name, value: ds._id || ds.id || '' }))
]}
value={formData.paintId}
onChange={(e) => {
const selectedPaintId = e.target.value;
setFormData(prev => {
const ds = dataSheets.find(d => (d._id || d.id) === selectedPaintId);
let thinnerId = ''; // Resetar ao trocar tinta
if (ds && ds.reducer) {
const reducerClean = ds.reducer.trim().toLowerCase();
console.log(`Consumption: Looking for reducer "${reducerClean}" for paint ID ${selectedPaintId}`);
// Busca exata ou por inclusão
const matchingThinner = dataSheets.find(d =>
d.name.toLowerCase() === reducerClean ||
d.name.toLowerCase().includes(reducerClean) ||
reducerClean.includes(d.name.toLowerCase())
);
if (matchingThinner) {
thinnerId = matchingThinner._id || matchingThinner.id || '';
console.log(`Consumption: Found reducer match: ${matchingThinner.name}`);
}
}
return { ...prev, paintId: selectedPaintId, thinnerId, thinnerSymbol: ds?.reducer || '' };
});
}}
/>
<Input
name="thinnerSymbol"
label="Redutor (diluente)"
value={formData.thinnerSymbol}
readOnly
placeholder="Preenchido pela tinta"
className="bg-surface-soft/50 font-bold text-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-2">
<Input
name="paintConsumption"
label="Consumo Tinta (L/Kg)"
type="number"
step="0.001"
value={formData.paintConsumption}
onChange={handleChange}
/>
<Input
name="thinnerConsumption"
label="Consumo Diluente (L/Kg)"
type="number"
step="0.001"
value={formData.thinnerConsumption}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Demão')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,167 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import api from '../../services/api';
import * as geometryService from '../../services/geometryTypeService';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Part, GeometryType } from '../../types';
interface CreatePartModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
projectId?: string;
initialData?: Part;
}
export const CreatePartModal: React.FC<CreatePartModalProps> = ({ isOpen, onClose, onSuccess, projectId, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<{ id: string, name: string }[]>([]);
const [geometryTypes, setGeometryTypes] = useState<GeometryType[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState(projectId || '');
const [formData, setFormData] = useState({
description: '',
dimensions: '',
weight: '',
type: '',
area: '',
complexity: '',
quantity: '1',
notes: ''
});
useEffect(() => {
if (initialData) {
setFormData({
description: initialData.description || '',
dimensions: initialData.dimensions || '',
weight: initialData.weight?.toString() || '',
type: initialData.type || '',
area: initialData.area?.toString() || '',
complexity: initialData.complexity?.toString() || '',
quantity: initialData.quantity?.toString() || '1',
notes: initialData.notes || ''
});
if (initialData.projectId) setSelectedProjectId(initialData.projectId);
} else {
setFormData({ description: '', dimensions: '', weight: '', type: '', area: '', complexity: '', quantity: '1', notes: '' });
if (projectId) setSelectedProjectId(projectId);
}
}, [initialData, isOpen, projectId]);
useEffect(() => {
if (isOpen) {
if (!projectId) {
api.get('/projects')
.then(res => setProjects(res.data))
.catch(err => console.error("Error fetching projects", err));
}
geometryService.getAllTypes()
.then(res => setGeometryTypes(res.data))
.catch(err => console.error("Error fetching geometry types", err));
}
}, [isOpen, projectId]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
const projectToUse = projectId || selectedProjectId;
if (!projectToUse) {
alert("Por favor, selecione um projeto.");
return;
}
setLoading(true);
try {
const payload = {
description: formData.type, // Usar o tipo como descrição
projectId: projectToUse,
dimensions: formData.dimensions || undefined,
weight: formData.weight ? parseFloat(formData.weight) : undefined,
type: formData.type || undefined,
area: formData.area ? parseFloat(formData.area) : undefined,
quantity: formData.quantity ? parseInt(formData.quantity) : 1,
notes: formData.notes || undefined
};
if (initialData) {
await api.put(`/parts/${initialData.id}`, payload);
} else {
await api.post('/parts', payload);
}
onSuccess();
onClose();
} catch (error) {
console.error('Error saving part', error);
alert('Erro ao salvar peça');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={initialData ? "Editar Peça" : "Nova Peça / Geometria"}>
<form onSubmit={handleSubmit} className="space-y-4">
{!projectId && (
<Select
name="projectId"
label="Projeto / Obra"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={selectedProjectId}
onChange={(e) => setSelectedProjectId(e.target.value)}
required
/>
)}
<Select
name="type"
label="Tipo Geometria"
options={[
{ label: 'Selecione...', value: '' },
...geometryTypes.map(t => ({ label: t.name, value: t.name }))
]}
value={formData.type}
onChange={handleChange}
required
/>
<div className="grid grid-cols-2 gap-4">
<Input name="weight" label="Kg estimado do lote" type="number" step="0.1" value={formData.weight} onChange={handleChange} />
<Input name="area" label="Área Superfície (m²)" type="number" step="0.01" value={formData.area} onChange={handleChange} />
</div>
<div className="flex flex-col gap-1 w-full">
<label className="text-[10px] font-bold text-primary uppercase tracking-[0.15em] ml-1 mb-1">Observações</label>
<textarea
name="notes"
aria-label="Observações"
className="flex min-h-[80px] w-full rounded-xl border bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--input-text)] px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary disabled:opacity-50 transition-all font-medium placeholder:text-[var(--input-placeholder)]"
value={formData.notes}
onChange={handleChange}
/>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Adicionar Peça')}</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { FileDown, Plus } from 'lucide-react';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import { CreatePaintingSchemeModal } from './CreatePaintingSchemeModal';
import { ImportSchemeModal } from './ImportSchemeModal';
import type { Project } from '../../types';
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: Project;
}
export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [step, setStep] = useState<'form' | 'success'>('form');
const [createdProjectId, setCreatedProjectId] = useState<string | null>(null);
// Sub-modals state
const [showSchemeModal, setShowSchemeModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [formData, setFormData] = useState({
name: '',
client: '',
startDate: '',
endDate: '',
technician: '',
environment: '',
weightKg: '', // Text input for number
});
React.useEffect(() => {
if (isOpen) {
setStep('form');
setCreatedProjectId(null);
if (initialData) {
setFormData({
name: initialData.name || '',
client: initialData.client || '',
startDate: initialData.startDate ? new Date(initialData.startDate).toISOString().split('T')[0] : '',
endDate: initialData.endDate ? new Date(initialData.endDate).toISOString().split('T')[0] : '',
technician: initialData.technician || '',
environment: initialData.environment || '',
weightKg: initialData.weightKg ? String(initialData.weightKg) : ''
});
} else {
setFormData({ name: '', client: '', startDate: '', endDate: '', technician: '', environment: '', weightKg: '' });
}
}
}, [initialData, isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log('Submit button clicked. Current formData:', formData);
if (isGuest()) {
console.warn('Submission blocked: user is guest');
showGuestWarning();
return;
}
if (!formData.name || !formData.client) {
console.warn('Submission blocked: required fields missing', { name: !!formData.name, client: !!formData.client });
alert('Por favor, preencha o Nome do Projeto e o Cliente.');
return;
}
setLoading(true);
try {
const payload = {
...formData,
weightKg: formData.weightKg ? parseFloat(formData.weightKg) : null
};
console.log('Sending project payload to backend...', payload);
if (initialData) {
console.log('Updating project:', initialData.id);
const res = await api.put(`/projects/${initialData.id}`, payload);
console.log('Project updated successfully:', res.data);
onSuccess();
onClose();
} else {
console.log('Posting new project...');
const res = await api.post('/projects', payload);
console.log('Project created response:', res.data);
const pid = res.data.id || res.data._id;
if (!pid) {
console.error('No ID returned from create project:', res.data);
throw new Error('ID do projeto não retornado pelo servidor.');
}
setCreatedProjectId(pid);
onSuccess();
setStep('success');
}
} catch (error: unknown) {
console.error('Error saving project:', error);
const axiosError = error as any;
const errorMsg = axiosError.response?.data?.error || axiosError.message || 'Erro desconhecido';
alert(`Erro ao salvar projeto: ${errorMsg}`);
} finally {
setLoading(false);
}
};
const handleClose = () => {
// If in success step, we just finish
onClose();
};
return (
<>
<Modal isOpen={isOpen} onClose={handleClose} title={step === 'form' ? (initialData ? "Editar Projeto" : "Novo Projeto") : "Projeto Criado com Sucesso!"}>
{step === 'form' ? (
<form onSubmit={handleSubmit} className="space-y-4">
<Input name="name" label="Nome do Projeto" required value={formData.name} onChange={handleChange} />
<Input name="client" label="Cliente" required value={formData.client} onChange={handleChange} />
<Input name="technician" label="Responsável Técnico" value={formData.technician} onChange={handleChange} />
<div className="grid grid-cols-2 gap-4">
<Input name="startDate" label="Início Planejado" type="date" value={formData.startDate} onChange={handleChange} />
<Input name="endDate" label="Fim Planejado" type="date" value={formData.endDate} onChange={handleChange} />
</div>
<Input name="weightKg" label="Peso Total (Kg)" type="number" placeholder="0.00" value={formData.weightKg} onChange={handleChange} />
<Select
name="environment"
label="Ambiente (Corrosividade)"
options={[
{ label: 'C1 - Muito Baixa', value: 'C1' },
{ label: 'C2 - Baixa', value: 'C2' },
{ label: 'C3 - Média', value: 'C3' },
{ label: 'C4 - Alta', value: 'C4' },
{ label: 'C5 - Muito Alta', value: 'C5' },
{ label: 'CX - Extrema', value: 'CX' }
]}
value={formData.environment}
onChange={handleChange}
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={handleClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Salvando...' : (initialData ? 'Salvar Alterações' : 'Criar Projeto')}</Button>
</div>
</form>
) : (
<div className="space-y-6 text-center py-4">
<div className="space-y-2">
<h3 className="text-xl font-bold text-text-main">Configurar Esquema de Pintura?</h3>
<p className="text-sm text-text-muted px-4">O projeto foi criado. Deseja adicionar um esquema de pintura agora?</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => setShowImportModal(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
>
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
<FileDown size={24} />
</div>
<span className="font-bold text-sm text-text-main group-hover:text-primary">Importar de Obra</span>
</button>
<button
onClick={() => setShowSchemeModal(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-2xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all group"
>
<div className="p-3 rounded-full bg-surface-soft group-hover:bg-white text-primary transition-colors">
<Plus size={24} />
</div>
<span className="font-bold text-sm text-text-main group-hover:text-primary">Criar Novo</span>
</button>
</div>
<div className="pt-4 border-t border-border/40">
<Button variant="ghost" className="w-full" onClick={handleClose}>Pular / Finalizar</Button>
</div>
</div>
)}
</Modal>
{createdProjectId && (
<>
<CreatePaintingSchemeModal
isOpen={showSchemeModal}
onClose={() => setShowSchemeModal(false)}
onSuccess={() => { setShowSchemeModal(false); onClose(); }}
projectId={createdProjectId}
/>
<ImportSchemeModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
onSuccess={() => { setShowImportModal(false); onClose(); }}
targetProjectId={createdProjectId}
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal } from '../Modal';
import { Button } from '../Button';
import { Edit, Trash2, Plus } from 'lucide-react';
import { DiluentRegistrationModal } from './DiluentRegistrationModal';
import { getDataSheets, deleteDataSheet } from '../../services/dataSheetService';
import type { TechnicalDataSheet } from '../../types';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface DiluentListModalProps {
isOpen: boolean;
onClose: () => void;
}
export const DiluentListModal: React.FC<DiluentListModalProps> = ({ isOpen, onClose }) => {
const { isAdmin } = useAuth();
const { showToast } = useToast();
const [diluents, setDiluents] = useState<TechnicalDataSheet[]>([]);
const [loading, setLoading] = useState(false);
// State for the registration/edit modal
const [showFormModal, setShowFormModal] = useState(false);
const [selectedDiluent, setSelectedDiluent] = useState<TechnicalDataSheet | undefined>(undefined);
const fetchDiluents = useCallback(async () => {
setLoading(true);
try {
const response = await getDataSheets();
// Filter only THINNER types
const filtered = response.data.filter(ds =>
ds.type === 'THINNER' || ds.type === 'DILUENTE'
);
setDiluents(filtered);
} catch (error) {
console.error('Error fetching diluents:', error);
showToast('Erro ao carregar lista de diluentes.', 'error');
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
if (isOpen) {
fetchDiluents();
}
}, [isOpen, fetchDiluents]);
const handleDelete = async (id: string, name: string) => {
if (confirm(`Tem certeza que deseja excluir o diluente "${name}" ? `)) {
try {
await deleteDataSheet(id);
showToast('Diluente excluído com sucesso.', 'success');
fetchDiluents();
} catch (error) {
console.error('Error deleting diluent:', error);
showToast('Erro ao excluir diluente.', 'error');
}
}
};
const handleEdit = (diluent: TechnicalDataSheet) => {
setSelectedDiluent(diluent);
setShowFormModal(true);
};
const handleNew = () => {
setSelectedDiluent(undefined);
setShowFormModal(true);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title="Gerenciar Diluentes"
maxWidth="max-w-4xl"
>
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-text-muted text-sm">
Lista de diluentes cadastrados no sistema.
</p>
{isAdmin() && (
<Button onClick={handleNew} className="flex items-center gap-2">
<Plus size={16} />
Novo Diluente
</Button>
)}
</div>
<div className="bg-surface rounded-xl border border-border/40 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-text-muted">Carregando...</div>
) : diluents.length === 0 ? (
<div className="p-8 text-center text-text-muted">Nenhum diluente encontrado.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-soft border-b border-border/40">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Nome</th>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Fabricante</th>
<th className="px-6 py-3 text-left text-xs font-bold text-text-muted uppercase">Estoque Mín.</th>
<th className="px-6 py-3 text-right text-xs font-bold text-text-muted uppercase">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{diluents.map((diluent) => (
<tr key={diluent._id || diluent.id} className="hover:bg-surface-hover transition-colors">
<td className="px-6 py-3 text-sm font-medium text-text-main">
{diluent.name}
</td>
<td className="px-6 py-3 text-sm text-text-secondary">
{diluent.manufacturer}
{diluent.manufacturerCode && (
<span className="text-xs text-text-muted block">
{diluent.manufacturerCode}
</span>
)}
</td>
<td className="px-6 py-3 text-sm text-text-secondary">
{diluent.minStock ? `${diluent.minStock} L` : '-'}
</td>
<td className="px-6 py-3 text-right flex justify-end gap-2">
{isAdmin() && (
<>
<button
onClick={() => handleEdit(diluent)}
className="p-1.5 text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Editar"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDelete(diluent._id || diluent.id, diluent.name)}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={16} />
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="flex justify-end pt-4">
<Button variant="ghost" onClick={onClose}>
Fechar
</Button>
</div>
</div>
</Modal>
{showFormModal && (
<DiluentRegistrationModal
isOpen={showFormModal}
onClose={() => setShowFormModal(false)}
onSuccess={() => {
fetchDiluents();
setShowFormModal(false);
}}
initialData={selectedDiluent}
/>
)}
</>
);
};

View File

@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { TechnicalDataSheet } from '../../types';
import { createDataSheet, updateDataSheet } from '../../services/dataSheetService';
interface DiluentRegistrationModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: TechnicalDataSheet;
}
export const DiluentRegistrationModal: React.FC<DiluentRegistrationModalProps> = ({ isOpen, onClose, onSuccess, initialData }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
// Form Data
const [name, setName] = useState('');
const [manufacturer, setManufacturer] = useState('');
const [manufacturerCode, setManufacturerCode] = useState('');
const [minStock, setMinStock] = useState('');
const [typicalApplication, setTypicalApplication] = useState('');
useEffect(() => {
if (isOpen) {
if (initialData) {
setName(initialData.name);
setManufacturer(initialData.manufacturer || '');
setManufacturerCode(initialData.manufacturerCode || '');
setMinStock(String(initialData.minStock || ''));
setTypicalApplication(initialData.typicalApplication || '');
} else {
setName('');
setManufacturer('');
setManufacturerCode('');
setMinStock('');
setTypicalApplication('');
}
}
}, [isOpen, initialData]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
const formData = new FormData();
formData.append('name', name);
formData.append('manufacturer', manufacturer);
formData.append('manufacturerCode', manufacturerCode);
formData.append('minStock', String(Number(minStock) || 0));
formData.append('typicalApplication', typicalApplication);
formData.append('type', 'THINNER');
// Ensure fileUrl is handled if required by backend, existing logic used a placeholder string
if (!initialData) {
formData.append('fileUrl', 'placeholder_url');
}
try {
if (initialData && (initialData._id || initialData.id)) {
await updateDataSheet(initialData._id || initialData.id, formData);
} else {
await createDataSheet(formData);
}
onSuccess();
onClose();
} catch (error: any) {
console.error('Error saving diluent:', error);
alert(error.response?.data?.error || 'Erro ao salvar diluente.');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? "Editar Diluente" : "Cadastrar Novo Diluente"}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Nome do Diluente"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="Ex: Diluente Epóxi 123"
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fabricante"
name="manufacturer"
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
required
placeholder="Ex: Sherwin Williams"
/>
<Input
label="Cód. Fabricante"
name="manufacturerCode"
value={manufacturerCode}
onChange={(e) => setManufacturerCode(e.target.value)}
placeholder="Ex: REF-001"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Estoque Mínimo (L)"
name="minStock"
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
placeholder="0"
/>
</div>
<Input
label="Aplicação Típica"
name="typicalApplication"
value={typicalApplication}
onChange={(e) => setTypicalApplication(e.target.value)}
placeholder="Ex: Diluição de tintas epóxi série X"
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import { Modal } from '../Modal';
import { Select } from '../Select';
import { Button } from '../Button';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
import type { Project, PaintingScheme } from '../../types';
interface ImportSchemeModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
targetProjectId: string;
isExchangeMode?: boolean;
hasInspections?: boolean;
}
export const ImportSchemeModal: React.FC<ImportSchemeModalProps> = ({ isOpen, onClose, onSuccess, targetProjectId, isExchangeMode, hasInspections }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [schemes, setSchemes] = useState<PaintingScheme[]>([]);
const [sourceProjectId, setSourceProjectId] = useState('');
const [sourceSchemeId, setSourceSchemeId] = useState('');
const [shouldReplace, setShouldReplace] = useState(false);
// Initial state setup
useEffect(() => {
if (isOpen) {
// Default replace to TRUE if in exchange mode and allowed (no inspections)
if (isExchangeMode && !hasInspections) {
setShouldReplace(true);
} else {
setShouldReplace(false);
}
api.get('/projects').then(res => {
const otherProjects = res.data.filter((p: Project) => p.id !== targetProjectId);
setProjects(otherProjects);
}).catch(err => console.error("Error loading projects", err));
} else {
setSourceProjectId('');
setSourceSchemeId('');
setSchemes([]);
setShouldReplace(false);
}
}, [isOpen, targetProjectId, isExchangeMode, hasInspections]);
// Fetch schemes when project selected
useEffect(() => {
if (sourceProjectId) {
api.get(`/painting-schemes?projectId=${sourceProjectId}`).then(res => {
const projectSchemes = res.data.filter((s: PaintingScheme) => s.projectId === sourceProjectId);
setSchemes(projectSchemes);
}).catch(err => console.error("Error loading schemes", err));
} else {
setSchemes([]);
}
}, [sourceProjectId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
if (!sourceSchemeId) return;
if (!targetProjectId) {
alert("Erro: Projeto de destino não identificado. Tente recarregar a página.");
return;
}
setLoading(true);
try {
// If Replacing, first delete ALL existing schemes for this project
if (shouldReplace && isExchangeMode && !hasInspections) {
// 1. Fetch current schemes
const currentSchemesRes = await api.get(`/painting-schemes?projectId=${targetProjectId}`);
const currentSchemes = currentSchemesRes.data.filter((s: PaintingScheme) => s.projectId === targetProjectId);
// 2. Delete them
await Promise.all(currentSchemes.map((s: PaintingScheme) => api.delete(`/painting-schemes/${s.id}`)));
}
const schemeToClone = schemes.find(s => s.id === sourceSchemeId);
if (!schemeToClone) throw new Error("Scheme not found");
// Clone and remove ID/Project specific fields to create a fresh copy
const schemeData = { ...(schemeToClone as any) };
delete schemeData.id;
delete schemeData.projectId;
delete schemeData._id;
delete schemeData.__v;
delete schemeData.createdAt;
delete schemeData.updatedAt;
await api.post('/painting-schemes', {
...schemeData,
projectId: targetProjectId,
// If replacing, keep original name? User asked to "Exchange". Maybe we don't need "(Cópia)" suffix if strictly exchanging.
// But safer to keep distinct unless user renames. Let's keep existing logic or maybe drop suffix if replacing?
// Step 1251 prompt implies "Troca Limpa". A clean swap usually implies taking the new scheme AS IS.
name: shouldReplace ? schemeData.name : `${schemeData.name} (Cópia)`
});
onSuccess();
onClose();
} catch (error) {
console.error('Error importing scheme', error);
alert('Erro ao importar esquema');
} finally {
setLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={isExchangeMode ? "Trocar / Importar Esquema" : "Importar Esquema de Pintura"}>
<form onSubmit={handleSubmit} className="space-y-6">
<p className="text-sm text-text-muted">Selecione uma obra existente para copiar seu esquema de pintura.</p>
{isExchangeMode && hasInspections && (
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 items-start">
<div className="mt-1 text-amber-600 font-bold text-xs uppercase">Atenção</div>
<div className="text-xs text-amber-700">
Esta obra possui inspeções ou registros cadastrados. Por segurança, <strong>não é permitido substituir</strong> o esquema atual, apenas adicionar novos itens.
</div>
</div>
)}
{isExchangeMode && !hasInspections && (
<div className="bg-surface-soft p-3 rounded-lg border border-border flex items-center gap-3">
<input
type="checkbox"
id="replace-check"
className="w-4 h-4 text-primary rounded border focus:ring-primary"
checked={shouldReplace}
onChange={(e) => setShouldReplace(e.target.checked)}
/>
<label htmlFor="replace-check" className="text-sm font-medium text-text-main cursor-pointer select-none">
Substituir todos os esquemas atuais (Limpar Obra)
</label>
</div>
)}
<Select
name="sourceProject"
label="Obra/Projeto de Origem"
options={projects.map(p => ({ label: p.name, value: p.id }))}
value={sourceProjectId}
onChange={(e) => setSourceProjectId(e.target.value)}
required
/>
<Select
name="sourceScheme"
label="Esquema de Pintura"
options={schemes.map(s => ({ label: s.name, value: s.id }))}
value={sourceSchemeId}
onChange={(e) => setSourceSchemeId(e.target.value)}
required
disabled={!sourceProjectId}
/>
{schemes.length === 0 && sourceProjectId && (
<p className="text-xs text-amber-500 font-bold">Esta obra não possui esquemas cadastrados.</p>
)}
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading || !sourceSchemeId}>
{loading ? 'Processando...' : (shouldReplace ? 'Trocar Esquema' : 'Adicionar Cópia')}
</Button>
</div>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { stockService, type StockMovement, type StockItem } from '../../services/stockService';
import { format } from 'date-fns';
import { ArrowUp, ArrowDown, RefreshCw, Trash2, Edit2, Save, X, FileText, Activity } from 'lucide-react';
import { useAuth } from '../../context/useAuth';
interface StockHistoryModalProps {
isOpen: boolean;
onClose: () => void;
item: StockItem;
onUpdate?: () => void;
}
interface AuditLog {
_id: string;
action: 'CREATE' | 'UPDATE' | 'DELETE';
userName: string;
details: string;
timestamp: string;
movementNumber?: number;
}
export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({ isOpen, onClose, item, onUpdate }) => {
const { isAdmin } = useAuth();
const [activeTab, setActiveTab] = useState<'movements' | 'logs'>('movements');
const [movements, setMovements] = useState<StockMovement[]>([]);
const [logs, setLogs] = useState<AuditLog[]>([]);
const [currentItem, setCurrentItem] = useState<StockItem>(item);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [editValues, setEditValues] = useState<{ date: string; quantity: string; notes: string }>({
date: '',
quantity: '',
notes: ''
});
const fetchData = async () => {
if (item._id) {
setLoading(true);
try {
// Always fetch item to keep balance fresh
const itemData = await stockService.getById(item._id);
setCurrentItem(itemData);
if (activeTab === 'movements') {
const data = await stockService.getMovements(item._id);
setMovements(data);
} else {
const data = await stockService.getAuditLogs(item._id);
setLogs(data);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen, item, activeTab]);
const formatMovementId = (num?: number) => {
if (!num) return '-';
return `${item.rrNumber}/${String(num).padStart(2, '0')}`;
};
const filteredLogs = logs.filter(log => {
const term = searchTerm.toLowerCase();
const formattedId = formatMovementId(log.movementNumber);
return (
log.details.toLowerCase().includes(term) ||
log.userName.toLowerCase().includes(term) ||
formattedId.toLowerCase().includes(term)
);
});
const handleEditClick = (move: StockMovement) => {
setEditingId(move._id!);
const dateStr = new Date(move.date).toISOString().slice(0, 16);
setEditValues({
date: dateStr,
quantity: String(move.quantity),
notes: move.notes || ''
});
};
const handleCancelEdit = () => {
setEditingId(null);
setEditValues({ date: '', quantity: '', notes: '' });
};
const handleSave = async (id: string) => {
try {
await stockService.updateMovement(id, {
date: new Date(editValues.date).toISOString(),
quantity: Number(editValues.quantity),
notes: editValues.notes
});
setEditingId(null);
fetchData();
if (onUpdate) onUpdate();
} catch (error) {
console.error('Error updating movement:', error);
alert('Erro ao atualizar movimentação.');
}
};
const handleDelete = async (id: string, qty: number) => {
if (confirm(`Tem certeza que deseja excluir esta movimentação de ${qty}? O saldo do lote será revertido.`)) {
try {
await stockService.deleteMovement(id);
fetchData();
if (onUpdate) onUpdate();
} catch (error) {
console.error('Error deleting movement:', error);
alert('Erro ao excluir movimentação.');
}
}
};
const getMovementIcon = (type: string) => {
switch (type) {
case 'ENTRY': return <ArrowUp size={16} className="text-green-500" />;
case 'CONSUMPTION': return <ArrowDown size={16} className="text-blue-500" />;
case 'ADJUSTMENT': return <RefreshCw size={16} className="text-amber-500" />;
default: return null;
}
};
const getMovementLabel = (type: string) => {
switch (type) {
case 'ENTRY': return 'Entrada';
case 'CONSUMPTION': return 'Consumo';
case 'ADJUSTMENT': return 'Ajuste';
default: return type;
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Histórico - ${item.rrNumber}`}
maxWidth="max-w-4xl"
>
<div className="space-y-4">
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4 flex justify-between items-center">
<div>
<p className="text-sm text-text-secondary">Produto: <span className="text-text-main font-semibold">{typeof currentItem.dataSheetId === 'object' ? currentItem.dataSheetId.name : '...'}</span></p>
<p className="text-sm text-text-secondary">Lote: <span className="text-text-main font-semibold">{currentItem.batchNumber}</span></p>
</div>
<div className="text-right">
<p className="text-sm text-text-secondary">Saldo Atual</p>
<span className="text-text-main font-bold text-2xl">{currentItem.quantity} <span className="text-lg font-normal text-text-muted">{currentItem.unit}</span></span>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-border/40 mb-4 justify-between items-center">
<div className="flex">
<button
onClick={() => setActiveTab('movements')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'movements' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
>
<Activity size={16} />
Movimentações
</button>
<button
onClick={() => setActiveTab('logs')}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'logs' ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text-main'}`}
>
<FileText size={16} />
Logs de Auditoria
</button>
</div>
{activeTab === 'logs' && (
<input
type="text"
placeholder="Buscar por nº Mov ou Detalhes..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="text-xs bg-surface border border-border/40 rounded-lg px-3 py-1.5 focus:outline-none focus:border-primary w-64 text-text-main placeholder-text-muted"
/>
)}
</div>
{loading ? (
<div className="text-center py-8 text-text-muted">Carregando...</div>
) : activeTab === 'movements' ? (
// MOVEMENTS TABLE
movements.length === 0 ? (
<div className="text-center py-8 text-text-muted">Nenhuma movimentação registrada.</div>
) : (
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
<table className="w-full text-sm text-left">
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
<tr>
<th className="px-4 py-3 w-32 text-center">ID</th>
<th className="px-4 py-3 w-40">Data</th>
<th className="px-4 py-3 w-32">Tipo</th>
<th className="px-4 py-3 w-28">Qtd</th>
<th className="px-4 py-3 w-40">Responsável</th>
<th className="px-4 py-3">Detalhes</th>
{isAdmin() && <th className="px-4 py-3 w-24 text-right">Ações</th>}
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{movements.map((move: any) => {
const isEditing = editingId === move._id;
return (
<tr key={move._id} className="hover:bg-surface-hover/50">
<td className="px-4 py-3 text-center font-mono text-text-muted text-xs">
{formatMovementId(move.movementNumber)}
</td>
<td className="px-4 py-3 text-text-main align-top">
{isEditing ? (
<input
type="datetime-local"
value={editValues.date}
onChange={(e) => setEditValues({ ...editValues, date: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
/>
) : (
<span className="whitespace-nowrap">
{format(new Date(move.date), 'dd/MM/yyyy HH:mm')}
</span>
)}
</td>
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 font-medium">
{getMovementIcon(move.type)}
<span>{getMovementLabel(move.type)}</span>
</div>
</td>
<td className="px-4 py-3 font-bold align-top">
{isEditing ? (
<input
type="number"
value={editValues.quantity}
onChange={(e) => setEditValues({ ...editValues, quantity: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs"
placeholder="(ex: -10)"
/>
) : (
<span className={move.quantity > 0 ? 'text-green-500' : 'text-red-500'}>
{move.quantity > 0 ? '+' : ''}{move.quantity}
</span>
)}
</td>
<td className="px-4 py-3 text-text-secondary align-top text-xs">
<div className="line-clamp-2" title={move.responsible}>
{move.responsible}
</div>
</td>
<td className="px-4 py-3 text-text-muted text-xs align-top">
{isEditing ? (
<textarea
value={editValues.notes}
onChange={(e) => setEditValues({ ...editValues, notes: e.target.value })}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-y min-h-[2.5rem]"
placeholder="Notas..."
/>
) : (
<div className="line-clamp-2">
{move.type === 'ADJUSTMENT' && move.reason}
{move.type === 'CONSUMPTION' && `Solicitante: ${move.requester}`}
{move.notes && ` - ${move.notes}`}
{!move.notes && !move.reason && !move.requester && '-'}
</div>
)}
</td>
{isAdmin() && (
<td className="px-4 py-3 text-right align-top">
{isEditing ? (
<div className="flex justify-end gap-2">
<button
onClick={() => handleSave(move._id!)}
className="p-1.5 bg-green-500/10 text-green-500 hover:bg-green-500/20 rounded-lg transition-colors"
title="Salvar"
>
<Save size={16} />
</button>
<button
onClick={handleCancelEdit}
className="p-1.5 bg-red-500/10 text-red-500 hover:bg-red-500/20 rounded-lg transition-colors"
title="Cancelar"
>
<X size={16} />
</button>
</div>
) : (
<div className="flex justify-end gap-2">
<button
onClick={() => handleEditClick(move)}
className="p-1.5 text-blue-500 hover:bg-blue-500/10 rounded-lg transition-colors"
title="Editar"
>
<Edit2 size={16} />
</button>
<button
onClick={() => handleDelete(move._id!, move.quantity)}
className="p-1.5 text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
title="Excluir"
>
<Trash2 size={16} />
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)
) : (
// LOGS TABLE
filteredLogs.length === 0 ? (
<div className="text-center py-8 text-text-muted">Nenhum log de auditoria encontrado.</div>
) : (
<div className="relative overflow-hidden rounded-xl border border-border/40 bg-surface">
<table className="w-full text-sm text-left">
<thead className="bg-surface-soft text-text-muted font-medium uppercase text-xs">
<tr>
<th className="px-4 py-3 w-40">Data</th>
<th className="px-4 py-3 w-32">Ação</th>
<th className="px-4 py-3 w-32 text-center">ID Mov.</th>
<th className="px-4 py-3 w-40">Usuário</th>
<th className="px-4 py-3">Detalhes da Alteração</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{filteredLogs.map((log) => (
<tr key={log._id} className="hover:bg-surface-hover/50">
<td className="px-4 py-3 text-text-main align-top whitespace-nowrap">
{format(new Date(log.timestamp), 'dd/MM/yyyy HH:mm')}
</td>
<td className="px-4 py-3 align-top font-bold">
<span className={
log.action === 'CREATE' ? 'text-green-500' :
log.action === 'UPDATE' ? 'text-blue-500' :
'text-red-500'
}>
{log.action === 'CREATE' ? 'CRIAÇÃO' :
log.action === 'UPDATE' ? 'EDIÇÃO' : 'EXCLUSÃO'}
</span>
</td>
<td className="px-4 py-3 text-center align-top font-mono text-text-main">
{formatMovementId(log.movementNumber)}
</td>
<td className="px-4 py-3 text-text-secondary align-top">
{log.userName}
</td>
<td className="px-4 py-3 text-text-muted text-xs align-top font-mono">
{log.details}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { stockService, type StockItem } from '../../services/stockService';
import api from '../../services/api';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface StockModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
initialData?: StockItem;
initialType?: 'PAINT' | 'THINNER';
}
export const StockModal: React.FC<StockModalProps> = ({ isOpen, onClose, onSuccess, initialData, initialType = 'PAINT' }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [dataSheets, setDataSheets] = useState<any[]>([]);
// Form Data
const [dataSheetId, setDataSheetId] = useState('');
const [rrNumber, setRrNumber] = useState('');
const [batchNumber, setBatchNumber] = useState('');
const [color, setColor] = useState('');
const [invoiceNumber, setInvoiceNumber] = useState('');
const [receivedBy, setReceivedBy] = useState('');
const [quantity, setQuantity] = useState('');
const [unit, setUnit] = useState('L');
const [expirationDate, setExpirationDate] = useState('');
const [minStock, setMinStock] = useState('');
const [notes, setNotes] = useState('');
useEffect(() => {
const fetchDataSheets = async () => {
try {
const res = await api.get('/datasheets'); // Assuming this endpoint exists and lists all
setDataSheets(res.data);
} catch (err) {
console.error("Error fetching datasheets", err);
}
};
if (isOpen) {
fetchDataSheets();
if (initialData) {
setDataSheetId(typeof initialData.dataSheetId === 'object' ? initialData.dataSheetId._id : initialData.dataSheetId);
setRrNumber(initialData.rrNumber);
setBatchNumber(initialData.batchNumber);
setColor(initialData.color || '');
setInvoiceNumber(initialData.invoiceNumber || '');
setReceivedBy(initialData.receivedBy || '');
setQuantity(String(initialData.quantity));
setUnit(initialData.unit);
setExpirationDate(initialData.expirationDate ? new Date(initialData.expirationDate).toISOString().split('T')[0] : '');
setMinStock(String(initialData.minStock || 0));
setNotes(initialData.notes || '');
} else {
// Reset form
setDataSheetId('');
setRrNumber('');
setBatchNumber('');
setColor('');
setInvoiceNumber('');
setReceivedBy('');
setQuantity('');
setUnit('L');
setExpirationDate('');
setMinStock('0');
setNotes('');
}
}
}, [isOpen, initialData]);
// Handle filling color etc if picking a DataSheet (Optional feature, not implemented yet)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
setLoading(true);
const payload: any = {
dataSheetId,
rrNumber,
batchNumber,
color,
invoiceNumber,
receivedBy,
unit,
expirationDate: expirationDate || undefined,
minStock: Number(minStock) || 0,
notes
};
// If creating, send quantity. If updating, DO NOT send quantity (handled via adjusts)
if (!initialData) {
payload.quantity = Number(quantity);
}
try {
if (initialData) {
await stockService.update(initialData._id!, payload);
} else {
await stockService.create(payload);
}
onSuccess();
} catch (error: any) {
console.error('Error saving stock item:', error);
alert(error.response?.data?.error || 'Erro ao salvar item.');
} finally {
setLoading(false);
}
};
const isThinner = initialData
? (typeof initialData.dataSheetId === 'object' && (initialData.dataSheetId.type === 'THINNER' || initialData.dataSheetId.type === 'DILUENTE'))
: (initialType === 'THINNER');
const filteredDataSheets = dataSheets.filter(ds => {
const dsType = ds.type || 'PAINT';
const isDsThinner = dsType === 'THINNER' || dsType === 'DILUENTE';
return isThinner ? isDsThinner : !isDsThinner;
});
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={initialData ? "Editar Detalhes do Lote" : `Nova Entrada de Estoque (${isThinner ? 'Diluente' : 'Tinta'})`}
>
<form onSubmit={handleSubmit} className="space-y-4">
<Select
label="Produto (Ficha Técnica)"
name="dataSheetId"
value={dataSheetId}
onChange={(e) => {
const val = e.target.value;
setDataSheetId(val);
// Auto-fill minStock from DataSheet if set and current is empty/0
const ds = dataSheets.find(d => d._id === val);
if (ds && ds.minStock && (!minStock || minStock === '0')) {
setMinStock(String(ds.minStock));
}
}}
options={filteredDataSheets.map(ds => ({ label: `${ds.name} - ${ds.manufacturer}`, value: ds._id }))}
disabled={!!initialData} // Lock product on edit
/>
<div className="grid grid-cols-2 gap-4">
<Input
label="RR (Rastreabilidade)"
name="rrNumber"
value={rrNumber}
onChange={(e) => setRrNumber(e.target.value)}
required
disabled={!!initialData} // Usually unique ID shouldn't change easily
/>
<Input
label="Lote Fabricante"
name="batchNumber"
value={batchNumber}
onChange={(e) => setBatchNumber(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Input
label="Nota Fiscal"
name="invoiceNumber"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
/>
<Input
label="Recebido Por"
name="receivedBy"
value={receivedBy}
onChange={(e) => setReceivedBy(e.target.value)}
/>
</div>
{!isThinner && (
<Input
label="Cor"
name="color"
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="Ex: Amarelo Segurança, CINZA N6.5"
/>
)}
{!initialData && (
<div className="grid grid-cols-2 gap-4">
<Input
label="Quantidade Inicial"
name="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
required
/>
<Select
label="Unidade"
name="unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
options={[
{ label: 'Litros (L)', value: 'L' },
{ label: 'Galões (Gal)', value: 'Gal' },
{ label: 'Quartos (Qt)', value: 'Qt' },
{ label: 'Kg', value: 'Kg' },
{ label: 'Unidade (Un)', value: 'Un' }
]}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{!isThinner && (
<Input
label="Data de Validade"
name="expirationDate"
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
/>
)}
<div className={isThinner ? "col-span-2" : ""}>
<Input
label="Estoque Mínimo (L)"
name="minStock"
type="number"
value={minStock}
onChange={(e) => setMinStock(e.target.value)}
placeholder="Qtd de alerta"
/>
</div>
</div>
<Input
label="Observações"
name="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</form>
</Modal >
);
};

View File

@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { Modal } from '../Modal';
import { Input } from '../Input';
import { Button } from '../Button';
import { Select } from '../Select';
import { stockService, type StockItem } from '../../services/stockService';
import { useAuth } from '../../context/useAuth';
import { useToast } from '../../hooks/useToast';
interface StockOutModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
item: StockItem;
}
export const StockOutModal: React.FC<StockOutModalProps> = ({ isOpen, onClose, onSuccess, item }) => {
const { isGuest } = useAuth();
const { showGuestWarning } = useToast();
const [loading, setLoading] = useState(false);
const [type, setType] = useState<'CONSUMPTION' | 'ADJUSTMENT'>('CONSUMPTION');
const [quantity, setQuantity] = useState('');
// Adjustment fields
const [reason, setReason] = useState('');
// Consumption fields
const [requester, setRequester] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isGuest()) {
showGuestWarning();
return;
}
const qtyNum = Number(quantity);
if (!qtyNum || qtyNum <= 0) {
alert('Quantidade deve ser maior que zero.');
return;
}
if (type === 'ADJUSTMENT' && !reason) {
alert('Motivo é obrigatório para ajustes.');
return;
}
if (type === 'CONSUMPTION' && !requester) {
alert('Solicitante é obrigatório para consumo.');
return;
}
setLoading(true);
try {
if (type === 'ADJUSTMENT') {
// Adjust can be positive or negative, but here we frame it as "Stock Out" mostly?
// Actually the requirement is "Two systems of Stock Out". So Adjustment implies REMOVING?
// Or "Correction" which could be adding?
// Let's assume this modal is generic, but usually used for outs.
// However, "Baixa por ajuste técnico" implies reducing.
// But typically adjustment allows both. Let's send negative quantity for reducing.
await stockService.adjust(item._id!, {
quantityDelta: -qtyNum, // Negative for removal
reason
});
} else {
await stockService.consume(item._id!, {
quantityConsumed: qtyNum,
requester,
date
});
}
onSuccess();
} catch (error: any) {
console.error('Error processing stock out:', error);
alert(error.response?.data?.error || 'Erro ao realizar baixa.');
} finally {
setLoading(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Realizar Baixa - ${item.rrNumber}`}
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-surface-soft p-4 rounded-xl border border-border/40 mb-4">
<p className="text-sm font-semibold text-text-muted">Item Selecionado:</p>
<p className="text-lg font-bold text-text-main">
{typeof item.dataSheetId === 'object' ? item.dataSheetId.name : 'Produto'}
</p>
<p className="text-sm text-text-secondary">Lote: {item.batchNumber} | Cor: {item.color || '-'}</p>
<p className="text-sm text-text-secondary mt-1">
Disponível: <span className="font-bold text-green-500">{item.quantity} {item.unit}</span>
</p>
</div>
<Select
label="Tipo de Baixa"
name="type"
value={type}
onChange={(e) => setType(e.target.value as any)}
options={[
{ label: 'Consumo em Obra', value: 'CONSUMPTION' },
{ label: 'Ajuste Técnico / Perda', value: 'ADJUSTMENT' }
]}
/>
<Input
label="Quantidade a Baixar"
name="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
required
placeholder={`Max: ${item.quantity}`}
/>
{type === 'ADJUSTMENT' ? (
<Input
label="Motivo do Ajuste"
name="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex: Material vencido, Pote danificado, Desperdício teste..."
required
/>
) : (
<>
<Input
label="Solicitante (Encarregado/Pintor)"
name="requester"
value={requester}
onChange={(e) => setRequester(e.target.value)}
required
/>
<Input
label="Data do Consumo"
name="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</>
)}
<div className="flex justify-end gap-2 mt-6">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button type="submit" variant="danger" disabled={loading}>
{loading ? 'Processando...' : 'Confirmar Baixa'}
</Button>
</div>
</form>
</Modal>
);
};