✅ Restauração do código oficial do GPI-JWT-V3
This commit is contained in:
93
src/client/components/modals/CloneSchemeModal.tsx
Normal file
93
src/client/components/modals/CloneSchemeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
463
src/client/components/modals/CreateControlRecordModal.tsx
Normal file
463
src/client/components/modals/CreateControlRecordModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
444
src/client/components/modals/CreateInspectionModal.tsx
Normal file
444
src/client/components/modals/CreateInspectionModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
374
src/client/components/modals/CreatePaintingSchemeModal.tsx
Normal file
374
src/client/components/modals/CreatePaintingSchemeModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
167
src/client/components/modals/CreatePartModal.tsx
Normal file
167
src/client/components/modals/CreatePartModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
215
src/client/components/modals/CreateProjectModal.tsx
Normal file
215
src/client/components/modals/CreateProjectModal.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
175
src/client/components/modals/DiluentListModal.tsx
Normal file
175
src/client/components/modals/DiluentListModal.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
150
src/client/components/modals/DiluentRegistrationModal.tsx
Normal file
150
src/client/components/modals/DiluentRegistrationModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
183
src/client/components/modals/ImportSchemeModal.tsx
Normal file
183
src/client/components/modals/ImportSchemeModal.tsx
Normal 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 já 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>
|
||||
);
|
||||
};
|
||||
377
src/client/components/modals/StockHistoryModal.tsx
Normal file
377
src/client/components/modals/StockHistoryModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
268
src/client/components/modals/StockModal.tsx
Normal file
268
src/client/components/modals/StockModal.tsx
Normal 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 >
|
||||
);
|
||||
};
|
||||
165
src/client/components/modals/StockOutModal.tsx
Normal file
165
src/client/components/modals/StockOutModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user