import React, { useState, useEffect, useRef } from 'react';
import { Camera, Send, FileText, User, LogOut, CheckCircle, XCircle, Download, Search, Loader2, Eye, ScanLine, DollarSign, Calendar, Plus, Trash2, Save, Edit3 } from 'lucide-react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, onAuthStateChanged } from 'firebase/auth';
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, updateDoc, doc, deleteDoc } from 'firebase/firestore';
// --- Configuração do Firebase ---
const firebaseConfig = JSON.parse(__firebase_config);
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
// --- Carregar Tesseract.js ---
const loadTesseract = () => {
return new Promise((resolve, reject) => {
if (window.Tesseract) {
resolve(window.Tesseract);
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js';
script.onload = () => resolve(window.Tesseract);
script.onerror = reject;
document.head.appendChild(script);
});
};
// --- Componente Principal ---
export default function MaterialManagerApp() {
const [user, setUser] = useState(null);
const [userName, setUserName] = useState('');
const [userRole, setUserRole] = useState(null);
const [loadingAuth, setLoadingAuth] = useState(true);
useEffect(() => {
const initAuth = async () => {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
// Token customizado
} else {
await signInAnonymously(auth);
}
};
initAuth();
loadTesseract().catch(err => console.log("Erro OCR:", err));
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
const savedName = localStorage.getItem('tech_app_name');
const savedRole = localStorage.getItem('tech_app_role');
if (savedName) setUserName(savedName);
if (savedRole) setUserRole(savedRole);
setLoadingAuth(false);
});
return () => unsubscribe();
}, []);
const handleLogin = (name, role) => {
setUserName(name);
setUserRole(role);
localStorage.setItem('tech_app_name', name);
localStorage.setItem('tech_app_role', role);
};
const handleLogout = () => {
setUserName('');
setUserRole(null);
localStorage.removeItem('tech_app_name');
localStorage.removeItem('tech_app_role');
};
if (loadingAuth) return
;
if (!userName || !userRole) {
return ;
}
return (
Gestão de Materiais
Gestão
{userRole === 'admin' ? 'Controle de Estoque/Custos' : 'App do Técnico'}
{userName}
{userRole === 'tech' ? 'Técnico' : 'Administrador'}
{userRole === 'tech' ? (
) : (
)}
);
}
// --- Tela de Login ---
function LoginPage({ onLogin }) {
const [name, setName] = useState('');
const [role, setRole] = useState('tech');
return (
);
}
// --- Visão do Técnico (Upload Simples) ---
function TechnicianView({ userName, user }) {
const [osNumber, setOsNumber] = useState('');
const [unit, setUnit] = useState('');
const [amount, setAmount] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const [image, setImage] = useState(null);
const [preview, setPreview] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [feedback, setFeedback] = useState(null);
const fileInputRef = useRef(null);
const handleImageChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 1000;
const scaleSize = MAX_WIDTH / img.width;
canvas.width = MAX_WIDTH;
canvas.height = img.height * scaleSize;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7);
setImage(compressedDataUrl);
setPreview(compressedDataUrl);
};
};
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!user) return;
if (!image) { setFeedback({ type: 'error', msg: 'Foto da nota é obrigatória.' }); return; }
setIsSubmitting(true);
try {
// Cria a nota com status 'pendente' e array de itens vazio (para o ADM preencher)
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'receipts'), {
osNumber,
unit,
totalAmount: parseFloat(amount.replace(',', '.')) || 0, // Valor total estimado pelo técnico
date,
technicianName: userName,
technicianId: user.uid,
imageUrl: image,
timestamp: new Date().toISOString(),
status: 'pendente',
items: [] // Array vazio para ser populado pela ADM
});
setFeedback({ type: 'success', msg: 'Nota enviada com sucesso!' });
setOsNumber(''); setUnit(''); setAmount(''); setImage(null); setPreview(null);
if (fileInputRef.current) fileInputRef.current.value = '';
setTimeout(() => setFeedback(null), 3000);
} catch (error) {
console.error(error);
setFeedback({ type: 'error', msg: 'Erro ao enviar.' });
} finally {
setIsSubmitting(false);
}
};
return (
Lançar Nota de Material
{feedback && (
{feedback.type === 'success' ? : }
{feedback.msg}
)}
);
}
// --- Modal de Extração de Itens (ADM) ---
function ItemExtractionModal({ receipt, onClose, onSave }) {
const [items, setItems] = useState(receipt.items || []);
const [isProcessing, setIsProcessing] = useState(false);
const [newItem, setNewItem] = useState({ name: '', qty: 1, price: '' });
const [totalCheck, setTotalCheck] = useState(0);
// Calcula o total ao vivo
useEffect(() => {
const total = items.reduce((acc, item) => acc + (item.qty * item.price), 0);
setTotalCheck(total);
}, [items]);
const runOCR = async () => {
setIsProcessing(true);
try {
const Tesseract = await loadTesseract();
const result = await Tesseract.recognize(receipt.imageUrl, 'por', {
logger: m => {} // logs desativados para limpar console
});
const lines = result.data.text.split('\n');
const suggestedItems = [];
// Lógica simples para tentar achar linhas com preço
// Procura linhas que terminam com padrao monetario ex: 10,00 ou 10.00
const priceRegex = /(\d+)[.,](\d{2})\s*$/;
lines.forEach(line => {
const cleanLine = line.trim();
const match = cleanLine.match(priceRegex);
if (match && cleanLine.length > 5) {
// Se achou preço, assume que o resto é o nome
const priceStr = match[0].replace(',', '.');
const price = parseFloat(priceStr);
const name = cleanLine.replace(match[0], '').trim();
// Tenta achar quantidade no inicio (ex: 2x Coca Cola)
let qty = 1;
const qtyMatch = name.match(/^(\d+)[xX\s]/);
let cleanName = name;
if (qtyMatch) {
qty = parseInt(qtyMatch[1]);
cleanName = name.substring(qtyMatch[0].length).trim();
}
// Filtra ruídos muito curtos
if (cleanName.length > 2) {
suggestedItems.push({
id: Date.now() + Math.random(),
name: cleanName,
qty: qty,
price: price
});
}
}
});
if (suggestedItems.length > 0) {
// Adiciona aos itens existentes
setItems([...items, ...suggestedItems]);
} else {
alert("O OCR leu o texto, mas não identificou o padrão de itens automaticamente. O texto bruto pode estar bagunçado. Por favor, insira manualmente consultando a imagem.");
}
} catch (err) {
console.error(err);
alert("Erro ao processar imagem.");
} finally {
setIsProcessing(false);
}
};
const addItem = () => {
if (!newItem.name || !newItem.price) return;
setItems([...items, { ...newItem, id: Date.now(), price: parseFloat(newItem.price) }]);
setNewItem({ name: '', qty: 1, price: '' });
};
const removeItem = (id) => {
setItems(items.filter(i => i.id !== id));
};
return (
{/* Lado Esquerdo: Imagem */}
Referência Visual
Dica: A IA sugere,
mas você valida.
{/* Lado Direito: Tabela de Edição */}
Detalhamento de Materiais
{/* Tabela de Itens */}
{/* Adicionar Manual */}
{/* Rodapé com Totais e Salvar */}
Total Calculado
R$ {totalCheck.toLocaleString('pt-BR', {minimumFractionDigits: 2})}
{receipt.totalAmount > 0 && Math.abs(totalCheck - receipt.totalAmount) > 1 && (
⚠ Difere do total da nota (R$ {receipt.totalAmount})
)}
);
}
// --- Visão do Administrador ---
function AdminDashboard({ user }) {
const [receipts, setReceipts] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [editingReceipt, setEditingReceipt] = useState(null); // Nota sendo editada no modal
useEffect(() => {
if (!user) return;
const q = query(collection(db, 'artifacts', appId, 'public', 'data', 'receipts'), orderBy('timestamp', 'desc'));
const unsubscribe = onSnapshot(q, (snapshot) => {
setReceipts(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })));
setLoading(false);
});
return () => unsubscribe();
}, [user]);
const saveItems = async (receiptId, items) => {
try {
// Recalcula o total baseado nos itens
const newTotal = items.reduce((acc, i) => acc + (i.qty * i.price), 0);
await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'receipts', receiptId), {
items: items,
totalAmount: newTotal,
status: 'processado' // Marca automaticamente como processado
});
setEditingReceipt(null);
} catch (e) {
console.error("Erro ao salvar itens", e);
alert("Erro ao salvar.");
}
};
const handleDelete = async (id) => {
if(confirm("Excluir permanentemente?")) {
await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'receipts', id));
}
};
// --- Exportação Excel (Granular: Linha por Material) ---
const exportToCSV = () => {
const BOM = "\uFEFF";
const headers = ['Data Nota', 'Técnico', 'OS', 'Unidade', 'Material/Item', 'Quantidade', 'Valor Unit (R$)', 'Valor Total (R$)'];
let csvRows = [];
receipts.forEach(r => {
const noteDate = r.date ? new Date(r.date).toLocaleDateString('pt-BR') : '-';
if (r.items && r.items.length > 0) {
// Se tem itens detalhados, cria uma linha para cada item
r.items.forEach(item => {
csvRows.push([
noteDate,
`"${r.technicianName}"`,
`"${r.osNumber}"`,
`"${r.unit}"`,
`"${item.name}"`, // O material em si
item.qty.toString().replace('.', ','),
item.price.toString().replace('.', ','),
(item.qty * item.price).toString().replace('.', ',')
].join(';'));
});
} else {
// Se não tem itens detalhados (nota fechada), cria uma linha genérica
csvRows.push([
noteDate,
`"${r.technicianName}"`,
`"${r.osNumber}"`,
`"${r.unit}"`,
`"NOTA FECHADA (SEM DETALHE)"`,
"1",
r.totalAmount ? r.totalAmount.toString().replace('.', ',') : '0,00',
r.totalAmount ? r.totalAmount.toString().replace('.', ',') : '0,00',
].join(';'));
}
});
const csvContent = BOM + [headers.join(';'), ...csvRows].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.setAttribute('download', `materiais_detalhados_${new Date().toISOString().slice(0,10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const filteredReceipts = receipts.filter(r =>
r.technicianName.toLowerCase().includes(filter.toLowerCase()) ||
r.osNumber.includes(filter) ||
r.unit.toLowerCase().includes(filter.toLowerCase())
);
return (
{/* Barra de Ferramentas */}
{/* Lista de Notas */}
{loading ? (
) : (
{filteredReceipts.map((receipt) => (
setEditingReceipt(receipt)}>
{receipt.technicianName}
OS: {receipt.osNumber}
{receipt.unit} • {new Date(receipt.date).toLocaleDateString('pt-BR')}
{receipt.status}
{receipt.items && receipt.items.length > 0 && (
{receipt.items.length} Itens detalhados
)}
R$ {receipt.totalAmount?.toLocaleString('pt-BR', {minimumFractionDigits: 2})}
))}
)}
{/* Modal de Edição */}
{editingReceipt && (
setEditingReceipt(null)}
onSave={saveItems}
/>
)}
);
}