import React, { useState, useEffect, useRef } from 'react';
import { initializeApp } from 'firebase/app';
import {
getAuth,
onAuthStateChanged,
signInWithEmailAndPassword,
signOut,
} from 'firebase/auth';
import {
getFirestore,
collection,
addDoc,
getDocs,
query,
where,
doc,
updateDoc,
onSnapshot,
setDoc,
deleteDoc,
runTransaction,
getDoc
} from 'firebase/firestore';
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
// --- ÍCONOS SVG ---
const PlusIcon = () => ;
const HomeIcon = () => ;
const DocumentTextIcon = () => ;
const UsersIcon = () => ;
const CollectionIcon = () => ;
const ChartBarIcon = () => ;
const CashIcon = () => ;
const LogoutIcon = () => ;
const MenuIcon = () => ;
const XIcon = () => ;
const TrashIcon = () => ;
const PencilIcon = () => ;
const PrinterIcon = () => ;
const UserGroupIcon = () => ;
const ReceiptIcon = () => ;
const SortIcon = ({ direction, active }) => (
{direction === 'asc' ? '↑' : '↓'}
);
// --- CONFIGURACIÓN DE FIREBASE ---
const firebaseConfig = {
apiKey: "AIzaSyBjSRsPF-dgdvAbBASb5kNh39C5VgXMC2o",
authDomain: "miappdeventas-f0318.firebaseapp.com",
projectId: "miappdeventas-f0318",
storageBucket: "miappdeventas-f0318.appspot.com",
messagingSenderId: "471807009356",
appId: "1:471807009356:web:95d3aabc797bf55a550ae9"
};
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
// --- COMPONENTES DE LA UI ---
const Spinner = () => (
);
const getLocalDateString = (date = new Date()) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const Modal = ({ children, isOpen, onClose, title }) => {
if (!isOpen) return null;
return (
{String(title)}
{children}
);
};
// --- PÁGINA DE LOGIN ---
const LoginPage = ({ setUserData }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signInWithEmailAndPassword(auth, email, password);
} catch (err) {
setError('Usuario o contraseña incorrectos. Por favor, intente de nuevo.');
setIsLoading(false);
}
};
return (
Publi Server
Diseño Gráfico
Iniciar Sesión
);
};
// --- DASHBOARD ---
const Dashboard = ({ userData, workOrders, users }) => {
const statusCounts = workOrders.reduce((acc, order) => {
const s = order.status || 'pendiente';
acc[s] = (acc[s] || 0) + 1;
return acc;
}, {});
const statusData = [
{ name: 'Pendiente', value: statusCounts.pendiente || 0 },
{ name: 'En Proceso', value: statusCounts.en_proceso || 0 },
{ name: 'Listo', value: statusCounts.listo || 0 },
{ name: 'Completado', value: statusCounts.completado || 0 },
];
const COLORS = ['#FF8042', '#0088FE', '#FFBB28', '#00C49F'];
const salesByVendor = workOrders.reduce((acc, order) => {
const vendorName = String(order.vendorName || 'Sin Vendedor');
const total = parseFloat(order.total) || 0;
acc[vendorName] = (acc[vendorName] || 0) + total;
return acc;
}, {});
const salesData = Object.keys(salesByVendor).map(name => ({
name,
total: salesByVendor[name]
}));
const totalPendingBalance = workOrders
.filter(order => order.status !== 'completado')
.reduce((sum, order) => sum + (parseFloat(order.balance) || 0), 0);
const stats = [
{ name: 'Total Órdenes', value: workOrders.length, color: 'bg-blue-600' },
{ name: 'En Taller', value: (statusCounts.pendiente || 0) + (statusCounts.en_proceso || 0), color: 'bg-yellow-500' },
{ name: 'Listo Entrega', value: statusCounts.listo || 0, color: 'bg-green-500' },
{ name: 'Saldo Global', value: totalPendingBalance, color: 'bg-orange-500', isCurrency: true },
];
return (
Panel de Control General
{stats.map(stat => (
{stat.name}
{stat.isCurrency ? `$${stat.value.toFixed(2)}` : stat.value}
))}
Facturación por Vendedor ($)
{userData.role === 'admin' ? (
) : (
Datos restringidos al Administrador
)}
Distribución de Estados
{statusData.map((entry, index) => (
|
))}
);
};
// --- GESTIÓN DE PRODUCTOS / SERVICIOS ---
const ProductCatalog = ({ services, onSave, onDelete }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState(null);
const [formData, setFormData] = useState({ code: '', name: '', price: '' });
useEffect(() => {
if (editingProduct) {
setFormData({ code: String(editingProduct.code || ''), name: String(editingProduct.name), price: String(editingProduct.price) });
} else {
setFormData({ code: '', name: '', price: '' });
}
}, [editingProduct]);
const handleSave = () => {
onSave({ ...editingProduct, ...formData, price: parseFloat(String(formData.price).replace(',', '.')) || 0 });
setIsModalOpen(false);
setEditingProduct(null);
};
return (
Catálogo de Productos y Servicios
| Código |
Descripción |
Precio Base |
Acciones |
{services.map(s => (
| {String(s.code || '-')} |
{String(s.name)} |
${parseFloat(s.price || 0).toFixed(2)} |
|
))}
setIsModalOpen(false)} title={editingProduct ? "Editar Producto" : "Nuevo Producto"}>
);
};
// --- GESTIÓN DE USUARIOS ---
const UsersManager = ({ users, onSave, onDelete }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [formData, setFormData] = useState({ name: '', email: '', role: 'vendedor' });
useEffect(() => {
if (editingUser) {
setFormData({ name: String(editingUser.name), email: String(editingUser.email), role: String(editingUser.role) });
} else {
setFormData({ name: '', email: '', role: 'vendedor' });
}
}, [editingUser]);
const handleSave = () => {
if (!formData.name || !formData.email) {
alert("Nombre y email son obligatorios.");
return;
}
onSave({ ...editingUser, ...formData });
setIsModalOpen(false);
setEditingUser(null);
};
return (
);
};
// --- FORMULARIO CLIENTE ---
const CustomerForm = ({ customer, onSave, onCancel }) => {
const [formData, setFormData] = useState({
idNumber: String(customer?.idNumber || ''),
phone: String(customer?.phone || ''),
email: String(customer?.email || ''),
address: String(customer?.address || ''),
fullName: customer?.name ? `${customer.name} ${customer.lastName}`.trim() : '',
});
const handleSubmit = (e) => {
e.preventDefault();
const nameParts = formData.fullName.trim().split(' ');
const name = nameParts.shift() || '';
const lastName = nameParts.join(' ') || '';
const dataToSave = {
idNumber: formData.idNumber,
phone: formData.phone,
email: formData.email,
address: formData.address,
name,
lastName,
};
if (customer && customer.id) dataToSave.id = customer.id;
onSave(dataToSave);
};
return (
);
};
const CustomersList = ({ customers, onEdit }) => {
const [searchTerm, setSearchTerm] = useState('');
const filtered = customers.filter(c =>
String(`${c.name} ${c.lastName} ${c.idNumber}`).toLowerCase().includes(searchTerm.toLowerCase())
);
return (
);
};
// --- GESTIÓN DE ÓRDENES DE TRABAJO ---
const WorkOrderForm = ({ order, onSave, onCancel, customers, services, users, userData }) => {
const initialFormData = {
creationDate: getLocalDateString(),
deliveryDate: '',
customerId: '',
customerName: '',
vendorId: String(userData.uid),
vendorName: String(userData.name),
status: 'pendiente',
items: [{ id: Date.now(), code: '', description: '', quantity: "1", unitPrice: "0", subtotal: 0 }],
subtotal: 0,
iva: 0,
totalIva: 0,
total: 0,
payments: [],
balance: 0,
notes: ''
};
const [formData, setFormData] = useState((order && order.id) ? { ...order, iva: order.iva !== undefined ? order.iva : 0 } : initialFormData);
const [customerSearch, setCustomerSearch] = useState('');
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [showServiceDropdown, setShowServiceDropdown] = useState(null);
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false);
const [validationError, setValidationError] = useState('');
useEffect(() => {
if (order && order.id) {
setFormData({ ...order, iva: order.iva !== undefined ? order.iva : 0 });
} else {
setFormData(initialFormData);
}
}, [order]);
useEffect(() => {
const calculateTotals = () => {
const subtotal = (formData.items || []).reduce((acc, item) => {
const price = parseFloat(String(item.unitPrice || 0).replace(',', '.')) || 0;
const qty = parseFloat(String(item.quantity || 0).replace(',', '.')) || 0;
return acc + (qty * price);
}, 0);
const ivaRate = (parseFloat(String(formData.iva || 0).replace(',', '.')) || 0) / 100;
const totalIva = subtotal * ivaRate;
const total = subtotal + totalIva;
const totalPaid = (formData.payments || []).reduce((acc, p) => {
return acc + (parseFloat(String(p.amount || 0).replace(',', '.')) || 0);
}, 0);
const balance = total - totalPaid;
setFormData(prev => ({ ...prev, subtotal, totalIva, total, balance }));
};
calculateTotals();
}, [formData.items, formData.iva, formData.payments]);
const handleItemChange = (index, field, value) => {
const newItems = [...formData.items];
newItems[index][field] = value;
if (field === 'code' && value.trim() !== '') {
const matchedProduct = services.find(s => (String(s.code) || '').toLowerCase() === value.toLowerCase());
if (matchedProduct) {
newItems[index].description = String(matchedProduct.name);
newItems[index].unitPrice = String(matchedProduct.price);
}
}
if (field === 'quantity' || field === 'unitPrice' || field === 'code') {
const qty = parseFloat(String(newItems[index].quantity || 0).replace(',', '.')) || 0;
const price = parseFloat(String(newItems[index].unitPrice || 0).replace(',', '.')) || 0;
newItems[index].subtotal = qty * price;
}
setFormData({ ...formData, items: newItems });
};
const handlePaymentChange = (index, field, value) => {
const newPayments = [...formData.payments];
newPayments[index][field] = value;
setFormData({ ...formData, payments: newPayments });
};
const handleCustomerSelect = (customer) => {
setFormData({ ...formData, customerId: String(customer.id), customerName: String(`${customer.name} ${customer.lastName}`) });
setCustomerSearch('');
setShowCustomerDropdown(false);
setValidationError('');
};
const handleServiceSelectFromDropdown = (product, itemIndex) => {
const newItems = [...formData.items];
newItems[itemIndex].code = String(product.code || '');
newItems[itemIndex].description = String(product.name);
newItems[itemIndex].unitPrice = String(product.price);
const qty = parseFloat(String(newItems[itemIndex].quantity || 1).replace(',', '.')) || 1;
newItems[itemIndex].subtotal = qty * product.price;
setFormData({ ...formData, items: newItems });
setShowServiceDropdown(null);
};
const handleFormSubmit = () => {
if (!formData.customerId) {
setValidationError('⚠️ Error: Debe asignar un cliente a la orden de trabajo antes de guardar.');
return;
}
setValidationError('');
onSave(formData);
};
const selectedCustomerDetails = customers.find(c => c.id === formData.customerId);
return (
Formulario de Orden
{validationError && (
{validationError}
)}
Receptor del Trabajo
{formData.customerId ? (
{String(formData.customerName)}
{selectedCustomerDetails && (
📞 Telf: {String(selectedCustomerDetails.phone || '-')}
📍 Dir: {String(selectedCustomerDetails.address || '-')}
)}
) : (
{ setCustomerSearch(e.target.value); setShowCustomerDropdown(true); }}
onFocus={() => setShowCustomerDropdown(true)}
placeholder="🔎 Escriba nombre del cliente..."
className="w-full p-4 border rounded-2xl shadow-inner focus:ring-4 focus:ring-blue-50 outline-none font-bold text-lg"
/>
{showCustomerDropdown && (
{customers.filter(c => String(`${c.name} ${c.lastName} ${c.idNumber}`).toLowerCase().includes(customerSearch.toLowerCase())).map(c => (
handleCustomerSelect(c)} className="p-4 hover:bg-blue-600 hover:text-white cursor-pointer border-b last:border-b-0 transition-colors group">
{String(c.name)} {String(c.lastName)}
ID: {String(c.idNumber)}
))}
)}
)}
| Código |
Descripción de Trabajo |
Cant. |
Unitario |
Total |
|
{(formData.items || []).map((item, index) => (
|
handleItemChange(index, 'code', e.target.value)}
placeholder="Escaneo..."
className="w-full p-3 border border-transparent focus:border-blue-400 rounded-xl text-xs font-mono font-black outline-none bg-transparent"
/>
|
handleItemChange(index, 'description', e.target.value)}
onFocus={() => setShowServiceDropdown(index)}
onBlur={() => {
setTimeout(() => { if (showServiceDropdown === index) setShowServiceDropdown(null); }, 200);
}}
className="w-full p-3 border border-transparent focus:border-blue-400 rounded-xl text-xs font-black uppercase tracking-tight outline-none bg-transparent"
placeholder="Escriba para buscar en catálogo..."
/>
{showServiceDropdown === index && (
{services
.filter(s => String(s.name || '').toLowerCase().includes(String(item.description || '').toLowerCase()) || String(s.code || '').toLowerCase().includes(String(item.description || '').toLowerCase()))
.map(s => (
{ e.preventDefault(); handleServiceSelectFromDropdown(s, index); }}
className="p-4 hover:bg-blue-600 hover:text-white text-[11px] cursor-pointer border-b last:border-0 transition-colors flex justify-between items-center font-bold"
>
{String(s.code)}
{String(s.name)}
${parseFloat(s.price).toFixed(2)}
))}
)}
|
handleItemChange(index, 'quantity', e.target.value)} className="w-full p-3 border border-transparent focus:border-blue-400 rounded-xl text-sm text-center font-black bg-transparent" />
|
handleItemChange(index, 'unitPrice', e.target.value)}
className="w-full p-3 border border-transparent focus:border-blue-400 rounded-xl text-sm font-black text-blue-700 text-center bg-transparent"
placeholder="0.00"
/>
|
${parseFloat(item.subtotal || 0).toFixed(2)} |
|
))}
Cómputo Final
Subtotal:${(formData.subtotal || 0).toFixed(2)}
VALOR TOTAL:
${(formData.total || 0).toFixed(2)}
Saldo Pendiente:
${(formData.balance || 0).toFixed(2)}
Gestión de Abonos
{(formData.payments || []).map((p, i) => (
handlePaymentChange(i, 'date', e.target.value)} className="p-2 border rounded-xl text-xs font-black text-gray-700 bg-white shadow-sm outline-none" />
handlePaymentChange(i, 'amount', e.target.value)} className="p-2 border border-green-300 rounded-xl text-base font-black text-green-800 bg-white shadow-inner text-center" placeholder="0.00" />
{p.method === 'transferencia' && (
handlePaymentChange(i, 'reference', e.target.value)} className="p-2 border border-blue-200 rounded-xl text-[10px] bg-blue-50 w-full font-black focus:ring-2 focus:ring-blue-400 outline-none uppercase" />
)}
))}
setIsCustomerModalOpen(false)} title="Agregar Nuevo Cliente">
{ if (data.id) updateDoc(doc(db, "customers", data.id), data); else addDoc(collection(db, "customers"), data); setIsCustomerModalOpen(false); }} onCancel={() => setIsCustomerModalOpen(false)} />
);
};
// --- LISTA DE ÓRDENES DE TRABAJO ---
const WorkOrdersList = ({ workOrders, onEdit, onDelete, onPrint, userData, customers }) => {
const [filterStatus, setFilterStatus] = useState('todos');
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'creationDate', direction: 'desc' });
const requestSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const filteredOrders = workOrders.filter(order => {
const status = order.status ? String(order.status).trim().toLowerCase() : '';
if (filterStatus === 'todos' && status === 'completado') return false;
if (filterStatus !== 'todos' && filterStatus !== 'todos_visibles' && status !== filterStatus) return false;
if (customerSearchTerm) {
const search = customerSearchTerm.toLowerCase();
const customer = customers.find(c => String(c.id) === String(order.customerId));
const customerName = String(order.customerName || '').toLowerCase();
const idNumber = customer && customer.idNumber ? String(customer.idNumber).toLowerCase() : '';
if (!customerName.includes(search) && !idNumber.includes(search)) return false;
}
return true;
});
const sortedOrders = [...filteredOrders].sort((a, b) => {
let aValue = a[sortConfig.key];
let bValue = b[sortConfig.key];
if (sortConfig.key === 'orderNumber') {
const numA = parseInt(String(aValue).replace('OT-', '')) || 0;
const numB = parseInt(String(bValue).replace('OT-', '')) || 0;
return sortConfig.direction === 'asc' ? numA - numB : numB - numA;
}
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
const totalBalance = sortedOrders.reduce((sum, o) => sum + (parseFloat(o.balance) || 0), 0);
return (
setCustomerSearchTerm(e.target.value)} placeholder="🔎 Buscar por nombre o cédula..." className="p-4 border rounded-2xl flex-grow min-w-[150px] shadow-inner focus:ring-4 focus:ring-blue-50 outline-none font-bold uppercase text-[11px]" />
Saldo total pendiente:
${totalBalance.toFixed(2)}
| requestSort('orderNumber')} className="px-6 py-5 cursor-pointer hover:bg-gray-200 transition-colors group border-r border-gray-200">
# OT
|
Cliente / Entidad |
requestSort('creationDate')} className="px-6 py-5 cursor-pointer hover:bg-gray-200 transition-colors border-r border-gray-200 text-center">Fecha |
Vendedor |
Estado |
Deuda |
Acciones |
{sortedOrders.map(order => (
| {String(order.orderNumber)} |
{String(order.customerName)} |
{String(order.creationDate)} |
{String(order.vendorName || 'S/N')} |
{String(order.status)}
|
${parseFloat(order.balance || 0).toFixed(2)} |
{userData.role === 'admin' && }
|
))}
{sortedOrders.length === 0 &&
Cero resultados
}
);
};
// --- GESTIÓN DE GASTOS ---
const ExpensesManager = ({ expenses, userData, onSave, onDelete }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentExpense, setCurrentExpense] = useState({ date: getLocalDateString(), description: '', amount: '' });
const [filterDate, setFilterDate] = useState(getLocalDateString());
const handleSave = () => {
const amount = parseFloat(String(currentExpense.amount).replace(',', '.'));
if (!currentExpense.description || isNaN(amount) || amount <= 0) {
alert("⚠️ Descripción y monto son obligatorios.");
return;
}
onSave({ ...currentExpense, amount, userId: String(userData.uid), userName: String(userData.name) });
setIsModalOpen(false);
};
const filtered = (expenses || []).filter(e => String(e.date) === filterDate);
return (
);
};
// --- COMPONENTE REPORTE DE CIERRE ---
const CashClosingReport = ({ workOrders, expenses, users, userData }) => {
const [selectedDate, setSelectedDate] = useState(getLocalDateString());
const [selectedUser, setSelectedUser] = useState('');
const payments = [];
workOrders.forEach(o => {
if (selectedUser && o.vendorId !== selectedUser) return;
(o.payments || []).forEach(p => {
if (p.date === selectedDate) {
const jobsSummary = (o.items || []).map(i => String(i.description)).join(', ');
payments.push({
...p,
ot: String(o.orderNumber),
cliente: String(o.customerName),
trabajos: jobsSummary,
vendedor: String(o.vendorName || 'S/N')
});
}
});
});
const dailyExpenses = (expenses || []).filter(e => {
const matchDate = String(e.date) === selectedDate;
const matchUser = !selectedUser || String(e.userId) === selectedUser;
return matchDate && matchUser;
});
const totalCash = payments.filter(p => p.method !== 'transferencia').reduce((sum, p) => sum + parseFloat(String(p.amount || 0).replace(',', '.')), 0);
const totalTrans = payments.filter(p => p.method === 'transferencia').reduce((sum, p) => sum + parseFloat(String(p.amount || 0).replace(',', '.')), 0);
const totalExp = dailyExpenses.reduce((sum, e) => sum + parseFloat(String(e.amount || 0).replace(',', '.')), 0);
return (
Cierre Diario de Caja
setSelectedDate(e.target.value)} className="p-4 border rounded-2xl w-full font-black text-gray-800 outline-none focus:ring-4 focus:ring-blue-50 bg-gray-50 shadow-inner" />
{userData.role === 'admin' && (
)}
Físico Recaudado
${totalCash.toFixed(2)}
Transferencias
${totalTrans.toFixed(2)}
Egresos / Gastos
-${totalExp.toFixed(2)}
Efectivo Real en Caja
${(totalCash - totalExp).toFixed(2)}
Base: Efectivo - Gastos
Detalle de Operaciones Diarias
RECAUDACIÓN BRUTA: ${(totalCash + totalTrans).toFixed(2)}
| # OT |
Cliente |
Detalle Trabajos |
Monto |
Modo |
Referencia |
{payments.map((p, i) => (
| {String(p.ot)} |
{String(p.cliente)} |
{String(p.trabajos || 'S/D')}
|
${parseFloat(String(p.amount).replace(',', '.')).toFixed(2)} |
{String(p.method || 'Efectivo')}
|
{p.method === 'transferencia' ? String(p.reference || 'N/D') : '-'}
|
))}
{payments.length === 0 &&
Sin actividad financiera
}
);
};
// --- COMPONENTE REPORTES ---
const Reports = ({ workOrders, users }) => {
const [startDate, setStartDate] = useState(getLocalDateString(new Date(new Date().setDate(1))));
const [endDate, setEndDate] = useState(getLocalDateString());
const [selectedUser, setSelectedUser] = useState('');
const filteredOrders = workOrders.filter(o =>
String(o.creationDate) >= startDate &&
String(o.creationDate) <= endDate &&
(!selectedUser || String(o.vendorId) === selectedUser)
);
const totalVentas = filteredOrders.reduce((sum, o) => sum + parseFloat(String(o.total || 0).replace(',', '.')), 0);
const totalCobrado = filteredOrders.reduce((sum, o) => sum + (o.payments || []).reduce((s, p) => s + parseFloat(String(p.amount || 0).replace(',', '.')), 0), 0);
return (
Análisis de Rendimiento
Monto Facturado
${totalVentas.toFixed(2)}
{filteredOrders.length} Órdenes procesadas
Capital Cobrado
${totalCobrado.toFixed(2)}
Liquidaciones Efectivas
{selectedUser && (
📍 Auditando métricas individuales de: {users.find(u => u.uid === selectedUser)?.name.toUpperCase()}
)}
);
};
// --- COMPONENTE PRINCIPAL APP ---
export default function App() {
const [user, setUser] = useState(null);
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState('dashboard');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [workOrders, setWorkOrders] = useState([]);
const [customers, setCustomers] = useState([]);
const [services, setServices] = useState([]);
const [users, setUsers] = useState([]);
const [expenses, setExpenses] = useState([]);
const [editingOrder, setEditingOrder] = useState(null);
const [editingCustomer, setEditingCustomer] = useState(null);
const [orderToDelete, setOrderToDelete] = useState(null);
useEffect(() => {
const loadScripts = async () => {
if (document.getElementById('jspdf-script')) return;
const jspdf = document.createElement('script');
jspdf.id = 'jspdf-script';
jspdf.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
document.body.appendChild(jspdf);
jspdf.onload = () => {
const autoTable = document.createElement('script');
autoTable.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.23/jspdf.plugin.autotable.min.js';
document.body.appendChild(autoTable);
};
};
loadScripts();
}, []);
useEffect(() => {
const unsubAuth = onAuthStateChanged(auth, async (u) => {
if (u) {
const snap = await getDocs(query(collection(db, "users"), where("uid", "==", u.uid)));
if (!snap.empty) {
const data = snap.docs[0].data();
const fData = { id: String(snap.docs[0].id), uid: String(u.uid), ...data };
setUserData(fData);
onSnapshot(collection(db, "customers"), s => setCustomers(s.docs.map(d => ({ id: d.id, ...d.data() }))));
onSnapshot(collection(db, "services"), s => setServices(s.docs.map(d => ({ id: d.id, ...d.data() }))));
onSnapshot(collection(db, "expenses"), s => setExpenses(s.docs.map(d => ({ id: d.id, ...d.data() }))));
onSnapshot(collection(db, "users"), s => setUsers(s.docs.map(d => ({ uid: d.id, ...d.data() }))));
const qO = fData.role === 'admin' ? collection(db, "workOrders") : query(collection(db, "workOrders"), where("vendorId", "==", u.uid));
onSnapshot(qO, s => setWorkOrders(s.docs.map(d => ({ id: d.id, ...d.data() }))));
}
setUser(u);
} else { setUser(null); setUserData(null); }
setLoading(false);
});
return () => unsubAuth();
}, []);
const handleSaveUser = async (data) => {
try {
const userRef = doc(db, "users", String(data.uid || data.email));
await setDoc(userRef, data, { merge: true });
} catch (e) { console.error(e); }
};
const handleSaveExpense = async (data) => {
try { await addDoc(collection(db, "expenses"), data); } catch (e) { console.error(e); }
};
const handleDeleteExpense = async (id) => {
if (window.confirm("¿Eliminar este gasto?")) {
try { await deleteDoc(doc(db, "expenses", id)); } catch (e) { console.error(e); }
}
};
const handleSaveCustomer = async (data) => {
try {
if (data.id) await updateDoc(doc(db, "customers", data.id), data);
else await addDoc(collection(db, "customers"), data);
setEditingCustomer(null);
setCurrentPage('customers');
} catch (e) { console.error(e); }
};
const handleSaveProduct = async (data) => {
try {
if (data.id) await updateDoc(doc(db, "services", data.id), data);
else await addDoc(collection(db, "services"), data);
} catch (e) { console.error(e); }
};
const handleSaveWorkOrder = async (data) => {
try {
const normalizedData = {
...data,
iva: parseFloat(String(data.iva).replace(',', '.')) || 0,
items: (data.items || []).map(item => ({
...item,
quantity: parseFloat(String(item.quantity).replace(',', '.')) || 0,
unitPrice: parseFloat(String(item.unitPrice).replace(',', '.')) || 0
})),
payments: (data.payments || []).map(p => ({
...p,
amount: parseFloat(String(p.amount).replace(',', '.')) || 0,
method: String(p.method || 'efectivo'),
reference: String(p.reference || '')
}))
};
if (data.id) await updateDoc(doc(db, "workOrders", data.id), normalizedData);
else {
const counterRef = doc(db, "counters", "workOrderCounter");
await runTransaction(db, async (transaction) => {
const counterDoc = await transaction.get(counterRef);
const nextNum = counterDoc.exists() ? counterDoc.data().currentNumber + 1 : 1;
const orderNum = `OT-${String(nextNum).padStart(3, '0')}`;
const newRef = doc(collection(db, "workOrders"));
transaction.set(newRef, { ...normalizedData, orderNumber: orderNum, id: newRef.id });
transaction.set(counterRef, { currentNumber: nextNum });
});
}
setEditingOrder(null); setCurrentPage('work-orders');
} catch (e) { console.error(e); }
};
const generatePDF = (order) => {
if (!window.jspdf || !window.jspdf.jsPDF) return;
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'mm', format: [80, 250] });
const margin = 5;
const pageWidth = doc.internal.pageSize.getWidth();
const contentWidth = pageWidth - (margin * 2);
let y = 12;
doc.setFontSize(16).setFont('helvetica', 'bold').text('CompuServer & PubliServer', pageWidth / 2, y, { align: 'center' });
y += 8;
doc.setFontSize(10).setFont('helvetica', 'normal').text('Av. Luis Cordero 407', pageWidth / 2, y, { align: 'center' });
y += 6;
doc.text('Telf: 099 201 2673', pageWidth / 2, y, { align: 'center' });
y += 8;
doc.setLineWidth(0.3).line(margin, y, pageWidth - margin, y);
y += 8;
doc.setFontSize(12).setFont('helvetica', 'bold').text(`ORDEN: ${String(order.orderNumber)}`, margin, y);
y += 6;
doc.setFontSize(10).setFont('helvetica', 'normal').text(`Fecha: ${String(order.creationDate)}`, margin, y);
y += 8;
doc.setFontSize(11).setFont('helvetica', 'bold').text('DATOS DEL CLIENTE:', margin, y);
y += 6;
doc.setFontSize(10).setFont('helvetica', 'normal');
const customer = customers.find(c => String(c.id) === String(order.customerId));
const nameLines = doc.splitTextToSize(`Nombre: ${String(order.customerName)}`, contentWidth);
doc.text(nameLines, margin, y);
y += (nameLines.length * 5);
doc.text(`RUC/Cédula: ${String(customer?.idNumber || 'S/N')}`, margin, y);
y += 5;
doc.text(`Teléfono: ${String(customer?.phone || 'S/N')}`, margin, y);
y += 5;
const dirLines = doc.splitTextToSize(`Dirección: ${String(customer?.address || 'S/N')}`, contentWidth);
doc.text(dirLines, margin, y);
y += (dirLines.length * 5);
const emailLines = doc.splitTextToSize(`Correo: ${String(customer?.email || 'S/N')}`, contentWidth);
doc.text(emailLines, margin, y);
y += (emailLines.length * 5) + 3;
doc.autoTable({
startY: y,
head: [['Cant', 'Desc', 'PU', 'Sub']],
body: (order.items || []).map(i => [
parseFloat(String(i.quantity).replace(',', '.')),
String(i.description),
parseFloat(String(i.unitPrice).replace(',', '.')).toFixed(2),
parseFloat(i.subtotal || 0).toFixed(2)
]),
theme: 'grid',
styles: { fontSize: 9, cellPadding: 1.5 },
headStyles: { fillColor: [240, 240, 240], textColor: [0, 0, 0], fontStyle: 'bold' },
margin: { left: margin, right: margin }
});
y = doc.lastAutoTable.finalY + 8;
doc.setFontSize(10).setFont('helvetica', 'normal');
doc.text(`Subtotal: $${parseFloat(order.subtotal || 0).toFixed(2)}`, pageWidth - margin, y, { align: 'right' });
y += 5;
doc.text(`IVA: $${parseFloat(order.totalIva || 0).toFixed(2)}`, pageWidth - margin, y, { align: 'right' });
y += 7;
doc.setFontSize(12).setFont('helvetica', 'bold').text(`TOTAL: $${parseFloat(order.total || 0).toFixed(2)}`, pageWidth - margin, y, { align: 'right' });
y += 7;
doc.setFontSize(11).text(`SALDO: $${parseFloat(order.balance || 0).toFixed(2)}`, pageWidth - margin, y, { align: 'right' });
y += 18;
doc.setFontSize(10).setFont('helvetica', 'italic').text("Gracias por preferirnos, es un placer poder servirle", pageWidth / 2, y, { align: 'center' });
doc.save(`${String(order.orderNumber)}.pdf`);
};
if (loading) return ;
if (!user) return ;
if (!userData) return ;
const renderPage = () => {
if (editingOrder) return setEditingOrder(null)} />;
if (editingCustomer !== null) return {editingCustomer.id ? 'Actualizar Ficha de Cliente' : 'Registro de Nuevo Cliente'}
setEditingCustomer(null)} />;
switch (currentPage) {
case 'dashboard': return ;
case 'work-orders': return (
Gestión Órdenes de Trabajo
setOrderToDelete(id)} onPrint={generatePDF} userData={userData} customers={customers} />
);
case 'customers': return ;
case 'catalog': return deleteDoc(doc(db, "services", id))} />;
case 'users-management': return userData.role === 'admin' ? deleteDoc(doc(db, "users", id))} /> : null;
case 'expenses': return ;
case 'cash-closing': return userData.role === 'admin' ? : null;
case 'reports': return userData.role === 'admin' ? : null;
default: return ;
}
};
return (
setOrderToDelete(null)} title="Confirmar Acción Crítica">
⚠️
¿Está completamente seguro de eliminar este registro?
Esta operación no tiene reversa en la base de datos.
);
}