- Taller Central • Activo
+
+ {isSuperAdmin ? (
+
+ Plataforma RepairIT • SuperAdmin
+
+ ) : (
+
+ Sucursal:
+ {venues.length > 1 ? (
+
+
+
+ {activeVenue ? activeVenue.name : "Cargando..."}
+
+
+
+
+ {venues.map((v) => (
+ handleHeaderSwitchVenue(v._id)}
+ className="text-[10px] transition-colors cursor-pointer flex items-center justify-between gap-4"
+ >
+ {v.name}
+ {v._id === activeVenue?._id && ✓ }
+
+ ))}
+
+
+ ) : (
+
+ {activeVenue ? activeVenue.name : "Cargando..."}
+
+ )}
+
+ )}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000..2354754
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,897 @@
+import { supabase } from "./supabaseClient";
+
+// Generador de código de seguimiento único alfanumérico (6 caracteres)
+const generateTrackingCode = () => {
+ const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ return "RT-" + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
+};
+
+// Helper para obtener el usuario de localStorage
+const getLocalUser = () => {
+ return JSON.parse(localStorage.getItem("repairit_user") || "{}");
+};
+
+const mapClient = (client) => {
+ if (!client) return null;
+ if (typeof client === "string") return client;
+ const c = Array.isArray(client) ? client[0] : client;
+ if (!c) return null;
+ return {
+ ...c,
+ _id: c.id,
+ };
+};
+
+const mapOrder = (order) => {
+ if (!order) return null;
+ return {
+ ...order,
+ _id: order.id,
+ venueId: order.venue_id,
+ clientId: mapClient(order.clientId || order.client_id),
+ trackingCode: order.tracking_code,
+ deviceType: order.device_type,
+ deviceModel: order.device_model,
+ diagnosis: order.diagnosis || "",
+ budget: order.budget || null,
+ };
+};
+
+export const api = {
+ // Autenticación & Perfil
+ auth: {
+ login: async (email, password) => {
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ // Obtener perfil público asociado al usuario
+ const { data: profile, error: profileError } = await supabase
+ .from("profiles")
+ .select("*, organization:organizations(*), venue:venues(*)")
+ .eq("id", data.user.id)
+ .single();
+
+ if (profileError) {
+ throw new Error("No se pudo obtener el perfil del usuario.");
+ }
+
+ const userData = {
+ _id: profile.id,
+ name: profile.name,
+ email: data.user.email,
+ role: profile.role,
+ organizationId: profile.organization_id,
+ venueId: profile.venue_id,
+ token: data.session.access_token,
+ };
+
+ localStorage.setItem("repairit_token", data.session.access_token);
+ localStorage.setItem("repairit_user", JSON.stringify(userData));
+
+ return userData;
+ },
+
+ register: async (registerData) => {
+ // Registrar cuenta en Supabase Auth
+ const { data, error } = await supabase.auth.signUp({
+ email: registerData.email,
+ password: registerData.password,
+ options: {
+ data: {
+ name: registerData.name,
+ },
+ },
+ });
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ // Crear Organización
+ const { data: org, error: orgError } = await supabase
+ .from("organizations")
+ .insert({ name: registerData.organizationName || "Organización RepairIT" })
+ .select()
+ .single();
+
+ if (orgError) throw new Error(orgError.message);
+
+ // Crear Sucursal por defecto
+ const { data: venue, error: venueError } = await supabase
+ .from("venues")
+ .insert({
+ organization_id: org.id,
+ name: registerData.venueName || "Sucursal Central",
+ email: registerData.venueEmail || registerData.email,
+ phone: registerData.venuePhone || "+54 381 4223344",
+ address: registerData.venueAddress || "Av. Sarmiento 1234, San Miguel de Tucumán",
+ })
+ .select()
+ .single();
+
+ if (venueError) throw new Error(venueError.message);
+
+ // Crear Perfil Público
+ const { error: profileError } = await supabase
+ .from("profiles")
+ .insert({
+ id: data.user.id,
+ organization_id: org.id,
+ venue_id: venue.id,
+ name: registerData.name,
+ role: "admin",
+ });
+
+ if (profileError) throw new Error(profileError.message);
+
+ return { message: "Registro completado con éxito. Por favor inicia sesión." };
+ },
+
+ logout: async () => {
+ await supabase.auth.signOut();
+ localStorage.removeItem("repairit_token");
+ localStorage.removeItem("repairit_user");
+ },
+
+ getProfile: async () => {
+ let user = getLocalUser();
+
+ if (!user.venueId || user.venueId === "undefined" || !user.organizationId) {
+ try {
+ const { data: authData } = await supabase.auth.getUser();
+ if (authData?.user) {
+ const { data: profile } = await supabase
+ .from("profiles")
+ .select("*, organization:organizations(*), venue:venues(*)")
+ .eq("id", authData.user.id)
+ .maybeSingle();
+
+ if (profile) {
+ user = {
+ _id: profile.id,
+ name: profile.name,
+ email: authData.user.email,
+ role: profile.role,
+ subscriptionPlan: profile.organization?.subscription_plan || "Multi-Taller Pro",
+ subscriptionStatus: profile.organization?.subscription_status || "activo",
+ organizationId: profile.organization_id,
+ venueId: profile.venue_id,
+ };
+ localStorage.setItem("repairit_user", JSON.stringify(user));
+ }
+ }
+ } catch (e) {
+ console.error("Error al autorecuperar perfil:", e);
+ }
+ }
+
+ if (!user.venueId || user.venueId === "undefined") {
+ return { name: "Sucursal Central", address: "Av. Sarmiento 1234, San Miguel de Tucumán", phone: "+54 381 4223344", email: "central@repairit.cloud" };
+ }
+
+ const { data, error } = await supabase
+ .from("venues")
+ .select("*")
+ .eq("id", user.venueId)
+ .maybeSingle();
+
+ if (error || !data) {
+ return { name: "Sucursal Central", address: "Av. Sarmiento 1234, San Miguel de Tucumán", phone: "+54 381 4223344", email: "central@repairit.cloud" };
+ }
+ return { ...data, _id: data.id };
+ },
+
+ updateProfile: async (profileData) => {
+ const user = getLocalUser();
+ if (!user.venueId || user.venueId === "undefined") {
+ throw new Error("No hay sucursal activa en sesión.");
+ }
+ const { data, error } = await supabase
+ .from("venues")
+ .update(profileData)
+ .eq("id", user.venueId)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ getVenues: async () => {
+ let user = getLocalUser();
+ if (!user.organizationId || user.organizationId === "undefined") {
+ try {
+ const { data: authData } = await supabase.auth.getUser();
+ if (authData?.user) {
+ const { data: profile } = await supabase
+ .from("profiles")
+ .select("organization_id")
+ .eq("id", authData.user.id)
+ .maybeSingle();
+ if (profile) {
+ user.organizationId = profile.organization_id;
+ }
+ }
+ } catch (e) {
+ console.error("Error al autorecuperar sucursales:", e);
+ }
+ }
+
+ if (!user.organizationId) return [];
+
+ const { data, error } = await supabase
+ .from("venues")
+ .select("*")
+ .eq("organization_id", user.organizationId);
+
+ if (error || !data) return [];
+ return data.map((v) => ({ ...v, _id: v.id }));
+ },
+
+ createVenue: async (venueData) => {
+ const user = getLocalUser();
+ const { data, error } = await supabase
+ .from("venues")
+ .insert({
+ organization_id: user.organizationId,
+ ...venueData,
+ })
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ updateVenue: async (id, venueData) => {
+ const user = getLocalUser();
+ const { data, error } = await supabase
+ .from("venues")
+ .update(venueData)
+ .eq("id", id)
+ .eq("organization_id", user.organizationId)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ switchVenue: async (venueId) => {
+ const user = getLocalUser();
+ const { error } = await supabase
+ .from("profiles")
+ .update({ venue_id: venueId })
+ .eq("id", user._id || user.id);
+
+ if (error) throw new Error(error.message);
+
+ return { venueId };
+ },
+ },
+
+ // Clientes
+ clients: {
+ getAll: async () => {
+ const user = getLocalUser();
+ if (!user.organizationId || !user.venueId || user.organizationId === "undefined" || user.venueId === "undefined") {
+ return [];
+ }
+ const { data, error } = await supabase
+ .from("clients")
+ .select("*")
+ .eq("organization_id", user.organizationId)
+ .eq("venue_id", user.venueId);
+
+ if (error) throw new Error(error.message);
+ return data.map((c) => ({ ...c, _id: c.id }));
+ },
+
+ create: async (clientData) => {
+ const user = getLocalUser();
+
+ // Buscar si ya existe por DNI dentro de la sucursal
+ const { data: existingClient } = await supabase
+ .from("clients")
+ .select("*")
+ .eq("organization_id", user.organizationId)
+ .eq("venue_id", user.venueId)
+ .eq("dni", clientData.dni)
+ .maybeSingle();
+
+ if (existingClient) {
+ // Actualizar datos
+ const { data, error } = await supabase
+ .from("clients")
+ .update(clientData)
+ .eq("id", existingClient.id)
+ .select()
+ .single();
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ }
+
+ // Crear nuevo
+ const { data, error } = await supabase
+ .from("clients")
+ .insert({
+ organization_id: user.organizationId,
+ venue_id: user.venueId,
+ ...clientData,
+ })
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ update: async (id, clientData) => {
+ const user = getLocalUser();
+ const { data, error } = await supabase
+ .from("clients")
+ .update(clientData)
+ .eq("id", id)
+ .eq("organization_id", user.organizationId)
+ .eq("venue_id", user.venueId)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ delete: async (id) => {
+ const user = getLocalUser();
+ const { error } = await supabase
+ .from("clients")
+ .delete()
+ .eq("id", id)
+ .eq("organization_id", user.organizationId)
+ .eq("venue_id", user.venueId);
+
+ if (error) throw new Error(error.message);
+ return { message: "Cliente eliminado correctamente." };
+ },
+ },
+
+ // Órdenes
+ orders: {
+ getAll: async () => {
+ const user = getLocalUser();
+ if (!user.venueId || user.venueId === "undefined") return [];
+ const { data, error } = await supabase
+ .from("orders")
+ .select("*, clientId:clients(*)")
+ .eq("venue_id", user.venueId);
+
+ if (error) throw new Error(error.message);
+
+ return data.map(mapOrder);
+ },
+
+ create: async (orderData) => {
+ const user = getLocalUser();
+ const trackingCode = generateTrackingCode();
+
+ const { data, error } = await supabase
+ .from("orders")
+ .insert({
+ venue_id: user.venueId,
+ client_id: orderData.clientId,
+ tracking_code: trackingCode,
+ device_type: orderData.deviceType,
+ device_model: orderData.deviceModel,
+ accessories: orderData.accessories,
+ cosmetic: orderData.cosmetic,
+ issue: orderData.issue,
+ date: orderData.date,
+ time: orderData.time,
+ })
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return mapOrder(data);
+ },
+
+ update: async (id, orderData) => {
+ const { data, error } = await supabase
+ .from("orders")
+ .update(orderData)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return mapOrder(data);
+ },
+
+ updateStatus: async (id, status, text) => {
+ // Obtener orden actual para leer su historial
+ const { data: order } = await supabase
+ .from("orders")
+ .select("history")
+ .eq("id", id)
+ .single();
+
+ const history = Array.isArray(order?.history) ? order.history : [];
+ const dateStr = new Date().toLocaleDateString("es-AR") + " " + new Date().toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
+
+ history.push({ date: dateStr, text });
+
+ const { data, error } = await supabase
+ .from("orders")
+ .update({ status, history })
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return mapOrder(data);
+ },
+
+ updateDiagnosisAndBudget: async (id, { diagnosis, budget, status }) => {
+ const { data: order } = await supabase
+ .from("orders")
+ .select("history")
+ .eq("id", id)
+ .single();
+
+ const history = Array.isArray(order?.history) ? order.history : [];
+ const dateStr = new Date().toLocaleDateString("es-AR") + " " + new Date().toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
+
+ const statusText = status ? status.toUpperCase() : "ACTUALIZADO";
+ history.push({ date: dateStr, text: `Diagnóstico / Presupuesto cargado (${statusText})` });
+
+ const updateData = { history };
+ if (diagnosis !== undefined) updateData.diagnosis = diagnosis;
+ if (budget !== undefined) updateData.budget = budget;
+ if (status) updateData.status = status;
+
+ const { data, error } = await supabase
+ .from("orders")
+ .update(updateData)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return mapOrder(data);
+ },
+
+ track: async (code) => {
+ const cleanCode = (code || "").trim().toLowerCase();
+ if (cleanCode === "demo-id" || cleanCode === "demo") {
+ return {
+ id: "demo-id-12345",
+ _id: "demo-id-12345",
+ trackingCode: "demo-id",
+ deviceType: "Notebook",
+ deviceModel: "Lenovo ThinkPad E14 Gen 2",
+ issue: "El equipo no enciende tras descarga eléctrica. Parpadea LED de carga.",
+ diagnosis: "Falla en circuito primario de alimentación 19V. Cortocircuito en mosfet de entrada y chip IC PMIC dañados.",
+ status: "presupuestado",
+ clientId: {
+ name: "Juan Pérez (Cliente Demo)",
+ dni: "38.450.123",
+ phone: "+54 381 555-0192",
+ email: "juan.demo@email.com"
+ },
+ venueId: {
+ name: "Sucursal Central (Demo)",
+ address: "Av. Sarmiento 1234, San Miguel de Tucumán"
+ },
+ budget: {
+ items: [
+ { desc: "Reemplazo de Mosfet e IC Regulador PMIC", price: 18500 },
+ { desc: "Mantenimiento térmico y limpieza de placa madre", price: 6500 }
+ ],
+ approved: false,
+ dateApproved: null
+ },
+ history: [
+ { date: "24/07/2026 09:30", text: "Equipo ingresado en recepción." },
+ { date: "24/07/2026 11:15", text: "Diagnóstico finalizado por el laboratorio técnico." },
+ { date: "24/07/2026 11:30", text: "Presupuesto detallado generado y disponible online." }
+ ]
+ };
+ }
+
+ const { data, error } = await supabase
+ .from("orders")
+ .select("*, venueId:venues(*), clientId:clients(*)")
+ .eq("tracking_code", code)
+ .maybeSingle();
+
+ if (error) throw new Error(error.message);
+ if (!data) throw new Error("Orden de servicio no encontrada.");
+
+ const mapped = mapOrder(data);
+ if (mapped && data.venueId) {
+ const v = Array.isArray(data.venueId) ? data.venueId[0] : data.venueId;
+ mapped.venueId = v ? { ...v, _id: v.id } : null;
+ }
+ return mapped;
+ },
+
+ approveBudget: async (code) => {
+ const cleanCode = (code || "").trim().toLowerCase();
+ if (cleanCode === "demo-id" || cleanCode === "demo") {
+ return {
+ id: "demo-id-12345",
+ _id: "demo-id-12345",
+ trackingCode: "demo-id",
+ deviceType: "Notebook",
+ deviceModel: "Lenovo ThinkPad E14 Gen 2",
+ issue: "El equipo no enciende tras descarga eléctrica. Parpadea LED de carga.",
+ diagnosis: "Falla en circuito primario de alimentación 19V. Cortocircuito en mosfet de entrada y chip IC PMIC dañados.",
+ status: "en_reparacion",
+ clientId: {
+ name: "Juan Pérez (Cliente Demo)",
+ dni: "38.450.123",
+ phone: "+54 381 555-0192",
+ email: "juan.demo@email.com"
+ },
+ venueId: {
+ name: "Sucursal Central (Demo)",
+ address: "Av. Sarmiento 1234, San Miguel de Tucumán"
+ },
+ budget: {
+ items: [
+ { desc: "Reemplazo de Mosfet e IC Regulador PMIC", price: 18500 },
+ { desc: "Mantenimiento térmico y limpieza de placa madre", price: 6500 }
+ ],
+ approved: true,
+ dateApproved: new Date().toLocaleDateString("es-AR")
+ },
+ history: [
+ { date: "24/07/2026 09:30", text: "Equipo ingresado en recepción." },
+ { date: "24/07/2026 11:15", text: "Diagnóstico finalizado por el laboratorio técnico." },
+ { date: "24/07/2026 11:30", text: "Presupuesto detallado generado y disponible online." },
+ { date: new Date().toLocaleDateString("es-AR") + " " + new Date().toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }), text: "Presupuesto aprobado online por el cliente (Demo)." }
+ ]
+ };
+ }
+
+ const { data: order } = await supabase
+ .from("orders")
+ .select("budget, history")
+ .eq("tracking_code", code)
+ .single();
+
+ const budget = order.budget || {};
+ budget.approved = true;
+ budget.dateApproved = new Date().toLocaleDateString("es-AR");
+
+ const history = Array.isArray(order?.history) ? order.history : [];
+ const dateStr = new Date().toLocaleDateString("es-AR") + " " + new Date().toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
+
+ history.push({ date: dateStr, text: "Presupuesto aprobado online por el cliente." });
+
+ const { data, error } = await supabase
+ .from("orders")
+ .update({
+ status: "en_reparacion",
+ budget,
+ history,
+ })
+ .eq("tracking_code", code)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return mapOrder(data);
+ },
+ },
+
+ // Inventario
+ inventory: {
+ getAll: async () => {
+ const user = getLocalUser();
+ if (!user.venueId || user.venueId === "undefined") return [];
+ const { data, error } = await supabase
+ .from("inventory")
+ .select("*")
+ .eq("venue_id", user.venueId);
+
+ if (error) throw new Error(error.message);
+ return data.map(item => ({ ...item, _id: item.id }));
+ },
+
+ create: async (itemData) => {
+ const user = getLocalUser();
+ const { data, error } = await supabase
+ .from("inventory")
+ .insert({
+ organization_id: user.organizationId,
+ venue_id: user.venueId,
+ code: itemData.code,
+ name: itemData.name,
+ description: itemData.description,
+ category: itemData.category,
+ quantity: itemData.quantity,
+ min_quantity: itemData.minQuantity,
+ price: itemData.price,
+ })
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ update: async (id, itemData) => {
+ const user = getLocalUser();
+ const { data, error } = await supabase
+ .from("inventory")
+ .update({
+ code: itemData.code,
+ name: itemData.name,
+ description: itemData.description,
+ category: itemData.category,
+ quantity: itemData.quantity,
+ min_quantity: itemData.minQuantity,
+ price: itemData.price,
+ })
+ .eq("id", id)
+ .eq("venue_id", user.venueId)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ delete: async (id) => {
+ const user = getLocalUser();
+ const { error } = await supabase
+ .from("inventory")
+ .delete()
+ .eq("id", id)
+ .eq("venue_id", user.venueId);
+
+ if (error) throw new Error(error.message);
+ return { message: "Artículo eliminado." };
+ },
+
+ adjustStock: async (id, amount) => {
+ const user = getLocalUser();
+ const { data: item } = await supabase
+ .from("inventory")
+ .select("quantity")
+ .eq("id", id)
+ .eq("venue_id", user.venueId)
+ .single();
+
+ const newQty = Math.max(0, (item?.quantity || 0) + amount);
+
+ const { data, error } = await supabase
+ .from("inventory")
+ .update({ quantity: newQty })
+ .eq("id", id)
+ .eq("venue_id", user.venueId)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ bulkUpsert: async (itemsArray) => {
+ const { data, error } = await supabase
+ .from("inventory")
+ .upsert(itemsArray, { onConflict: "venue_id, code" });
+
+ if (error) throw new Error(error.message);
+ return data;
+ },
+ },
+
+ // Ventas de Caja (POS)
+ sales: {
+ getAll: async (venueId = null) => {
+ const user = getLocalUser();
+ let query = supabase
+ .from("sales")
+ .select("*, venue:venues(name), profile:profiles(name)");
+
+ if (venueId && venueId !== "todas") {
+ query = query.eq("venue_id", venueId);
+ } else if (user.role !== "superadmin") {
+ if (user.subscriptionPlan === "Multi-Taller Pro") {
+ query = query.eq("organization_id", user.organizationId);
+ } else {
+ query = query.eq("venue_id", user.venueId);
+ }
+ }
+
+ const { data, error } = await query.order("created_at", { ascending: false });
+
+ if (error) throw new Error(error.message);
+ return data.map((s) => ({
+ ...s,
+ _id: s.id,
+ venueName: s.venue?.name || "Desconocida",
+ cajero: s.profile?.name || "Sistema",
+ }));
+ },
+
+ create: async (saleData) => {
+ const user = getLocalUser();
+
+ const { data: sale, error: saleErr } = await supabase
+ .from("sales")
+ .insert({
+ organization_id: user.organizationId,
+ venue_id: user.venueId,
+ total: saleData.total,
+ payment_method: saleData.paymentMethod,
+ items: saleData.items,
+ created_by: user._id || user.id,
+ })
+ .select()
+ .single();
+
+ if (saleErr) throw new Error(saleErr.message);
+
+ // Descontar stock para cada producto
+ for (const item of saleData.items) {
+ const { data: currentItem } = await supabase
+ .from("inventory")
+ .select("quantity")
+ .eq("id", item.id)
+ .single();
+
+ const newQty = Math.max(0, (currentItem?.quantity || 0) - item.quantity);
+
+ await supabase
+ .from("inventory")
+ .update({ quantity: newQty })
+ .eq("id", item.id);
+ }
+
+ return { ...sale, _id: sale.id };
+ },
+ },
+
+ // SuperAdmin
+ super: {
+ getOrganizations: async () => {
+ const { data, error } = await supabase
+ .from("organizations")
+ .select("*, venues(id), profiles(id)");
+
+ if (error) throw new Error(error.message);
+
+ return data.map((org) => ({
+ ...org,
+ _id: org.id,
+ subscriptionPlan: org.subscription_plan,
+ subscriptionStatus: org.subscription_status,
+ createdAt: org.created_at,
+ venuesCount: Array.isArray(org.venues) ? org.venues.length : 0,
+ usersCount: Array.isArray(org.profiles) ? org.profiles.length : 0,
+ }));
+ },
+
+ updateSubscription: async (id, subscriptionPlan, subscriptionStatus) => {
+ const updateData = {};
+ if (subscriptionPlan) updateData.subscription_plan = subscriptionPlan;
+ if (subscriptionStatus) updateData.subscription_status = subscriptionStatus;
+
+ const { data, error } = await supabase
+ .from("organizations")
+ .update(updateData)
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return {
+ ...data,
+ _id: data.id,
+ subscriptionPlan: data.subscription_plan,
+ subscriptionStatus: data.subscription_status,
+ createdAt: data.created_at,
+ };
+ },
+
+ getUsers: async () => {
+ const { data, error } = await supabase
+ .from("profiles")
+ .select("*, organizationId:organizations(name), venueId:venues(name)");
+
+ if (error) throw new Error(error.message);
+
+ return data.map((profile) => ({
+ ...profile,
+ _id: profile.id,
+ createdAt: profile.created_at,
+ organizationId: profile.organizationId ? { ...profile.organizationId, _id: profile.organizationId.id } : null,
+ venueId: profile.venueId ? { ...profile.venueId, _id: profile.venueId.id } : null,
+ }));
+ },
+
+ updateUserRole: async (id, role) => {
+ const { data, error } = await supabase
+ .from("profiles")
+ .update({ role })
+ .eq("id", id)
+ .select()
+ .single();
+
+ if (error) throw new Error(error.message);
+ return { ...data, _id: data.id };
+ },
+
+ manageUser: async (userId, name, email, role, password = null) => {
+ const { error } = await supabase.rpc("super_admin_manage_user", {
+ p_user_id: userId,
+ p_new_name: name,
+ p_new_email: email,
+ p_new_role: role,
+ p_new_password: password,
+ });
+
+ if (error) throw new Error(error.message);
+ return { message: "Usuario actualizado correctamente." };
+ },
+
+ deleteUser: async (userId) => {
+ const { error } = await supabase.rpc("super_admin_delete_user", {
+ p_user_id: userId,
+ });
+
+ if (error) throw new Error(error.message);
+ return { message: "Usuario eliminado correctamente." };
+ },
+ },
+
+ // Cuentas de Acceso de Venue (Para Administradores)
+ venueAccounts: {
+ getAll: async () => {
+ const user = getLocalUser();
+ if (!user.organizationId || user.organizationId === "undefined") return [];
+ const { data, error } = await supabase
+ .from("profiles")
+ .select("*, venue:venues(*)")
+ .eq("organization_id", user.organizationId)
+ .eq("role", "tecnico");
+
+ if (error) throw new Error(error.message);
+ return data.map((p) => ({
+ ...p,
+ _id: p.id,
+ venueId: p.venue ? { ...p.venue, _id: p.venue.id } : null,
+ }));
+ },
+
+ create: async (accountData) => {
+ const { data, error } = await supabase.rpc("create_venue_account", {
+ p_email: accountData.email,
+ p_password: accountData.password,
+ p_name: accountData.name,
+ p_venue_id: accountData.venueId,
+ });
+
+ if (error) throw new Error(error.message);
+ return data;
+ },
+
+ delete: async (accountId) => {
+ const { error } = await supabase.rpc("delete_venue_account", {
+ p_user_id: accountId,
+ });
+
+ if (error) throw new Error(error.message);
+ return { message: "Cuenta eliminada correctamente." };
+ },
+ },
+};
diff --git a/frontend/src/services/supabaseClient.js b/frontend/src/services/supabaseClient.js
new file mode 100644
index 0000000..2bfdebe
--- /dev/null
+++ b/frontend/src/services/supabaseClient.js
@@ -0,0 +1,10 @@
+import { createClient } from "@supabase/supabase-js";
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
+const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+
+if (!supabaseUrl || !supabaseAnonKey) {
+ console.error("Faltan configurar las variables de entorno de Supabase.");
+}
+
+export const supabase = createClient(supabaseUrl, supabaseAnonKey);
diff --git a/frontend/src/views/admin/ClientsPage.jsx b/frontend/src/views/admin/ClientsPage.jsx
new file mode 100644
index 0000000..7e2b0ac
--- /dev/null
+++ b/frontend/src/views/admin/ClientsPage.jsx
@@ -0,0 +1,491 @@
+import React, { useState, useEffect } from "react";
+import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import { Users, Search, Trash2, Pencil, Eye, PlusCircle, ClipboardList } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+import { api } from "@/services/api";
+
+export default function ClientsPage() {
+ const [activeTab, setActiveTab] = useState("control");
+ const [clients, setClients] = useState([]);
+ const [orders, setOrders] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ const clientsData = await api.clients.getAll();
+ const ordersData = await api.orders.getAll();
+ setClients(clientsData);
+ setOrders(ordersData);
+ } catch (error) {
+ toast.error("Error al cargar datos", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ // Estados del formulario (Alta / Edición)
+ const [editingId, setEditingId] = useState(null);
+ const [clientName, setClientName] = useState("");
+ const [clientDni, setClientDni] = useState("");
+ const [clientPhone, setClientPhone] = useState("");
+ const [clientEmail, setClientEmail] = useState("");
+
+ // Modal de visualización de órdenes
+ const [selectedClientForOrders, setSelectedClientForOrders] = useState(null);
+ const [showOrdersModal, setShowOrdersModal] = useState(false);
+
+ // Modal de confirmación de borrado
+ const [clientToDelete, setClientToDelete] = useState(null);
+ const [showDeleteAlert, setShowDeleteAlert] = useState(false);
+
+ // Registrar o editar cliente
+ const handleRegisterClient = async (e) => {
+ e.preventDefault();
+
+ try {
+ if (editingId) {
+ // Editar cliente
+ await api.clients.update(editingId, {
+ name: clientName,
+ dni: clientDni,
+ phone: clientPhone,
+ email: clientEmail
+ });
+ toast.success("¡Ficha de Cliente Actualizada!", {
+ description: `Los datos de ${clientName} fueron actualizados.`
+ });
+ setEditingId(null);
+ } else {
+ // Alta de cliente
+ // Validar DNI único localmente antes de enviar (opcional, pero ayuda)
+ const exists = clients.some(c => c.dni.replace(/\./g, "").trim() === clientDni.replace(/\./g, "").trim());
+ if (exists) {
+ toast.error("El DNI ingresado ya pertenece a un cliente registrado.");
+ return;
+ }
+
+ const data = await api.clients.create({
+ name: clientName,
+ dni: clientDni,
+ phone: clientPhone,
+ email: clientEmail
+ });
+ toast.success("¡Cliente Registrado!", {
+ description: `${clientName} fue dado de alta.`
+ });
+ }
+
+ // Resetear formulario
+ setClientName("");
+ setClientDni("");
+ setClientPhone("");
+ setClientEmail("");
+ setActiveTab("control");
+ loadData();
+ } catch (error) {
+ toast.error("Error al registrar cliente", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
+ };
+
+ // Activar modo edición
+ const handleEditClick = (client) => {
+ setEditingId(client._id);
+ setClientName(client.name);
+ setClientDni(client.dni);
+ setClientPhone(client.phone);
+ setClientEmail(client.email);
+ setActiveTab("registro");
+ };
+
+ // Cancelar edición
+ const handleCancelEdit = () => {
+ setEditingId(null);
+ setClientName("");
+ setClientDni("");
+ setClientPhone("");
+ setClientEmail("");
+ setActiveTab("control");
+ };
+
+ // Solicitar borrado de cliente
+ const handleDeleteRequest = (client) => {
+ setClientToDelete(client);
+ setShowDeleteAlert(true);
+ };
+
+ // Confirmar borrado (borra cliente y todas sus órdenes asociadas)
+ const confirmDeleteClient = async () => {
+ if (!clientToDelete) return;
+
+ try {
+ await api.clients.delete(clientToDelete._id);
+ toast.error("Cliente eliminado", {
+ description: `${clientToDelete.name} y su historial de órdenes asociadas fueron eliminados.`
+ });
+ setClientToDelete(null);
+ setShowDeleteAlert(false);
+ loadData();
+ } catch (error) {
+ toast.error("Error al eliminar cliente", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
+ };
+
+ // Abrir modal con las órdenes de un cliente
+ const handleViewOrders = (client) => {
+ setSelectedClientForOrders(client);
+ setShowOrdersModal(true);
+ };
+
+ // Filtrado de clientes
+ const filteredClients = clients.filter(c => {
+ const term = searchQuery.toLowerCase().trim();
+ const cId = c._id || "";
+ return (
+ cId.toLowerCase().includes(term) ||
+ c.name.toLowerCase().includes(term) ||
+ c.dni.replace(/\./g, "").includes(term.replace(/\./g, "")) ||
+ (c.email && c.email.toLowerCase().includes(term)) ||
+ (c.phone && c.phone.includes(term))
+ );
+ });
+
+ return (
+
+ {/* Cabecera interna */}
+
+
+
+ Directorio de Clientes
+
+
+ Gestión de fichas de clientes y acceso a su historial de reparaciones técnico.
+
+
+
+ {/* Selector de Pestañas (Tabs) */}
+
+ setActiveTab("control")}
+ className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
+ activeTab === "control"
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Clientes Registrados
+
+ setActiveTab("registro")}
+ className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
+ activeTab === "registro"
+ ? "border-primary text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {editingId ? "Editar Cliente" : "Registrar Cliente"}
+
+
+
+
+ {activeTab === "control" ? (
+
+
+ {/* Buscador */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border text-xs w-full pl-9"
+ />
+
+
+ Total: {clients.length} Clientes
+
+
+
+ {/* Listado de Clientes */}
+ {filteredClients.length > 0 ? (
+
+
+
+
+ ID Cliente
+ Nombre
+ Identificación (DNI)
+ Contacto
+ Órdenes
+ Acción
+
+
+
+ {filteredClients.map((client) => {
+ const clientOrders = orders.filter(o => {
+ const oClientId = typeof o.clientId === "object" ? o.clientId._id : o.clientId;
+ return oClientId === client._id;
+ });
+ return (
+
+ {client._id.substring(client._id.length - 6).toUpperCase()}
+ {client.name}
+ {client.dni}
+
+
+ {client.phone}
+ {client.email}
+
+
+
+ 0 ? "default" : "secondary"} className="text-[10px] font-bold">
+ {clientOrders.length}
+
+
+
+
+
handleViewOrders(client)}
+ className="text-muted-foreground hover:text-primary hover:bg-primary/10 disabled:opacity-30"
+ title="Ver órdenes de reparación"
+ disabled={clientOrders.length === 0}
+ >
+
+
+
handleEditClick(client)}
+ className="text-muted-foreground hover:text-primary hover:bg-primary/10"
+ title="Editar ficha de cliente"
+ >
+
+
+
handleDeleteRequest(client)}
+ className="text-muted-foreground hover:text-red-400 hover:bg-red-950/15"
+ title="Eliminar cliente"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No se encontraron clientes con los filtros actuales.
+
+ )}
+
+ ) : (
+ /* Formulario de Alta / Edición */
+
+
+
+
+
+ {editingId ? "Editar Ficha de Cliente" : "Alta de Ficha de Cliente"}
+
+
+ {editingId ? `Modificá los datos de contacto y facturación de ${clientName}.` : "Registrá un nuevo cliente en la base de datos."}
+
+
+
+
+
+
+ )}
+
+
+ {/* Modal / Diálogo para visualización de Órdenes del Cliente */}
+ {showOrdersModal && selectedClientForOrders && (
+
+
+
+
+
+
+ Reparaciones de {selectedClientForOrders.name}
+
+
+ Ficha de cliente: {selectedClientForOrders._id.substring(selectedClientForOrders._id.length - 6).toUpperCase()} • DNI: {selectedClientForOrders.dni}
+
+
+
{
+ setShowOrdersModal(false);
+ setSelectedClientForOrders(null);
+ }}
+ className="text-muted-foreground hover:text-foreground text-xs"
+ >
+ Cerrar
+
+
+
+
+ {orders.filter(o => {
+ const oClientId = typeof o.clientId === "object" ? o.clientId._id : o.clientId;
+ return oClientId === selectedClientForOrders._id;
+ }).map(o => {
+ let badgeVariant = "secondary";
+ if (o.status === "listo") badgeVariant = "success";
+ if (o.status === "presupuestado") badgeVariant = "warning";
+ return (
+
+
+
+ {o.trackingCode}
+ {o.date}
+
+
{o.deviceModel} ({o.deviceType})
+
{o.issue}
+
+
+ {o.status.toUpperCase()}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {/* Diálogo de alerta para confirmación de eliminación */}
+
+
+
+
+ ¿Confirmás la eliminación del cliente?
+
+
+ Al eliminar a {clientToDelete?.name}, también se eliminarán del historial de forma permanente todas las órdenes de servicio asociadas. Esta acción no se puede deshacer.
+
+
+
+ Cancelar
+
+ Sí, Eliminar Todo
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/views/admin/DashboardPage.jsx b/frontend/src/views/admin/DashboardPage.jsx
index a03e83d..3bdbade 100644
--- a/frontend/src/views/admin/DashboardPage.jsx
+++ b/frontend/src/views/admin/DashboardPage.jsx
@@ -4,19 +4,303 @@ import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
-import { AlertCircle, ShieldAlert, PackageCheck, Wrench, ArrowUpRight } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertCircle,
+ ShieldAlert,
+ PackageCheck,
+ Wrench,
+ ArrowUpRight,
+ ShieldCheck,
+ Building2,
+ Users,
+ CreditCard,
+ ChevronDown,
+ Search,
+ LayoutDashboard,
+} from "lucide-react";
import { Link } from "react-router-dom";
+import { api } from "@/services/api";
+import { toast } from "sonner";
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
+
+ // Datos de sesión del usuario
+ const user = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ const isSuperAdmin = user.role === "superadmin";
+
+ // Estados de SuperAdmin
+ const [organizations, setOrganizations] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ // Estados de Taller (Inquilino común)
+ const [orders, setOrders] = useState([]);
+ const [inventory, setInventory] = useState([]);
+
+ const loadSuperData = async () => {
+ try {
+ setLoading(true);
+ const orgsData = await api.super.getOrganizations();
+ setOrganizations(orgsData);
+ } catch (err) {
+ toast.error("Error al cargar organizaciones de la plataforma", {
+ description: err.message || "Por favor, intente de nuevo."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadWorkshopData = async () => {
+ try {
+ setLoading(true);
+ const ordersData = await api.orders.getAll();
+ const inventoryData = await api.inventory.getAll();
+ setOrders(ordersData);
+ setInventory(inventoryData);
+ } catch (err) {
+ toast.error("Error al cargar datos del taller", {
+ description: err.message || "Por favor, intente de nuevo."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
useEffect(() => {
- const timer = setTimeout(() => {
- setLoading(false);
- }, 1000); // Retraso de 1 segundo para ver el skeleton de carga
- return () => clearTimeout(timer);
- }, []);
+ if (isSuperAdmin) {
+ loadSuperData();
+ } else {
+ loadWorkshopData();
+ }
+ }, [isSuperAdmin]);
+ // Actualizar Plan
+ const handlePlanChange = async (orgId, newPlan) => {
+ try {
+ await api.super.updateSubscription(orgId, newPlan, null);
+ toast.success("Plan de suscripción actualizado");
+ loadSuperData();
+ } catch (err) {
+ toast.error("Error al cambiar plan", { description: err.message });
+ }
+ };
+
+ // Actualizar Estado
+ const handleStatusChange = async (orgId, newStatus) => {
+ try {
+ await api.super.updateSubscription(orgId, null, newStatus);
+ toast.success("Estado de suscripción actualizado");
+ loadSuperData();
+ } catch (err) {
+ toast.error("Error al cambiar estado", { description: err.message });
+ }
+ };
+
+ // Render para SuperAdmin
+ if (isSuperAdmin) {
+ const filteredOrgs = organizations.filter(o =>
+ o.name.toLowerCase().includes(searchQuery.toLowerCase().trim())
+ );
+
+ const totalOrgs = organizations.length;
+ const activeOrgs = organizations.filter(o => o.subscriptionStatus === "activo").length;
+ const suspendedOrgs = organizations.filter(o => o.subscriptionStatus !== "activo").length;
+ const totalVenues = organizations.reduce((acc, o) => acc + (o.venuesCount || 0), 0);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Plataforma de Suscripciones
+
+
+ Administración centralizada de inquilinos, licencias operativas y planes de suscripción de talleres.
+
+
+
+ {/* Tarjetas de Métricas de Plataforma */}
+
+
+
+ Talleres / Inquilinos
+
+
+
+ {totalOrgs}
+ Registrados en la plataforma
+
+
+
+
+
+ Planes Activos
+
+
+
+ {activeOrgs}
+ Talleres con acceso vigente
+
+
+
+
+
+ Suspendidos / Vencidos
+
+
+
+ {suspendedOrgs}
+ Talleres bloqueados
+
+
+
+
+
+ Sucursales Totales
+
+
+
+ {totalVenues}
+ Puntos de servicio activos
+
+
+
+
+ {/* Listado y control de planes */}
+
+
+
+
+
+ Control de Cuentas & Licencias
+
+ Total: {organizations.length}
+
+
+ {/* Filtro rápido de búsqueda */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border text-xs w-full pl-9"
+ />
+
+
+
+ {filteredOrgs.length > 0 ? (
+
+
+
+
+ Taller
+ Sucursales
+ Técnicos
+ Plan Contratado
+ Acceso / Licencia
+ Alta
+
+
+
+ {filteredOrgs.map((org) => {
+ let statusBadge = "secondary";
+ let statusLabel = "Inactivo";
+ if (org.subscriptionStatus === "activo") {
+ statusBadge = "success";
+ statusLabel = "Activo";
+ } else if (org.subscriptionStatus === "suspendido") {
+ statusBadge = "destructive";
+ statusLabel = "Suspendido";
+ } else if (org.subscriptionStatus === "vencido") {
+ statusBadge = "outline";
+ statusLabel = "Vencido";
+ }
+
+ return (
+
+ {org.name}
+ {org.venuesCount || 0}
+ {org.usersCount || 0}
+
+
+
+
+ {org.subscriptionPlan}
+
+
+
+
+ handlePlanChange(org._id, "Taller Básico")} className="text-xs transition-colors cursor-pointer">Taller Básico
+ handlePlanChange(org._id, "Taller Premium")} className="text-xs transition-colors cursor-pointer">Taller Premium
+ handlePlanChange(org._id, "Multi-Taller Pro")} className="text-xs transition-colors cursor-pointer">Multi-Taller Pro
+
+
+
+
+
+
+
+
+ {statusLabel}
+
+
+
+
+
+ handleStatusChange(org._id, "activo")} className="text-xs transition-colors cursor-pointer">Activo
+ handleStatusChange(org._id, "suspendido")} className="text-xs transition-colors cursor-pointer">Suspendido
+ handleStatusChange(org._id, "vencido")} className="text-xs transition-colors cursor-pointer">Vencido
+
+
+
+ {org.createdAt ? new Date(org.createdAt).toLocaleDateString("es-AR") : "N/A"}
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No se encontraron talleres/inquilinos registrados.
+
+ )}
+
+
+
+ );
+ }
+
+ // Render para Técnicos / Administradores comunes
if (loading) {
return (
@@ -45,20 +329,37 @@ export default function DashboardPage() {
);
}
+ // Métricas operativas dinámicas calculadas desde los datos reales de la sucursal activa
+ const activeOrdersCount = orders.filter((o) => o.status !== "entregado" && o.status !== "rechazado").length;
+ const diagnosticsCount = orders.filter((o) => o.status === "diagnostico").length;
+ const repairCount = orders.filter((o) => o.status === "reparacion" || o.status === "en_reparacion").length;
+ const activeOrdersDetail = `${diagnosticsCount} en diagnóstico, ${repairCount} en reparación`;
+
+ const deliveredCount = orders.filter((o) => o.status === "entregado").length;
+
+ const totalItemsCount = inventory.length;
+ const categoriesCount = new Set(inventory.map((item) => item.category)).size;
+ const totalItemsDetail = `Distribuidos en ${categoriesCount} categorías`;
+
+ const lowStockItems = inventory.filter((item) => item.quantity <= (item.minQuantity || item.min_quantity));
+ const lowStockCount = lowStockItems.length;
+ const lowStockDetail = lowStockCount > 0 ? "Requieren compra o reposición" : "Niveles de stock óptimos";
+
return (
{/* Cabecera interna */}
-
- Panel de Control
+
+
+ Panel de Control
- Resumen operativo del taller y control de stock de insumos técnicos.
+ Resumen operativo del taller y control de stock de insumos técnicos de la sucursal activa.
- {/* Grid de Estadísticas Rápidas mediante Cards de shadcn/ui */}
+ {/* Grid de Estadísticas Rápidas */}
{/* Card 1: Órdenes Activas */}
@@ -68,20 +369,20 @@ export default function DashboardPage() {
- 12
- 4 en diagnóstico, 8 en reparación
+ {activeOrdersCount}
+ {activeOrdersDetail}
{/* Card 2: Entregados este Mes */}
- Entregas del Mes
+ Entregados Totales
- 45
- +15% respecto al mes anterior
+ {deliveredCount}
+ Dispositivos retirados por clientes
@@ -92,20 +393,20 @@ export default function DashboardPage() {
- 84
- Distribuidos en 14 categorías
+ {totalItemsCount}
+ {totalItemsDetail}
{/* Card 4: Alertas de Stock */}
-
+ 0 ? "bg-destructive/5 border-destructive/20 shadow-md" : "bg-card/45 border-border shadow-md"}>
- Alertas de Stock
-
+ Alertas de Stock
+ 0 ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground/60"} />
- 2
- Requieren compra urgente
+ 0 ? "text-3xl font-outfit font-black text-destructive block" : "text-3xl font-outfit font-black text-foreground block"}>{lowStockCount}
+ 0 ? "text-[10px] text-destructive/80 block font-medium mt-1" : "text-[10px] text-muted-foreground block font-medium mt-1"}>{lowStockDetail}
@@ -113,27 +414,61 @@ export default function DashboardPage() {
- {/* Tabla de Órdenes Recientes mediante Card de shadcn */}
+ {/* Tabla de Órdenes Recientes */}
Ingresos Recientes
- Actualizado hace 5m
+ Últimos 5 registros
-
-
- Aquí se desplegará el listado interactivo con filtros rápidos de diagnóstico. Ya podés probar la pestaña de Órdenes para dar de alta dispositivos.
-
-
-
- Ir a Registro de Dispositivos
-
-
+
+ {orders.length > 0 ? (
+
+
+
+
+ N° Orden
+ Cliente
+ Equipo
+ Estado
+
+
+
+ {orders.slice(0, 5).map((o) => {
+ const clientName = o.clientId && typeof o.clientId === "object" ? o.clientId.name : "N/A";
+ return (
+
+ {o.trackingCode}
+ {clientName}
+ {o.deviceModel}
+
+
+ {o.status}
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+
+ No hay órdenes de servicio cargadas. Ve a la pestaña de "Registrar Ingreso" para añadir un ingreso técnico.
+
+
+
+ Ir a Registro de Dispositivos
+
+
+
+ )}
- {/* Alertas de Stock Detalladas mediante Alert de shadcn/ui */}
+ {/* Alertas de Stock Detalladas */}
@@ -142,31 +477,26 @@ export default function DashboardPage() {
-
-
-
-
- Pasta Térmica Arctic MX-6
- Bajo Stock
-
-
- Mínimo requerido: 5 jeringas | Actual en stock: 2 jeringas.
-
+ {lowStockItems.length > 0 ? (
+ lowStockItems.map((item) => (
+
+
+
+
+ {item.name}
+ Bajo Stock
+
+
+ Mínimo requerido: {item.minQuantity || item.min_quantity} | Actual en stock: {item.quantity}.
+
+
+
+ ))
+ ) : (
+
+ No hay alertas de stock críticas en esta sucursal.
-
-
-
-
-
-
- Alcohol Isopropílico 1L
- Bajo Stock
-
-
- Mínimo requerido: 3 botellas | Actual en stock: 1 botella.
-
-
-
+ )}
diff --git a/frontend/src/views/admin/InventoryPage.jsx b/frontend/src/views/admin/InventoryPage.jsx
index cb5104c..07de3bd 100644
--- a/frontend/src/views/admin/InventoryPage.jsx
+++ b/frontend/src/views/admin/InventoryPage.jsx
@@ -1,36 +1,797 @@
+import React, { useState, useEffect, useRef } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
-import { Package } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
+import {
+ Package,
+ PlusCircle,
+ Search,
+ Trash2,
+ AlertCircle,
+ Pencil,
+ Download,
+ Upload,
+ Plus,
+ Minus,
+ X,
+ Save,
+ Scan
+} from "lucide-react";
+import { api } from "@/services/api";
export default function InventoryPage() {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Filtros
+ const [searchQuery, setSearchQuery] = useState("");
+ const [categoryFilter, setCategoryFilter] = useState("todas");
+ const [stockFilter, setStockFilter] = useState("todos");
+
+ // Control del modal
+ const [showModal, setShowModal] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+
+ // Formulario
+ const [itemName, setItemName] = useState("");
+ const [itemCategory, setItemCategory] = useState("Insumos");
+ const [itemDesc, setItemDesc] = useState("");
+ const [itemQty, setItemQty] = useState(0);
+ const [itemMinQty, setItemMinQty] = useState(0);
+ const [itemPrice, setItemPrice] = useState(0);
+ const [itemCode, setItemCode] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const fileInputRef = useRef(null);
+
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ const data = await api.inventory.getAll();
+ setItems(data);
+ } catch (error) {
+ toast.error("Error al cargar inventario", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ // 🔌 LECTOR DE CÓDIGO DE BARRAS EN INVENTARIO (BÚSQUEDA Y ALTA RÁPIDA)
+ useEffect(() => {
+ let buffer = "";
+ let lastKeyTime = Date.now();
+
+ const handleKeyDown = (e) => {
+ const activeEl = document.activeElement;
+ const isInput = activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA";
+
+ // Si está en otro input (ej. nombre o descripción), no capturamos para evitar pisar la escritura del usuario.
+ // Pero si está en el input del SKU o fuera de los inputs, sí leemos.
+ if (isInput && activeEl.id !== "item-code") {
+ return;
+ }
+
+ const currentTime = Date.now();
+ if (currentTime - lastKeyTime > 100) {
+ buffer = "";
+ }
+ lastKeyTime = currentTime;
+
+ if (e.key === "Enter") {
+ if (buffer.length >= 3) {
+ e.preventDefault();
+ handleInventoryScan(buffer.trim());
+ buffer = "";
+ }
+ } else if (e.key.length === 1) {
+ buffer += e.key;
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [items, showModal]);
+
+ const handleInventoryScan = (barcode) => {
+ // Buscar si el código ya existe en el stock
+ const existingItem = items.find(
+ (item) => item.code.toLowerCase() === barcode.toLowerCase()
+ );
+
+ if (existingItem) {
+ // 1. Si existe: Abrir modal de edición para ajustar stock rápidamente
+ handleOpenEditModal(existingItem);
+ toast.info(`Insumo detectado: ${existingItem.name}`, {
+ description: `Se abrió la edición para ajustar cantidad. Código: ${existingItem.code}`
+ });
+ } else {
+ // 2. Si no existe: Abrir modal de creación con el código precargado
+ setEditingId(null);
+ setItemName("");
+ setItemCategory("Insumos");
+ setItemDesc("");
+ setItemQty(0);
+ setItemMinQty(0);
+ setItemPrice(0);
+ setItemCode(barcode);
+ setShowModal(true);
+
+ toast.success(`Nuevo código detectado: "${barcode}"`, {
+ description: "Modal de registro abierto. Complete el nombre y precio del insumo.",
+ duration: 5000
+ });
+
+ // Mover foco al campo de nombre automáticamente tras abrirse el modal
+ setTimeout(() => {
+ document.getElementById("item-name")?.focus();
+ }, 150);
+ }
+ };
+
+ // Prevenir que el Enter del escáner guarde el modal al estar sobre el SKU input
+ const handleCodeKeyDown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault(); // Evitar submit accidental del formulario
+ document.getElementById("item-name")?.focus(); // Pasar foco a Nombre
+ }
+ };
+
+ // Abrir modal para crear
+ const handleOpenCreateModal = () => {
+ setEditingId(null);
+ setItemName("");
+ setItemCategory("Insumos");
+ setItemDesc("");
+ setItemQty(0);
+ setItemMinQty(0);
+ setItemPrice(0);
+ setItemCode("");
+ setShowModal(true);
+ };
+
+ // Abrir modal para editar
+ const handleOpenEditModal = (item) => {
+ setEditingId(item._id);
+ setItemName(item.name || "");
+ setItemCategory(item.category || "Insumos");
+ setItemDesc(item.description || "");
+ setItemQty(item.quantity || 0);
+ setItemMinQty(item.min_quantity || item.minQuantity || 0);
+ setItemPrice(item.price || 0);
+ setItemCode(item.code || "");
+ setShowModal(true);
+ };
+
+ // Guardar insumo (crear o editar)
+ const handleSaveItem = async (e) => {
+ e.preventDefault();
+ if (!itemName) {
+ toast.error("El nombre del insumo es obligatorio.");
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+ const finalCode = itemCode.trim() || `INV-${Math.floor(100 + Math.random() * 900)}`;
+
+ if (editingId) {
+ // Modificar
+ await api.inventory.update(editingId, {
+ name: itemName,
+ category: itemCategory,
+ description: itemDesc,
+ quantity: Number(itemQty),
+ minQuantity: Number(itemMinQty),
+ price: Number(itemPrice),
+ code: finalCode
+ });
+ toast.success("¡Insumo Actualizado!", {
+ description: `${itemName} fue modificado correctamente.`
+ });
+ } else {
+ // Crear
+ await api.inventory.create({
+ name: itemName,
+ category: itemCategory,
+ description: itemDesc,
+ quantity: Number(itemQty),
+ minQuantity: Number(itemMinQty),
+ price: Number(itemPrice),
+ code: finalCode
+ });
+ toast.success("¡Insumo Registrado!", {
+ description: `${itemName} se añadió al catálogo con el código ${finalCode}.`
+ });
+ }
+
+ setShowModal(false);
+ loadData();
+ } catch (error) {
+ toast.error("Error al guardar insumo", {
+ description: error.message || "Por favor, intente de nuevo."
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Ajustar cantidad de stock (+ / -) rápidamente en la tabla
+ const adjustQuantity = async (id, delta) => {
+ const item = items.find(i => i._id === id);
+ if (!item) return;
+
+ const currentQty = item.quantity || 0;
+ const newQty = currentQty + delta;
+ if (newQty < 0) {
+ toast.error("La cantidad no puede ser inferior a 0.");
+ return;
+ }
+
+ try {
+ await api.inventory.adjustStock(id, delta);
+ const minQty = item.min_quantity || item.minQuantity || 0;
+ if (delta < 0 && newQty < minQty) {
+ toast.warning(`¡Bajo Stock Crítico!`, {
+ description: `El stock de ${item.name} ha caído a ${newQty}. Mínimo sugerido: ${minQty}.`
+ });
+ } else {
+ toast.success("Stock ajustado correctamente.");
+ }
+ loadData();
+ } catch (error) {
+ toast.error("Error al ajustar stock", {
+ description: error.message || "Intente de nuevo."
+ });
+ }
+ };
+
+ // Eliminar insumo
+ const handleDeleteItem = async (id, name) => {
+ if (!window.confirm(`¿Está seguro de que desea eliminar permanentemente el insumo "${name}"?`)) {
+ return;
+ }
+
+ try {
+ await api.inventory.delete(id);
+ toast.success(`Insumo eliminado`, {
+ description: `${name} fue retirado del catálogo.`
+ });
+ loadData();
+ } catch (error) {
+ toast.error("Error al eliminar insumo", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
+ };
+
+ // 📤 EXPORTAR A CSV
+ const handleExportCSV = () => {
+ if (items.length === 0) {
+ toast.error("No hay insumos para exportar.");
+ return;
+ }
+
+ const headers = ["Codigo", "Nombre", "Categoria", "Descripcion", "Stock", "StockMinimo", "PrecioVenta"];
+ const rows = items.map(item => [
+ item.code,
+ item.name,
+ item.category || "Insumos",
+ item.description || "",
+ item.quantity || 0,
+ item.min_quantity || item.minQuantity || 0,
+ item.price || 0
+ ]);
+
+ const csvContent = "data:text/csv;charset=utf-8,\uFEFF"
+ + [headers.join(","), ...rows.map(e => e.map(val => `"${String(val).replace(/"/g, '""')}"`).join(","))].join("\n");
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", `inventario_${new Date().toISOString().split('T')[0]}.csv`);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ toast.success("Inventario exportado como CSV.");
+ };
+
+ // 📥 IMPORTAR DESDE CSV
+ const handleImportCSV = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async (event) => {
+ try {
+ const text = event.target.result;
+ const lines = text.split("\n").map(l => l.trim()).filter(Boolean);
+ if (lines.length < 2) {
+ throw new Error("El archivo CSV no contiene registros válidos.");
+ }
+
+ const user = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ if (!user.organizationId || !user.venueId || user.venueId === "undefined") {
+ throw new Error("Sesión de sucursal inválida.");
+ }
+
+ const parsedItems = [];
+ for (let i = 1; i < lines.length; i++) {
+ const row = lines[i].split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).map(c => c.replace(/^["']|["']$/g, "").trim());
+ if (row.length < 2) continue;
+
+ const code = row[0] || `INV-${Math.floor(100 + Math.random() * 900)}`;
+ const name = row[1];
+ const category = row[2] || "Insumos";
+ const description = row[3] || "";
+ const quantity = Number(row[4]) || 0;
+ const minQuantity = Number(row[5]) || 0;
+ const price = Number(row[6]) || 0;
+
+ if (!name) continue;
+
+ parsedItems.push({
+ organization_id: user.organizationId,
+ venue_id: user.venueId,
+ code,
+ name,
+ category,
+ description,
+ quantity,
+ min_quantity: minQuantity,
+ price
+ });
+ }
+
+ if (parsedItems.length === 0) {
+ throw new Error("No se encontraron filas con nombres de insumos válidos.");
+ }
+
+ await api.inventory.bulkUpsert(parsedItems);
+ toast.success("¡Importación completada con éxito!", {
+ description: `Se procesaron ${parsedItems.length} insumos de forma masiva.`
+ });
+ loadData();
+ } catch (error) {
+ toast.error("Error al importar CSV", {
+ description: error.message || "Compruebe el formato de las columnas."
+ });
+ }
+ };
+ reader.readAsText(file);
+ e.target.value = "";
+ };
+
+ // Obtener estado de stock (Normal, Bajo, Sin Stock)
+ const getStockStatus = (quantity, minQuantity) => {
+ if (quantity === 0) {
+ return { label: "Sin Stock", variant: "destructive", colorClass: "text-red-400 bg-red-500/10 border-red-500/20" };
+ }
+ if (quantity < minQuantity) {
+ return { label: "Bajo Stock", variant: "warning", colorClass: "text-amber-400 bg-amber-500/10 border-amber-500/20" };
+ }
+ return { label: "Stock Normal", variant: "success", colorClass: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20" };
+ };
+
+ // Filtrado de items
+ const filteredItems = items.filter(item => {
+ const term = searchQuery.toLowerCase().trim();
+ const matchesSearch =
+ (item.code && item.code.toLowerCase().includes(term)) ||
+ (item.name && item.name.toLowerCase().includes(term)) ||
+ (item.description && item.description.toLowerCase().includes(term));
+
+ const matchesCategory = categoryFilter === "todas" || item.category === categoryFilter;
+
+ let matchesStock = true;
+ const minQty = item.min_quantity || item.minQuantity || 0;
+ if (stockFilter === "bajo") {
+ matchesStock = item.quantity > 0 && item.quantity < minQty;
+ } else if (stockFilter === "sin") {
+ matchesStock = item.quantity === 0;
+ } else if (stockFilter === "normal") {
+ matchesStock = item.quantity >= minQty;
+ }
+
+ return matchesSearch && matchesCategory && matchesStock;
+ });
+
+ const totalValue = items.reduce((acc, curr) => acc + ((curr.price || 0) * (curr.quantity || 0)), 0);
+ const criticalItems = items.filter(i => (i.quantity || 0) < (i.min_quantity || i.minQuantity || 0)).length;
+
return (
-
-
-
- Inventario de Insumos
-
-
- Control de stock y alertas de reposición de componentes técnicos.
-
+
+
+ {/* Cabecera */}
+
+
+
+
+ Control de Inventario
+
+
+ Monitorea el stock de repuestos e insumos del taller, importa listas de precios y exporta informes.
+
+
+
+
+
+ Lector de Código Activo (Escanea para buscar o registrar)
+
+
+
+ Registrar Insumo
+
+
-
-
-
-
+ {/* Grid de Estadísticas */}
+
+
+
+ Total de Insumos
+
+
+
+ {items.length}
+ Componentes registrados
+
+
+
+
+
+ Valor Estimado Stock
+ $
+
+
+
+ ${totalValue.toLocaleString("es-AR")}
+
+ Valor de venta acumulado
+
+
+
+
0 ? "bg-destructive/5 border-destructive/20" : "bg-card/45 border-border"} shadow-md`}>
+
+ 0 ? "text-destructive/80" : "text-muted-foreground"}`}>Alertas Críticas
+ 0 ? "text-destructive animate-pulse" : "text-muted-foreground/60"}`} />
+
+
+ 0 ? "text-destructive" : "text-foreground"}`}>{criticalItems}
+ Bajo el mínimo sugerido
+
+
+
+
+ {/* Barra de herramientas */}
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border text-xs w-full pl-9 focus-visible:ring-1 focus-visible:ring-primary"
+ />
-
-
Módulo de Inventario en Desarrollo
-
- En la siguiente iteración de la Fase I implementaremos el control cuantitativo de insumos, auditoría automática de repuestos y la simulación de alerta de bajo suministro.
-
+
+
+
setCategoryFilter(e.target.value)}
+ className="h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-auto"
+ >
+ Todas las categorías
+ Insumos
+ Repuestos
+ Herramientas
+ Accesorios
+
+
+
setStockFilter(e.target.value)}
+ className="h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-auto"
+ >
+ Todos los niveles
+ Stock Normal
+ Bajo Stock
+ Sin Stock
+
+
+
+
+ fileInputRef.current?.click()}
+ className="h-9 border-border/80 text-xs font-semibold flex items-center gap-1.5 cursor-pointer w-full sm:w-auto"
+ title="Importar catálogo desde CSV"
+ >
+
+ Importar
+
+
+
+ Exportar
+
+
-
toast.info("Módulo en desarrollo.")}>
- Simular Escaneo de Stock
-
-
+
+
+ {/* Tabla de Resultados */}
+ {filteredItems.length > 0 ? (
+
+
+
+
+ Código
+ Insumo
+ Categoría
+ Precio Venta
+ Stock Actual
+ Mínimo
+ Estado
+ Acciones
+
+
+
+ {filteredItems.map((item) => {
+ const minQty = item.min_quantity || item.minQuantity || 0;
+ const { label, variant, colorClass } = getStockStatus(item.quantity || 0, minQty);
+ return (
+
+ {item.code}
+
+ {item.name}
+ {item.description && (
+ {item.description}
+ )}
+
+
+ {item.category}
+
+ ${(item.price || 0).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+ {item.quantity}
+ {minQty}
+
+
+ {label}
+
+
+
+
+
+ {/* Ajustes rápidos */}
+
adjustQuantity(item._id, 1)}
+ className="h-7 w-7 text-emerald-500 hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer rounded-full"
+ title="Sumar 1 unidad"
+ >
+
+
+
adjustQuantity(item._id, -1)}
+ className="h-7 w-7 text-amber-500 hover:text-amber-500 hover:bg-amber-500/10 cursor-pointer rounded-full"
+ title="Restar 1 unidad"
+ >
+
+
+
+
+
+ {/* Editar */}
+
handleOpenEditModal(item)}
+ className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted/40 cursor-pointer rounded-full"
+ title="Editar insumo"
+ >
+
+
+
+ {/* Borrar */}
+
handleDeleteItem(item._id, item.name)}
+ className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10 cursor-pointer rounded-full"
+ title="Eliminar insumo"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No se encontraron insumos que coincidan con la búsqueda.
+
+ )}
+
+ {/* MODAL PARA CREACIÓN / EDICIÓN */}
+ {showModal && (
+
+
+
setShowModal(false)}
+ variant="ghost"
+ size="icon"
+ className="absolute top-4 right-4 text-muted-foreground hover:text-foreground cursor-pointer rounded-full h-8 w-8"
+ >
+
+
+
+
+
+ {editingId ? "Editar Detalle de Insumo" : "Registrar Nuevo Insumo"}
+
+
+ Complete las especificaciones del artículo para actualizar el stock.
+
+
+
+
+
+
+ )}
+
);
}
diff --git a/frontend/src/views/admin/LoginPage.jsx b/frontend/src/views/admin/LoginPage.jsx
index d855d63..5c4f4eb 100644
--- a/frontend/src/views/admin/LoginPage.jsx
+++ b/frontend/src/views/admin/LoginPage.jsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
+import { api } from "@/services/api";
+import { supabase } from "@/services/supabaseClient";
export default function LoginPage() {
const [email, setEmail] = useState("");
@@ -13,13 +15,42 @@ export default function LoginPage() {
const [rememberMe, setRememberMe] = useState(false);
const navigate = useNavigate();
- const handleLogin = (e) => {
+ useEffect(() => {
+ const token = localStorage.getItem("repairit_token");
+ if (token) {
+ navigate("/dashboard");
+ }
+ }, [navigate]);
+
+ const handleGoogleLogin = async () => {
+ try {
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider: "google",
+ options: {
+ redirectTo: window.location.origin + "/dashboard",
+ },
+ });
+ if (error) throw error;
+ } catch (error) {
+ toast.error("Error al conectar con Google", {
+ description: error.message,
+ });
+ }
+ };
+
+ const handleLogin = async (e) => {
e.preventDefault();
- // Simular el inicio de sesión y redirigir al Dashboard
- toast.success("¡Acceso concedido!", {
- description: `Bienvenido de vuelta, técnico.`
- });
- navigate("/dashboard");
+ try {
+ const data = await api.auth.login(email, password);
+ toast.success("¡Acceso concedido!", {
+ description: `Bienvenido de vuelta, ${data.name}.`
+ });
+ navigate("/dashboard");
+ } catch (error) {
+ toast.error("Error al iniciar sesión", {
+ description: error.message || "Credenciales inválidas. Intente nuevamente."
+ });
+ }
};
return (
@@ -96,6 +127,39 @@ export default function LoginPage() {
+
+
+
+
+
+
+
+
+
+ Iniciar sesión con Google
+
+
{
+ e.preventDefault();
+
+ if (!acceptTerms) {
+ toast.error("Debe aceptar los términos de recepción técnica.");
+ return;
+ }
+
+ try {
+ // 1. Registrar o actualizar cliente mediante la API
+ const client = await api.clients.create({
+ name: clientName,
+ dni: clientDni,
+ phone: clientPhone,
+ email: clientEmail,
+ });
+
+ const dateStr = new Date().toLocaleDateString("es-AR");
+ const timeStr = new Date().toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
+
+ // 2. Crear la orden de servicio vinculada
+ const order = await api.orders.create({
+ clientId: client._id,
+ deviceType,
+ deviceModel,
+ accessories: accessories || "Ninguno",
+ cosmetic: cosmeticCondition || "Sin detalles",
+ issue,
+ date: dateStr,
+ time: timeStr,
+ });
+
+ toast.success("¡Dispositivo Registrado!", {
+ description: `Código de seguimiento: ${order.trackingCode}.`,
+ });
+
+ // Resetear formulario y navegar a la lista
+ setClientName("");
+ setClientDni("");
+ setClientPhone("");
+ setClientEmail("");
+ setDeviceType("Notebook");
+ setDeviceModel("");
+ setAccessories("");
+ setCosmeticCondition("");
+ setIssue("");
+ setAcceptTerms(false);
+
+ navigate("/dashboard/ordenes");
+ } catch (error) {
+ toast.error("Error al registrar ingreso técnico", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
+ };
+
+ return (
+
+ {/* Cabecera interna */}
+
+
+
+ Ingreso de Equipos
+
+
+ Registro rápido de dispositivos recibidos para diagnóstico y reparación.
+
+
+
+
+
+
+
+
+ Nuevo Ingreso Técnico
+
+
+ Completá el perfil del cliente y las especificaciones del dispositivo.
+
+
+
+
+
+ {/* Sección: Datos del Cliente */}
+
+
1. Datos del Cliente
+
+
+ Nombre Completo
+ setClientName(e.target.value)}
+ placeholder="Edgar Ortiz"
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+
+
+ Email
+ setClientEmail(e.target.value)}
+ placeholder="edgar@repairit.cloud"
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+
+
+ {/* Sección: Datos del Equipo */}
+
+
2. Datos del Equipo
+
+
+ Tipo de Dispositivo
+ setDeviceType(e.target.value)}
+ className="w-full h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
+ >
+ Notebook
+ PC Escritorio
+ Consola
+ Celular
+ Otro
+
+
+
+ Marca y Modelo
+ setDeviceModel(e.target.value)}
+ placeholder="ASUS ROG Strix G15"
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+
+
+
+ Falla Reportada
+ setIssue(e.target.value)}
+ placeholder="Describí los síntomas del dispositivo..."
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs resize-none"
+ />
+
+
+
+ {/* Checkbox de Recepción */}
+
+
setAcceptTerms(checked)}
+ className="border-border focus-visible:ring-primary"
+ />
+
+
+ Confirmo la recepción física del equipo y autorizo la apertura del dispositivo para su diagnóstico.
+
+
+
+
+
+ Registrar Ingreso
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/views/admin/OrdersPage.jsx b/frontend/src/views/admin/OrdersPage.jsx
index 79cacb6..a4b6681 100644
--- a/frontend/src/views/admin/OrdersPage.jsx
+++ b/frontend/src/views/admin/OrdersPage.jsx
@@ -1,14 +1,25 @@
-import { useState } from "react";
-import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
+import { useState, useEffect } from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
-import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
-import { PlusCircle, ClipboardList, Trash2, ChevronDown } from "lucide-react";
+import {
+ ClipboardList,
+ Trash2,
+ ChevronDown,
+ Search,
+ FileSpreadsheet,
+ Printer,
+ Stethoscope,
+ FileText,
+ Plus,
+ X,
+ DollarSign,
+ Save
+} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -16,103 +27,329 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { api } from "@/services/api";
+
export default function OrdersPage() {
- // Estado para la tabla dinámica de órdenes de servicio
- const [orders, setOrders] = useState([
- {
- id: "RT-2026-ASUS",
- clientName: "Edgar Javier Ortiz",
- clientDni: "44.375.912",
- deviceType: "Notebook",
- deviceModel: "ASUS ROG Strix G15",
- serial: "SN-98218-ASUS",
- issue: "Calentamiento excesivo y reinicios aleatorios.",
- status: "presupuestado",
- date: "05/06/2026"
- },
- {
- id: "RT-2026-MOCK",
- clientName: "Cliente Registrado",
- clientDni: "29.987.654",
- deviceType: "Celular",
- deviceModel: "Samsung S23 Ultra",
- serial: "SN-PENDIENTE",
- issue: "Pantalla astillada tras caída física y puerto de carga flojo.",
- status: "diagnostico",
- date: "08/06/2026"
+ const [orders, setOrders] = useState([]);
+ const [clients, setClients] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [statusFilter, setStatusFilter] = useState("todos");
+
+ // Estado del perfil del taller cargado de la DB
+ const [profile, setProfile] = useState({
+ name: "RepairIT - Taller Central",
+ address: "Av. Sarmiento 1234, San Miguel de Tucumán",
+ phone: "+54 381 4223344",
+ email: "central@repairit.cloud"
+ });
+
+ // Modal Diagnóstico y Presupuesto
+ const [showDiagModal, setShowDiagModal] = useState(false);
+ const [diagOrder, setDiagOrder] = useState(null);
+ const [diagText, setDiagText] = useState("");
+ const [diagStatus, setDiagStatus] = useState("diagnostico");
+ const [budgetItems, setBudgetItems] = useState([{ desc: "", price: "" }]);
+ const [savingDiag, setSavingDiag] = useState(false);
+
+ const loadData = async () => {
+ try {
+ setLoading(true);
+ const ordersData = await api.orders.getAll();
+ const clientsData = await api.clients.getAll();
+ setOrders(ordersData);
+ setClients(clientsData);
+
+ try {
+ const profileData = await api.auth.getProfile();
+ setProfile({
+ name: profileData.name || "RepairIT - Taller Central",
+ address: profileData.address || "Av. Sarmiento 1234, San Miguel de Tucumán",
+ phone: profileData.phone || "+54 381 4223344",
+ email: profileData.email || "central@repairit.cloud"
+ });
+ } catch (err) {
+ console.error("Error al cargar perfil del taller:", err);
+ }
+ } catch (error) {
+ toast.error("Error al cargar órdenes de servicio", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ } finally {
+ setLoading(false);
}
- ]);
+ };
- // Pestaña activa
- const [activeTab, setActiveTab] = useState("registro");
+ useEffect(() => {
+ loadData();
+ }, []);
- // Estados del formulario
- const [clientName, setClientName] = useState("");
- const [clientDni, setClientDni] = useState("");
- const [clientPhone, setClientPhone] = useState("");
- const [clientEmail, setClientEmail] = useState("");
- const [deviceType, setDeviceType] = useState("Notebook");
- const [deviceModel, setDeviceModel] = useState("");
- const [serial, setSerial] = useState("");
- const [issue, setIssue] = useState("");
- const [acceptTerms, setAcceptTerms] = useState(false);
+ // Abrir Modal de Diagnóstico y Presupuesto
+ const handleOpenDiagModal = (order) => {
+ setDiagOrder(order);
+ setDiagText(order.diagnosis || "");
+ setDiagStatus(order.status || "diagnostico");
+
+ if (order.budget && Array.isArray(order.budget.items) && order.budget.items.length > 0) {
+ setBudgetItems(order.budget.items.map(i => ({ desc: i.desc, price: i.price?.toString() || "" })));
+ } else {
+ setBudgetItems([{ desc: "", price: "" }]);
+ }
- // Registrar orden
- const handleRegisterOrder = (e) => {
+ setShowDiagModal(true);
+ };
+
+ const handleAddBudgetItem = () => {
+ setBudgetItems([...budgetItems, { desc: "", price: "" }]);
+ };
+
+ const handleRemoveBudgetItem = (index) => {
+ setBudgetItems(budgetItems.filter((_, i) => i !== index));
+ };
+
+ const handleBudgetItemChange = (index, field, value) => {
+ const updated = [...budgetItems];
+ updated[index][field] = value;
+ setBudgetItems(updated);
+ };
+
+ const handleSaveDiagnosis = async (e) => {
e.preventDefault();
+ if (!diagOrder) return;
- if (!acceptTerms) {
- toast.error("Debe aceptar los términos de recepción técnica.");
+ try {
+ setSavingDiag(true);
+
+ const validBudgetItems = budgetItems
+ .filter(i => i.desc.trim() !== "")
+ .map(i => ({ desc: i.desc.trim(), price: Number(i.price) || 0 }));
+
+ const totalBudget = validBudgetItems.reduce((acc, i) => acc + i.price, 0);
+
+ const budgetData = validBudgetItems.length > 0 ? {
+ items: validBudgetItems,
+ total: totalBudget,
+ approved: diagOrder.budget?.approved || false,
+ dateApproved: diagOrder.budget?.dateApproved || null
+ } : null;
+
+ await api.orders.updateDiagnosisAndBudget(diagOrder._id, {
+ diagnosis: diagText,
+ budget: budgetData,
+ status: diagStatus
+ });
+
+ toast.success("Diagnóstico y Presupuesto guardados", {
+ description: `Se actualizó la orden ${diagOrder.trackingCode}.`
+ });
+
+ setShowDiagModal(false);
+ loadData();
+ } catch (error) {
+ toast.error("Error al guardar diagnóstico", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ } finally {
+ setSavingDiag(false);
+ }
+ };
+
+ // Exportar listado a Excel CSV
+ const handleExportToExcel = () => {
+ if (orders.length === 0) {
+ toast.error("No hay órdenes registradas para exportar.");
return;
}
- const trackingCode = `RT-2026-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
- const newOrder = {
- id: trackingCode,
- clientName,
- clientDni,
- deviceType,
- deviceModel,
- serial: serial || "SN-PENDIENTE",
- issue,
- status: "ingresado",
- date: new Date().toLocaleDateString("es-AR")
- };
-
- setOrders([newOrder, ...orders]);
-
- toast.success("¡Dispositivo Registrado!", {
- description: `Código de seguimiento: ${trackingCode}.`,
+ const headers = ["ID Orden", "Cliente", "DNI", "Dispositivo", "Falla Reportada", "Diagnostico", "Total Presupuesto", "Fecha Ingreso", "Estado"];
+ const rows = orders.map(o => {
+ const client = o.clientId || {};
+ const budgetTotal = o.budget ? o.budget.items?.reduce((s, i) => s + i.price, 0) || 0 : 0;
+ return [
+ o.trackingCode || o._id,
+ `"${client.name || "N/A"}"`,
+ `"${client.dni || "N/A"}"`,
+ `"${o.deviceType} ${o.deviceModel}"`,
+ `"${(o.issue || "").replace(/"/g, '""')}"`,
+ `"${(o.diagnosis || "").replace(/"/g, '""')}"`,
+ budgetTotal,
+ o.date,
+ o.status.toUpperCase()
+ ];
});
- // Resetear formulario y mover a la pestaña de listado
- setClientName("");
- setClientDni("");
- setClientPhone("");
- setClientEmail("");
- setDeviceType("Notebook");
- setDeviceModel("");
- setSerial("");
- setIssue("");
- setAcceptTerms(false);
- setActiveTab("control");
+ const csvContent = "data:text/csv;charset=utf-8,\uFEFF" + [headers.join(","), ...rows.map(r => r.join(","))].join("\n");
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", `Ordenes_Reparacion_${new Date().toISOString().split("T")[0]}.csv`);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("Listado de órdenes exportado a Excel CSV.");
};
- // Eliminar orden localmente (simulado)
- const handleDeleteOrder = (id) => {
- setOrders(orders.filter(o => o.id !== id));
- toast.error(`Orden ${id} eliminada del listado.`);
+ // Imprimir comprobante de recepción
+ const handlePrintOrder = (order) => {
+ const client = order.clientId || {};
+ const venueName = profile.name;
+ const venueAddress = profile.address;
+ const venuePhone = profile.phone;
+ const venueEmail = profile.email;
+
+ const printWindow = window.open("", "_blank", "width=800,height=900");
+ if (!printWindow) {
+ toast.error("El navegador bloqueó la ventana emergente de impresión.");
+ return;
+ }
+
+ const budgetItemsHtml = order.budget?.items?.map(i => `
+
+ ${i.desc}
+ $${i.price.toLocaleString("es-AR")}
+
+ `).join("") || "";
+
+ const totalBudget = order.budget?.items?.reduce((s, i) => s + i.price, 0) || 0;
+
+ printWindow.document.write(`
+
+
+
+ Comprobante de Recepción - Orden #${order.trackingCode}
+
+
+
+
+
+
+
+
DATOS DEL CLIENTE
+
+
+
Cliente
+
${client.name || "N/A"}
+
DNI / Identificación
+
${client.dni || "N/A"}
+
Contacto
+
${client.phone || ""} • ${client.email || ""}
+
+
+
Fecha de Ingreso
+
${order.date}
+
Hora
+
${order.time || "10:30"} hs
+
+
+
+
+
+
DETALLES DEL EQUIPO
+
+
+
Tipo de Equipo
+
${order.deviceType}
+
+
+
Marca y Modelo
+
${order.deviceModel}
+
+
+
+
+
Accesorios Recibidos
+
${order.accessories || "Ninguno"}
+
+
+
Estado Cosmético
+
${order.cosmetic || "Sin detalles"}
+
+
+
+
+
+
FALLA REPORTADA POR CLIENTE
+
${order.issue}
+
+
+ ${order.diagnosis ? `
+
+
DIAGNÓSTICO TÉCNICO DE LABORATORIO
+
${order.diagnosis}
+
+ ` : ""}
+
+ ${budgetItemsHtml ? `
+
+
PRESUPUESTO ESTIMADO DEL SERVICIO
+
+
+ TOTAL PREVISTO: $${totalBudget.toLocaleString("es-AR")}
+
+
+ ` : ""}
+
+
+
+
+
+
+
+ `);
+ printWindow.document.close();
};
- // Manejar cambio de estado en la tabla
- const handleStatusChange = (id, newStatus) => {
- setOrders(prev => prev.map(o => o.id === id ? { ...o, status: newStatus } : o));
- toast.success(`Estado de orden ${id} actualizado`, {
- description: `Nuevo estado: ${newStatus.toUpperCase()}`,
- });
+ const handleStatusChange = async (id, newStatus) => {
+ try {
+ await api.orders.updateStatus(id, newStatus, `Estado cambiado a ${newStatus.toUpperCase()}`);
+ toast.success(`Estado de orden actualizado`, {
+ description: `Nuevo estado: ${newStatus.toUpperCase()}`,
+ });
+ loadData();
+ } catch (error) {
+ toast.error("Error al actualizar estado", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
};
- // Selector interactivo de estado de tipo Dropdown Menu de shadcn
const getStatusDropdown = (orderId, currentStatus) => {
let colorClass = "";
let statusLabel = "";
@@ -122,24 +359,29 @@ export default function OrdersPage() {
statusLabel = "Ingresado";
break;
case "diagnostico":
- colorClass = "bg-amber-500/10 text-amber-400 border-amber-500/25 hover:bg-amber-500/20";
+ colorClass = "bg-purple-500/10 text-purple-400 border-purple-500/25 hover:bg-purple-500/20";
statusLabel = "En Diagnóstico";
break;
case "presupuestado":
- colorClass = "bg-purple-500/10 text-purple-400 border-purple-500/25 hover:bg-purple-500/20";
+ colorClass = "bg-amber-500/10 text-amber-400 border-amber-500/25 hover:bg-amber-500/20";
statusLabel = "Presupuestado";
break;
case "reparacion":
- colorClass = "bg-blue-500/10 text-blue-400 border-blue-500/25 hover:bg-blue-500/20";
+ case "en_reparacion":
+ colorClass = "bg-indigo-500/10 text-indigo-400 border-indigo-500/25 hover:bg-indigo-500/20";
statusLabel = "En Reparación";
break;
case "listo":
colorClass = "bg-emerald-500/10 text-emerald-400 border-emerald-500/25 hover:bg-emerald-500/20";
statusLabel = "Listo";
break;
- default:
- colorClass = "bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80";
+ case "entregado":
+ colorClass = "bg-muted text-muted-foreground border-border hover:bg-muted/80";
statusLabel = "Entregado";
+ break;
+ default:
+ colorClass = "bg-secondary text-secondary-foreground";
+ statusLabel = currentStatus;
}
return (
@@ -148,47 +390,29 @@ export default function OrdersPage() {
{statusLabel}
-
+
-
- handleStatusChange(orderId, "ingresado")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-sky-500/10 text-sky-400 border-sky-500/15 hover:bg-sky-500/20 hover:text-sky-350 focus:bg-sky-500/20 focus:text-sky-350"
- >
+
+ handleStatusChange(orderId, "ingresado")} className="text-xs cursor-pointer">
Ingresado
- handleStatusChange(orderId, "diagnostico")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-amber-500/10 text-amber-400 border-amber-500/15 hover:bg-amber-500/20 hover:text-amber-350 focus:bg-amber-500/20 focus:text-amber-350"
- >
+ handleStatusChange(orderId, "diagnostico")} className="text-xs cursor-pointer">
En Diagnóstico
- handleStatusChange(orderId, "presupuestado")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-purple-500/10 text-purple-400 border-purple-500/15 hover:bg-purple-500/20 hover:text-purple-350 focus:bg-purple-500/20 focus:text-purple-350"
- >
+ handleStatusChange(orderId, "presupuestado")} className="text-xs cursor-pointer">
Presupuestado
- handleStatusChange(orderId, "reparacion")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-blue-500/10 text-blue-400 border-blue-500/15 hover:bg-blue-500/20 hover:text-blue-350 focus:bg-blue-500/20 focus:text-blue-350"
- >
+ handleStatusChange(orderId, "reparacion")} className="text-xs cursor-pointer">
En Reparación
- handleStatusChange(orderId, "listo")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-emerald-500/10 text-emerald-400 border-emerald-500/15 hover:bg-emerald-500/20 hover:text-emerald-350 focus:bg-emerald-500/20 focus:text-emerald-350"
- >
+ handleStatusChange(orderId, "listo")} className="text-xs cursor-pointer">
Listo
- handleStatusChange(orderId, "entregado")}
- className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80 hover:text-zinc-350 focus:bg-zinc-800/80 focus:text-zinc-350"
- >
+ handleStatusChange(orderId, "entregado")} className="text-xs cursor-pointer">
Entregado
@@ -196,277 +420,329 @@ export default function OrdersPage() {
);
};
+ const filteredOrders = orders.filter((o) => {
+ const client = o.clientId || {};
+ const term = searchQuery.toLowerCase().trim();
+ const matchesSearch =
+ (o.trackingCode && o.trackingCode.toLowerCase().includes(term)) ||
+ (client.name && client.name.toLowerCase().includes(term)) ||
+ (client.dni && client.dni.includes(term)) ||
+ (o.deviceModel && o.deviceModel.toLowerCase().includes(term));
+
+ const matchesStatus = statusFilter === "todos" || o.status === statusFilter;
+ return matchesSearch && matchesStatus;
+ });
+
return (
{/* Cabecera interna */}
-
- Órdenes de Servicio
+
+
+ Órdenes de Servicio
- Alta de dispositivos ingresados y control de trazabilidad técnica del taller.
+ Seguimiento de trazabilidad técnica del taller, carga de diagnósticos, presupuestos y cambio de estados.
- {/* Selector de Pestañas (Tabs) estilo shadcn */}
-
- setActiveTab("registro")}
- className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
- activeTab === "registro"
- ? "border-primary text-foreground"
- : "border-transparent text-muted-foreground hover:text-foreground"
- }`}
- >
- Registrar Ingreso
-
- setActiveTab("control")}
- className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
- activeTab === "control"
- ? "border-primary text-foreground"
- : "border-transparent text-muted-foreground hover:text-foreground"
- }`}
- >
- Control de Trazabilidad y Estados
-
-
-
- {activeTab === "registro" ? (
-
- {/* Formulario de Ingreso de Dispositivo (Card shadcn) */}
-
-
-
-
- Ingreso de Equipo
-
-
- Registrá los datos del cliente y el dispositivo a reparar.
-
+
+
+
+
+
+ Control de Trazabilidad
+
+ Total: {orders.length} Equipos
+
+
+ {/* Filtros y Buscador */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border text-xs w-full pl-9 h-9"
+ />
-
-
-
- {/* Sección: Datos del Cliente */}
-
-
1. Datos del Cliente
-
-
- Nombre Completo
- setClientName(e.target.value)}
- placeholder="Edgar Ortiz"
- className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
- />
-
-
-
-
-
- Email
- setClientEmail(e.target.value)}
- placeholder="edgar@repairit.cloud"
- className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
- />
-
-
-
-
-
- {/* Sección: Datos del Equipo */}
-
-
2. Datos del Equipo
-
-
-
- Tipo
- setDeviceType(e.target.value)}
- className="w-full h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
- >
- Notebook
- PC Escritorio
- Consola
- Celular
- Otro
-
-
-
- Número de Serie
- setSerial(e.target.value)}
- placeholder="SN-XXXXX"
- className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
- />
-
-
-
-
- Marca y Modelo
- setDeviceModel(e.target.value)}
- placeholder="ASUS ROG Strix G15"
- className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
- />
-
-
-
- Falla Reportada
- setIssue(e.target.value)}
- placeholder="Describí los síntomas del dispositivo..."
- className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs resize-none"
- />
-
-
-
- {/* Checkbox de Recepción */}
-
-
setAcceptTerms(checked)}
- className="border-border focus-visible:ring-primary"
- />
-
-
- Confirmo la recepción física del equipo y autorizo la apertura del dispositivo para su diagnóstico.
-
-
-
-
-
+ setStatusFilter(e.target.value)}
+ className="h-9 bg-background/85 border border-border rounded-lg px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-auto"
>
- Registrar Ingreso
+ Todos los estados
+ Ingresado
+ En Diagnóstico
+ Presupuestado
+ En Reparación
+ Listo
+ Entregado
+
+
+
+ Excel
-
-
-
-
- ) : (
- /* Listado y Control de Órdenes Registradas (Card shadcn) */
-
-
-
-
-
- Control de Trazabilidad
-
- Total: {orders.length} Equipos
+
- {orders.length > 0 ? (
-
-
-
-
- Código
- Cliente
- Dispositivo
- Fecha
- Estado (Editar)
- Acción
-
-
-
- {orders.map((o) => (
-
- {o.id}
+ {/* Tabla de Órdenes */}
+ {loading ? (
+
+
+ Cargando órdenes de servicio...
+
+ ) : filteredOrders.length > 0 ? (
+
+
+
+
+ N° de Orden
+ Cliente
+ Dispositivo
+ Diagnóstico / Presupuesto
+ Estado
+ Acciones
+
+
+
+ {filteredOrders.map((o) => {
+ const client = o.clientId || {};
+ const hasDiag = !!o.diagnosis;
+ const hasBudget = o.budget && Array.isArray(o.budget.items) && o.budget.items.length > 0;
+ const totalB = hasBudget ? o.budget.items.reduce((s, i) => s + i.price, 0) : 0;
+
+ return (
+
+ {o.trackingCode}
- {o.clientName}
- {o.clientDni}
+ {client.name || "N/A"}
+
+ {client.dni && DNI: {client.dni} }
+ {client.phone && Tel: {client.phone} }
+
{o.deviceModel}
{o.deviceType}
- {o.date}
- {getStatusDropdown(o.id, o.status)}
+
+
+ {hasDiag ? (
+
+ Diagnóstico OK
+
+ ) : (
+ Sin Diagnóstico
+ )}
+ {hasBudget ? (
+
+ ${totalB.toLocaleString("es-AR")}
+
+ ) : (
+ Sin Presupuesto
+ )}
+
+
+ {getStatusDropdown(o._id, o.status)}
- handleDeleteOrder(o.id)}
- className="text-muted-foreground hover:text-red-400 hover:bg-red-950/15"
- title="Eliminar de la lista de prueba"
- >
-
-
+
+
handleOpenDiagModal(o)}
+ className="h-8 px-2 text-xs font-bold border-primary/30 text-primary hover:bg-primary/10 rounded-lg flex items-center gap-1 cursor-pointer"
+ title="Cargar Diagnóstico y Presupuesto"
+ >
+
+ Diag/Presup.
+
+
+
handlePrintOrder(o)}
+ className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-full cursor-pointer"
+ title="Imprimir Comprobante de Recepción"
+ >
+
+
+
- ))}
-
-
-
- ) : (
-
-
- No hay órdenes de servicio cargadas. Ve a la pestaña de "Registrar Ingreso" para añadir un ingreso técnico.
-
-
- )}
-
-
-
- * Nota: Los registros agregados persisten únicamente en el estado local de la sesión de prueba de React.
-
-
- )}
+ );
+ })}
+
+
+
+ ) : (
+
+
+ No se encontraron órdenes de servicio.
+
+
+ )}
+
+
+
+ {/* MODAL DE DIAGNÓSTICO Y PRESUPUESTO */}
+ {showDiagModal && diagOrder && (
+
+
+
+
+
+
+ Diagnóstico y Presupuesto
+
+
+ Orden N° {diagOrder.trackingCode} • {diagOrder.deviceType} {diagOrder.deviceModel}
+
+
+ setShowDiagModal(false)}
+ className="h-8 w-8 text-muted-foreground hover:text-foreground rounded-full cursor-pointer"
+ >
+
+
+
+
+
+
+ {/* Diagnóstico Técnico */}
+
+
+
+ Diagnóstico Técnico de Laboratorio
+
+ setDiagText(e.target.value)}
+ className="bg-background/80 border-border text-xs focus-visible:ring-1 focus-visible:ring-primary rounded-xl"
+ />
+
+
+ {/* Presupuesto de Mano de Obra y Repuestos */}
+
+
+
+
+ Ítems del Presupuesto (Mano de obra y repuestos)
+
+
+
+ Agregar Ítem
+
+
+
+
+ {budgetItems.map((item, idx) => (
+
+ ))}
+
+
+ {/* Total calculado */}
+
+ TOTAL PRESUPUESTADO:
+
+ ${budgetItems.reduce((acc, i) => acc + (Number(i.price) || 0), 0).toLocaleString("es-AR", { minimumFractionDigits: 2 })}
+
+
+
+
+ {/* Cambiar Estado */}
+
+
+ Actualizar Estado de la Orden
+
+ setDiagStatus(e.target.value)}
+ className="w-full h-9 bg-background border border-border rounded-xl px-3 text-xs font-semibold text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
+ >
+ En Diagnóstico
+ Presupuestado (Listo para envío al cliente)
+ En Reparación
+ Listo para Entregar
+
+
+
+ {/* Botones de Acción */}
+
+ setShowDiagModal(false)}
+ className="h-9 text-xs font-bold rounded-xl cursor-pointer"
+ >
+ Cancelar
+
+
+
+ {savingDiag ? "Guardando..." : "Guardar Diagnóstico y Presupuesto"}
+
+
+
+
+
+
+ )}
+
);
}
diff --git a/frontend/src/views/admin/POSPage.jsx b/frontend/src/views/admin/POSPage.jsx
new file mode 100644
index 0000000..8f5de0e
--- /dev/null
+++ b/frontend/src/views/admin/POSPage.jsx
@@ -0,0 +1,864 @@
+import { useState, useEffect, useRef } from "react";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import {
+ ShoppingCart,
+ Search,
+ Trash2,
+ Plus,
+ Minus,
+ Scan,
+ Printer,
+ DollarSign,
+ CreditCard,
+ Smartphone,
+ Wallet,
+ AlertCircle,
+ History,
+ Store,
+ Keyboard,
+ Sparkles,
+ Receipt
+} from "lucide-react";
+import { api } from "@/services/api";
+
+export default function POSPage() {
+ const user = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ const isMultiTaller = user.subscriptionPlan === "Multi-Taller Pro";
+ const isAdmin = user.role === "admin" || user.role === "superadmin";
+
+ const [activeTab, setActiveTab] = useState("venta"); // "venta" | "historial"
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [cart, setCart] = useState([]);
+ const [paymentMethod, setPaymentMethod] = useState("efectivo");
+ const [submitting, setSubmitting] = useState(false);
+ const [activeVenue, setActiveVenue] = useState(null);
+
+ // Calculadora de Vuelto
+ const [cashReceived, setCashReceived] = useState("0");
+
+ // Historial States
+ const [sales, setSales] = useState([]);
+ const [venues, setVenues] = useState([]);
+ const [selectedVenueFilter, setSelectedVenueFilter] = useState("todas");
+ const [salesLoading, setSalesLoading] = useState(false);
+
+ const searchInputRef = useRef(null);
+
+ const loadInventoryAndVenues = async () => {
+ try {
+ setLoading(true);
+ const data = await api.inventory.getAll();
+ setItems(data);
+
+ try {
+ const venueData = await api.auth.getProfile();
+ setActiveVenue(venueData);
+ } catch (err) {
+ console.error("Error al cargar datos del taller:", err);
+ }
+
+ if (isMultiTaller && isAdmin) {
+ try {
+ const venuesData = await api.auth.getVenues();
+ setVenues(venuesData);
+ } catch (err) {
+ console.error("Error al cargar sucursales:", err);
+ }
+ }
+ } catch (error) {
+ toast.error("Error al cargar inventario", {
+ description: error.message || "No se pudo recuperar la lista de insumos."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadSalesHistory = async (venueId = "todas") => {
+ try {
+ setSalesLoading(true);
+ const salesData = await api.sales.getAll(venueId);
+ setSales(salesData);
+ } catch (error) {
+ toast.error("Error al cargar historial de ventas", {
+ description: error.message || "No se pudo obtener el historial."
+ });
+ } finally {
+ setSalesLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadInventoryAndVenues();
+ }, []);
+
+ useEffect(() => {
+ if (activeTab === "historial") {
+ loadSalesHistory(selectedVenueFilter);
+ }
+ }, [activeTab, selectedVenueFilter]);
+
+ // Resetear vuelto al cambiar de método
+ useEffect(() => {
+ setCashReceived("0");
+ }, [paymentMethod]);
+
+ // 🔌 INTEGRACIÓN DE ATAJOS DE TECLADO Y LECTOR DE CÓDIGO
+ useEffect(() => {
+ let buffer = "";
+ let lastKeyTime = Date.now();
+
+ const handleKeyDown = (e) => {
+ // 1. ATAJOS DE TECLADO GLOBALES
+ if (e.key === "F2") {
+ e.preventDefault();
+ searchInputRef.current?.focus();
+ searchInputRef.current?.select();
+ return;
+ }
+
+ if (e.key === "F4") {
+ e.preventDefault();
+ const methods = ["efectivo", "transferencia", "debito", "credito"];
+ const nextIndex = (methods.indexOf(paymentMethod) + 1) % methods.length;
+ setPaymentMethod(methods[nextIndex]);
+ return;
+ }
+
+ if (e.key === "F8") {
+ e.preventDefault();
+ if (cart.length > 0 && !submitting) {
+ handleCheckout();
+ }
+ return;
+ }
+
+ const activeEl = document.activeElement;
+ const isInput = activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA";
+
+ // Atajos de cantidad: F9 o '+' (sumar), F3 o '-' (restar) al último producto del carrito
+ if (e.key === "F9" || (!isInput && (e.key === "+" || e.key === "Add"))) {
+ e.preventDefault();
+ if (cart.length > 0) {
+ const lastItem = cart[cart.length - 1];
+ updateCartQty(lastItem.id, 1);
+ }
+ return;
+ }
+
+ if (e.key === "F3" || (!isInput && (e.key === "-" || e.key === "Subtract"))) {
+ e.preventDefault();
+ if (cart.length > 0) {
+ const lastItem = cart[cart.length - 1];
+ updateCartQty(lastItem.id, -1);
+ }
+ return;
+ }
+
+ // 2. DETECCIÓN DEL LECTOR DE CÓDIGO (SOLO EN PESTAÑA VENTA)
+ if (activeTab !== "venta") return;
+
+ // Si el foco está en un input y NO es la búsqueda de productos, ignoramos para no interrumpir la escritura manual del usuario.
+ if (isInput && activeEl !== searchInputRef.current && activeEl.id !== "cash-received") {
+ return;
+ }
+
+ const currentTime = Date.now();
+ if (currentTime - lastKeyTime > 100) {
+ buffer = "";
+ }
+ lastKeyTime = currentTime;
+
+ if (e.key === "Enter") {
+ if (buffer.length >= 3) {
+ e.preventDefault();
+ handleScanBarcode(buffer.trim());
+ buffer = "";
+ }
+ } else if (e.key.length === 1) {
+ buffer += e.key;
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [items, cart, paymentMethod, activeTab, submitting, cashReceived]);
+
+ const handleScanBarcode = (barcode) => {
+ const foundItem = items.find(
+ (item) => item.code.toLowerCase() === barcode.toLowerCase()
+ );
+
+ if (foundItem) {
+ addToCart(foundItem);
+ toast.success(`Escaneado: ${foundItem.name}`, {
+ description: `Se agregó al carrito. Código: ${foundItem.code}`
+ });
+ } else {
+ toast.error(`Código no encontrado: "${barcode}"`, {
+ description: "Asegúrate de registrar primero el insumo en el Inventario."
+ });
+ }
+ };
+
+ const addToCart = (item) => {
+ if (item.quantity <= 0) {
+ toast.error("Producto sin stock disponible.");
+ return;
+ }
+
+ setCart((prevCart) => {
+ const existing = prevCart.find((i) => i.id === item._id);
+ if (existing) {
+ if (existing.quantity >= item.quantity) {
+ toast.warning("Se ha alcanzado el límite de stock de este insumo.");
+ return prevCart;
+ }
+ return prevCart.map((i) =>
+ i.id === item._id ? { ...i, quantity: i.quantity + 1 } : i
+ );
+ }
+ return [...prevCart, { id: item._id, name: item.name, price: item.price || 0, code: item.code, maxQty: item.quantity, quantity: 1 }];
+ });
+ };
+
+ const updateCartQty = (itemId, delta) => {
+ setCart((prevCart) =>
+ prevCart
+ .map((i) => {
+ if (i.id === itemId) {
+ const newQty = i.quantity + delta;
+ if (newQty > i.maxQty) {
+ toast.warning("Límite de stock alcanzado.");
+ return i;
+ }
+ return { ...i, quantity: newQty };
+ }
+ return i;
+ })
+ .filter((i) => i.quantity > 0)
+ );
+ };
+
+ const removeFromCart = (itemId) => {
+ setCart((prevCart) => prevCart.filter((i) => i.id !== itemId));
+ toast.info("Producto removido del carrito.");
+ };
+
+ const total = cart.reduce((acc, item) => acc + item.price * item.quantity, 0);
+
+ // 🖨️ IMPRESIÓN DE TICKET TÉRMICO (58mm/80mm)
+ const handlePrintReceipt = (saleId, soldItems, saleTotal, methodUsed, dateStr = null) => {
+ const printWindow = window.open("", "_blank", "width=300,height=600");
+ if (!printWindow) {
+ toast.error("El bloqueador de ventanas emergentes impidió abrir el ticket.");
+ return;
+ }
+
+ const ticketDate = dateStr ? new Date(dateStr) : new Date();
+
+ const ticketHtml = `
+
+
+
Ticket de Venta #${saleId.substring(0, 8)}
+
+
+
+
+
TICKET DE VENTA
+
${activeVenue?.name ? activeVenue.name.toUpperCase() : "SUCURSAL CENTRAL"}
+
+
Fecha: ${ticketDate.toLocaleString("es-AR")}
+
Ticket: ${saleId.substring(0, 8).toUpperCase()}
+
+
ITEMS:
+ ${soldItems.map(item => `
+
+ ${item.name}
+ x${item.quantity}
+ $${(item.price * item.quantity).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ `).join("")}
+
+
Medio de Pago: ${methodUsed.toUpperCase()}
+
+ TOTAL:
+ $${saleTotal.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+
+
+
+ `;
+
+ printWindow.document.write(ticketHtml);
+ printWindow.document.close();
+ };
+
+ const handleCheckout = async () => {
+ if (cart.length === 0) {
+ toast.error("El carrito está vacío.");
+ return;
+ }
+
+ // Validar cobro en efectivo si escribieron un monto
+ if (paymentMethod === "efectivo" && cashReceived && Number(cashReceived) < total) {
+ toast.warning("El dinero recibido es inferior al total a pagar.");
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+ const saleData = {
+ total,
+ paymentMethod,
+ items: cart.map(i => ({ id: i.id, name: i.name, code: i.code, price: i.price, quantity: i.quantity }))
+ };
+
+ const result = await api.sales.create(saleData);
+
+ const cambio = paymentMethod === "efectivo" && cashReceived
+ ? Number(cashReceived) - total
+ : 0;
+
+ toast.success("¡Venta procesada con éxito!", {
+ description: cambio > 0
+ ? `Vuelto a entregar: $${cambio.toLocaleString("es-AR", { minimumFractionDigits: 2 })}`
+ : `Se registró bajo el ID ${result._id.substring(0, 8)}.`
+ });
+
+ handlePrintReceipt(result._id, saleData.items, total, paymentMethod);
+
+ setCart([]);
+ setCashReceived("0");
+ loadInventoryAndVenues();
+ } catch (error) {
+ toast.error("Error al procesar la venta", {
+ description: error.message || "Intente nuevamente en unos minutos."
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ // Confirmar cobro directo presionando Enter en la calculadora
+ const handleCashKeyDown = (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (cart.length > 0 && !submitting) {
+ handleCheckout();
+ }
+ }
+ };
+
+ const filteredItems = items.filter(
+ (item) =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase().trim()) ||
+ item.code.toLowerCase().includes(searchQuery.toLowerCase().trim())
+ );
+
+ if (loading) {
+ return (
+
+
+
+
Cargando módulo de Caja...
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Caja
+
+
+ Vende insumos del inventario rápidamente y revisa el registro histórico de caja.
+
+
+
+ {/* Tab Toggle buttons */}
+
+ setActiveTab("venta")}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${
+ activeTab === "venta"
+ ? "bg-primary text-primary-foreground shadow-md"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ Nueva Venta
+
+ setActiveTab("historial")}
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all cursor-pointer ${
+ activeTab === "historial"
+ ? "bg-primary text-primary-foreground shadow-md shadow-primary/20"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ Historial de Ventas
+
+
+
+
+ {activeTab === "venta" ? (
+ /* VISTA DE NUEVA VENTA (POS) */
+
+
+
+ Escáner de Código Activo (Lectura Global)
+
+
+
+
+ {/* Columna Izquierda: Catálogo e Insumos */}
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border/80 text-xs w-full pl-10 h-10 rounded-xl focus-visible:ring-2 focus-visible:ring-primary/40"
+ />
+
+
+
+ {filteredItems.length === 0 ? (
+
+
+
No se encontraron insumos disponibles en el inventario.
+
+ ) : (
+
+ {filteredItems.map((item) => {
+ const isOutOfStock = item.quantity <= 0;
+ return (
+
!isOutOfStock && addToCart(item)}
+ className={`flex justify-between items-center p-3.5 rounded-xl border transition-all select-none ${
+ isOutOfStock
+ ? "opacity-45 bg-muted/10 border-border/40 cursor-not-allowed"
+ : "bg-card/70 border-border/60 hover:bg-primary/10 hover:border-primary/40 cursor-pointer active:scale-[0.98] shadow-sm"
+ }`}
+ >
+
+
{item.name}
+
{item.code}
+
+ Stock:
+
+ {item.quantity} un
+
+
+
+
+
+ ${item.price?.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ Agregar
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Columna Derecha: Carrito de Compras activo */}
+
+
+
+
+
+
+ Detalle de Venta
+
+ {cart.length > 0 && (
+
+ {cart.reduce((a, b) => a + b.quantity, 0)} ítems
+
+ )}
+
+
+ Resumen de productos seleccionados para cobro
+
+
+
+
+
+ {/* Items del Carrito */}
+
+ {cart.length === 0 ? (
+
+
+
El carrito está vacío
+
Haz clic en productos del catálogo o escanéalos con el lector.
+
+ ) : (
+ cart.map((item) => (
+
+
+ {item.name}
+ ${item.price.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+
+
updateCartQty(item.id, -1)}
+ className="h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
+ >
+
+
+
{item.quantity}
+
updateCartQty(item.id, 1)}
+ className="h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
+ >
+
+
+
+
+
removeFromCart(item.id)}
+ className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-full cursor-pointer"
+ >
+
+
+
+
+ ))
+ )}
+
+
+ {/* Totalizador y Métodos de Pago */}
+
+
+ TOTAL:
+
+ ${total.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+ {/* Métodos de Pago */}
+
+
Medio de Pago [F4]
+
+
+ setPaymentMethod("efectivo")}
+ className={`flex items-center gap-2 p-2.5 rounded-xl border text-left cursor-pointer transition-all ${
+ paymentMethod === "efectivo"
+ ? "bg-primary/10 border-primary text-primary font-bold shadow-sm"
+ : "border-border/80 bg-background/50 hover:bg-muted/30 text-foreground"
+ }`}
+ >
+
+ Efectivo
+
+
+ setPaymentMethod("transferencia")}
+ className={`flex items-center gap-2 p-2.5 rounded-xl border text-left cursor-pointer transition-all ${
+ paymentMethod === "transferencia"
+ ? "bg-primary/10 border-primary text-primary font-bold shadow-sm"
+ : "border-border/80 bg-background/50 hover:bg-muted/30 text-foreground"
+ }`}
+ >
+
+ Transf.
+
+
+ setPaymentMethod("debito")}
+ className={`flex items-center gap-2 p-2.5 rounded-xl border text-left cursor-pointer transition-all ${
+ paymentMethod === "debito"
+ ? "bg-primary/10 border-primary text-primary font-bold shadow-sm"
+ : "border-border/80 bg-background/50 hover:bg-muted/30 text-foreground"
+ }`}
+ >
+
+ Débito
+
+
+ setPaymentMethod("credito")}
+ className={`flex items-center gap-2 p-2.5 rounded-xl border text-left cursor-pointer transition-all ${
+ paymentMethod === "credito"
+ ? "bg-primary/10 border-primary text-primary font-bold shadow-sm"
+ : "border-border/80 bg-background/50 hover:bg-muted/30 text-foreground"
+ }`}
+ >
+
+ Crédito
+
+
+
+
+
+ {/* Calculadora de Vuelto (Solo Efectivo) */}
+ {paymentMethod === "efectivo" && total > 0 && (
+
+
+ Dinero Recibido
+ {cashReceived && Number(cashReceived) === total && (
+ Pago Justo
+ )}
+
+
+
+ $
+ setCashReceived(e.target.value)}
+ onFocus={(e) => e.target.select()}
+ onKeyDown={handleCashKeyDown}
+ className="pl-7 h-9 text-xs font-mono bg-background focus-visible:ring-1 focus-visible:ring-primary rounded-lg"
+ />
+
+
setCashReceived(total.toString())}
+ className="h-9 text-[10px] font-bold uppercase tracking-wider px-3.5 border border-border/80 cursor-pointer select-none rounded-lg"
+ >
+ Pago Justo
+
+
+ {cashReceived && Number(cashReceived) > total && (
+
+ Vuelto a entregar:
+
+ ${(Number(cashReceived) - total).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+ )}
+ {cashReceived && Number(cashReceived) < total && (
+
+ Faltan: ${(total - Number(cashReceived)).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+ )}
+
+ )}
+
+ {/* Confirmar Cobro */}
+
+
+ {submitting ? "Confirmando..." : "Confirmar y Emitir Ticket [F8]"}
+
+
+ {/* Atajos de teclado informativos */}
+
+
+
+ Atajos de Teclado Rápidos
+
+
+
[F2] Buscar [F2]
+
[F4] Medio Pago [F4]
+
[F9 / +] Sumar Cant. [F9]
+
[F3 / -] Restar Cant. [F3]
+
+ [F8] Confirmar y Cobrar [F8]
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ /* VISTA DE HISTORIAL DE VENTAS */
+
+
+ {/* Barra de Filtros del Historial */}
+
+
+
+
+ Registro de Caja Diaria
+
+
Ventas y egresos de stock realizados en la plataforma.
+
+
+ {/* Dropdown de sucursales (Solo si tiene Multi-Taller Pro y es Admin) */}
+ {isMultiTaller && isAdmin && (
+
+
+ setSelectedVenueFilter(e.target.value)}
+ className="h-9 bg-background/85 border border-border rounded-lg px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-auto"
+ >
+ Todas las Sucursales
+ {venues.map((v) => (
+
+ {v.name}
+
+ ))}
+
+
+ )}
+
+
+ {/* Tabla de Historial */}
+
+
+ {salesLoading ? (
+
+
+ Cargando transacciones de caja...
+
+ ) : sales.length === 0 ? (
+
+
+
No se encontraron transacciones en esta sucursal.
+
+ ) : (
+
+
+
+
+ Código / Ticket
+ Fecha y Hora
+ Sucursal
+ Cajero
+ Artículos Vendidos
+ Método de Pago
+ Monto Cobrado
+ Acciones
+
+
+
+ {sales.map((sale) => (
+
+
+ #{sale._id.substring(0, 8).toUpperCase()}
+
+
+ {new Date(sale.created_at).toLocaleString("es-AR")}
+
+
+
+
+ {sale.venueName}
+
+
+ {sale.cajero}
+
+
+ {sale.items?.map((i) => `${i.name} (x${i.quantity})`).join(", ")}
+
+
+
+
+ {sale.payment_method}
+
+
+
+ ${Number(sale.total).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+ handlePrintReceipt(sale._id, sale.items, Number(sale.total), sale.payment_method, sale.created_at)}
+ className="h-8 w-8 text-primary hover:text-primary hover:bg-primary/10 rounded-full cursor-pointer"
+ title="Re-imprimir Ticket de Venta"
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/views/admin/ProfilePage.jsx b/frontend/src/views/admin/ProfilePage.jsx
index 28bdf1a..a245800 100644
--- a/frontend/src/views/admin/ProfilePage.jsx
+++ b/frontend/src/views/admin/ProfilePage.jsx
@@ -1,36 +1,152 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
-import {
- Store,
- Save,
- CheckCircle
+import {
+ Store,
+ Save,
+ CheckCircle,
+ Plus,
+ Edit,
+ X,
+ RefreshCw,
} from "lucide-react";
+import { api } from "@/services/api";
export default function ProfilePage() {
- const [venueName, setVenueName] = useState("RepairIT Tucumán Centro");
- const [address, setAddress] = useState("San Martín 650, San Miguel de Tucumán");
- const [phone, setPhone] = useState("+54 381 4223344");
- const [email, setEmail] = useState("tucuman@repairit.cloud");
+ const user = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ const [venueName, setVenueName] = useState("");
+ const [address, setAddress] = useState("");
+ const [phone, setPhone] = useState("");
+ const [email, setEmail] = useState("");
+ const [venues, setVenues] = useState([]);
- const handleSave = (e) => {
+ const [showPlansModal, setShowPlansModal] = useState(false);
+
+ // Estados de Modal de Sucursal
+ const [showModal, setShowModal] = useState(false);
+ const [modalMode, setModalMode] = useState("create"); // "create" o "edit"
+ const [editingVenueId, setEditingVenueId] = useState(null);
+ const [modalName, setModalName] = useState("");
+ const [modalEmail, setModalEmail] = useState("");
+ const [modalPhone, setModalPhone] = useState("");
+ const [modalAddress, setModalAddress] = useState("");
+
+ const fetchProfile = async () => {
+ try {
+ const data = await api.auth.getProfile();
+ setVenueName(data.name || "");
+ setAddress(data.address || "");
+ setPhone(data.phone || "");
+ setEmail(data.email || "");
+
+ // Cargar todas las sucursales de la organización
+ const venuesData = await api.auth.getVenues();
+ setVenues(venuesData);
+ } catch (error) {
+ toast.error("Error al cargar perfil del taller", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
+ };
+
+ useEffect(() => {
+ fetchProfile();
+ }, []);
+
+ const handleSave = async (e) => {
e.preventDefault();
- toast.success("¡Configuración del Taller Guardada!", {
- description: `Los datos de la sucursal "${venueName}" han sido actualizados con éxito.`,
- });
+ try {
+ await api.auth.updateProfile({ name: venueName, address, phone, email });
+ toast.success("¡Configuración del Taller Guardada!", {
+ description: `Los datos de la sucursal "${venueName}" han sido actualizados con éxito.`,
+ });
+ fetchProfile();
+ } catch (error) {
+ toast.error("Error al guardar configuración", {
+ description: error.message || "No se pudieron guardar los cambios."
+ });
+ }
+ };
+
+ const handleOpenCreateModal = () => {
+ setModalMode("create");
+ setEditingVenueId(null);
+ setModalName("");
+ setModalEmail("");
+ setModalPhone("");
+ setModalAddress("");
+ setShowModal(true);
+ };
+
+ const handleOpenEditModal = (venue) => {
+ setModalMode("edit");
+ setEditingVenueId(venue._id);
+ setModalName(venue.name || "");
+ setModalEmail(venue.email || "");
+ setModalPhone(venue.phone || "");
+ setModalAddress(venue.address || "");
+ setShowModal(true);
+ };
+
+ const handleModalSubmit = async (e) => {
+ e.preventDefault();
+ try {
+ if (modalMode === "create") {
+ await api.auth.createVenue({
+ name: modalName,
+ email: modalEmail,
+ phone: modalPhone,
+ address: modalAddress,
+ });
+ toast.success("Nueva sucursal creada con éxito");
+ } else {
+ await api.auth.updateVenue(editingVenueId, {
+ name: modalName,
+ email: modalEmail,
+ phone: modalPhone,
+ address: modalAddress,
+ });
+ toast.success("Datos de sucursal actualizados con éxito");
+ }
+ setShowModal(false);
+ fetchProfile();
+ } catch (err) {
+ toast.error("Error al procesar la sucursal", { description: err.message });
+ }
+ };
+
+ const handleSwitchActiveVenue = async (venueId) => {
+ try {
+ const updatedUser = await api.auth.switchVenue(venueId);
+
+ // Actualizar sesión guardada
+ const localUser = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ localStorage.setItem("repairit_user", JSON.stringify({ ...localUser, venueId: updatedUser.venueId }));
+
+ toast.success("Sucursal activa cambiada", {
+ description: "El panel de control se actualizará con el contexto de la nueva sucursal."
+ });
+
+ // Forzar reload suave para recargar sidebar, órdenes e inventario
+ setTimeout(() => {
+ window.location.reload();
+ }, 600);
+ } catch (err) {
+ toast.error("Error al cambiar de sucursal", { description: err.message });
+ }
};
return (
{/* Cabecera interna */}
-
-
- Perfil de Taller (Venue)
+
+
+ Perfil de Taller
Gestión del perfil del local y estado de la suscripción de la sucursal.
@@ -38,15 +154,15 @@ export default function ProfilePage() {
-
+
{/* Columna Izquierda (2/3): Datos principales del taller */}
-
- {/* Card 1: Datos de la Sucursal */}
+
+ {/* Card 1: Datos de la Sucursal Activa */}
- Datos del Establecimiento
+ Datos del Establecimiento Activo
Información de contacto que se mostrará en los recibos y el seguimiento del cliente.
@@ -57,24 +173,24 @@ export default function ProfilePage() {
Nombre del Taller
- setVenueName(e.target.value)}
+ type="text"
+ required
+ value={venueName}
+ onChange={(e) => setVenueName(e.target.value)}
placeholder="RepairIT Tucumán Centro"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
Email de Contacto
-
setEmail(e.target.value)}
+ type="email"
+ required
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
placeholder="tucuman@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
@@ -84,24 +200,24 @@ export default function ProfilePage() {
Teléfono Comercial
- setPhone(e.target.value)}
+ type="text"
+ required
+ value={phone}
+ onChange={(e) => setPhone(e.target.value)}
placeholder="+54 381 4223344"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
Dirección Física
- setAddress(e.target.value)}
+ type="text"
+ required
+ value={address}
+ onChange={(e) => setAddress(e.target.value)}
placeholder="San Martín 650, San Miguel de Tucumán"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
@@ -109,8 +225,8 @@ export default function ProfilePage() {
-
@@ -121,21 +237,126 @@ export default function ProfilePage() {
+ {/* Card de Sucursales de la Organización */}
+
+
+
+
+ Sucursales de la Organización
+
+
+ Gestioná o alterná las sedes de servicio de tu negocio.
+
+
+ {user.subscriptionPlan === "Multi-Taller Pro" && (
+
+
+ Agregar Sucursal
+
+ )}
+
+
+ {user.subscriptionPlan !== "Multi-Taller Pro" ? (
+
+
+
+
+
+
Gestión de Sucursales Limitada
+
+ Mejore su plan al nivel Multi-Taller Pro para poder gestionar y cambiar entre múltiples sedes de su negocio.
+
+
setShowPlansModal(true)}
+ size="sm"
+ className="bg-primary/20 hover:bg-primary/30 text-primary border border-primary/25 font-bold text-[10px] uppercase py-1.5 px-4 rounded-md cursor-pointer transition-all shrink-0 mt-1"
+ >
+ Ver Planes Disponibles
+
+
+
+ ) : venues.length > 0 ? (
+
+ {venues.map((v) => {
+ const isActive = v.name === venueName;
+ return (
+
+
+
+ {v.name}
+ {isActive && (
+
+ Activa
+
+ )}
+
+
{v.address}
+
Tel: {v.phone}
+
{v.email}
+
+
+
+ handleOpenEditModal(v)}
+ variant="ghost"
+ size="sm"
+ className="h-7 text-xs font-medium text-muted-foreground hover:text-foreground flex items-center gap-1.5 px-2 hover:bg-muted/50 cursor-pointer"
+ >
+
+ Editar
+
+
+ {!isActive && (
+ handleSwitchActiveVenue(v._id)}
+ variant="outline"
+ size="sm"
+ className="h-7 text-[10px] font-bold uppercase border-border/60 hover:bg-primary hover:text-primary-foreground hover:border-primary flex items-center gap-1 px-2.5 cursor-pointer"
+ >
+
+ Activar
+
+ )}
+
+
+ );
+ })}
+
+ ) : (
+
+ No hay sucursales adicionales registradas.
+
+ )}
+
+
+
{/* Columna Derecha (1/3): Suscripción */}
-
+
{/* Card 2: Suscripción */}
Suscripción
-
+
Plan Licenciado
- Multi-Taller Pro
+
+ {user.subscriptionPlan || "Sin Plan"}
+
Vencimiento
@@ -143,17 +364,246 @@ export default function ProfilePage() {
Estado del Pago
-
-
- Al Día
+
+
+ {user.subscriptionStatus === "activo" ? "Al Día" : "Inactivo"}
+
+
+ setShowPlansModal(true)}
+ className="w-full text-[10px] font-bold uppercase border-border/80 hover:bg-card/50 transition-all cursor-pointer"
+ >
+ Ver / Cambiar Plan
+
+
+
+ {/* MODAL DE SUCURSAL (Creación / Edición) */}
+ {showModal && (
+
+
+
+
+ {modalMode === "create" ? "Registrar Nueva Sucursal" : "Editar Detalles de Sucursal"}
+
+ setShowModal(false)}
+ className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+
+
+
+
+
+
+ Nombre del Taller / Sucursal
+ setModalName(e.target.value)}
+ placeholder="Ej. RepairIT - Yerba Buena"
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+ Dirección Física
+ setModalAddress(e.target.value)}
+ placeholder="Ej. Av. Aconquija 1300, Yerba Buena"
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+
+
+ setShowModal(false)}
+ className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+ Cancelar
+
+
+
+ {modalMode === "create" ? "Crear Sucursal" : "Guardar Cambios"}
+
+
+
+
+
+ )}
+
+ {/* MODAL DE PLANES (Comparador de Precios) */}
+ {showPlansModal && (
+
+
+
setShowPlansModal(false)}
+ variant="ghost"
+ size="icon"
+ className="absolute top-4 right-4 text-muted-foreground hover:text-foreground cursor-pointer rounded-full h-8 w-8"
+ >
+
+
+
+
+
+ Planes de Suscripción RepairIT
+
+
+ Elija el plan que mejor se adapte al tamaño y necesidades de su taller de reparación técnica.
+
+
+
+
+
+ {/* Plan Básico */}
+
+
+
+ Taller Básico
+ {(user.subscriptionPlan === "Sin Plan" || user.subscriptionPlan === "Taller Básico") && (
+ Activo
+ )}
+
+
+ Gratis
+ / para siempre
+
+
+ ✓ 1 Sucursal física
+ ✓ Hasta 100 órdenes / mes
+ ✓ Inventario y stock básico
+ ✓ Consulta de tracking pública
+
+
+
+
+ {/* Plan Premium */}
+
+
Popular
+
+
+ Taller Premium
+ {user.subscriptionPlan === "Taller Premium" && (
+ Activo
+ )}
+
+
+ $19
+ / mes
+
+
+ ✓ 1 Sucursal física
+ ✓ Órdenes y equipos ilimitados
+ ✓ Inventario con alertas críticas
+ ✓ Dashboard métricas avanzado
+ ✓ Soporte prioritario 24/7
+
+
+
+
+ {/* Plan Multi-Taller Pro */}
+
+
+
+ Multi-Taller Pro
+ {user.subscriptionPlan === "Multi-Taller Pro" && (
+ Activo
+ )}
+
+
+ $39
+ / mes
+
+
+ ✓ Hasta 5 Sucursales
+ ✓ Selector rápido en barra superior
+ ✓ 4 cuentas Técnicos + 1 Admin
+ ✓ Órdenes e Inventario ilimitados
+ ✓ Reportes centralizados multisede
+
+
+
+
+
+
+
+ {
+ setShowPlansModal(false);
+ toast.info("Contacto con Soporte", {
+ description: "Para cambiar su suscripción de taller, por favor comuníquese a soporte@repairit.cloud o con su asesor comercial."
+ });
+ }}
+ className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-6 rounded-lg cursor-pointer transition-all shadow-md shadow-primary/15"
+ >
+ Solicitar Cambio de Plan
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/views/admin/SuperAdminUsersPage.jsx b/frontend/src/views/admin/SuperAdminUsersPage.jsx
new file mode 100644
index 0000000..e49e9b0
--- /dev/null
+++ b/frontend/src/views/admin/SuperAdminUsersPage.jsx
@@ -0,0 +1,424 @@
+import { useState, useEffect } from "react";
+import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Users,
+ Search,
+ ChevronDown,
+ Shield,
+ UserCheck,
+ UserCog,
+ Edit,
+ Trash2,
+ X,
+ Save,
+ Eye,
+ EyeOff
+} from "lucide-react";
+import { api } from "@/services/api";
+import { toast } from "sonner";
+
+export default function SuperAdminUsersPage() {
+ const [loading, setLoading] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
+
+ // States for Manage User Modal
+ const [showManageModal, setShowManageModal] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [modalName, setModalName] = useState("");
+ const [modalEmail, setModalEmail] = useState("");
+ const [modalRole, setModalRole] = useState("tecnico");
+ const [modalPassword, setModalPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const loadUsers = async () => {
+ try {
+ setLoading(true);
+ const data = await api.super.getUsers();
+ setUsers(data);
+ } catch (err) {
+ toast.error("Error al cargar usuarios de la plataforma", {
+ description: err.message || "Por favor, intente de nuevo."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadUsers();
+ }, []);
+
+ const handleRoleChange = async (userId, newRole) => {
+ try {
+ await api.super.updateUserRole(userId, newRole);
+ toast.success("Rol de usuario actualizado con éxito");
+ loadUsers();
+ } catch (err) {
+ toast.error("Error al actualizar rol", {
+ description: err.message || "No se pudo cambiar el rol."
+ });
+ }
+ };
+
+ const handleOpenManageModal = (user) => {
+ setSelectedUser(user);
+ setModalName(user.name || "");
+ setModalEmail(user.email || "");
+ setModalRole(user.role || "tecnico");
+ setModalPassword("");
+ setShowPassword(false);
+ setShowManageModal(true);
+ };
+
+ const handleUpdateUser = async (e) => {
+ e.preventDefault();
+ if (!modalName || !modalEmail || !modalRole) {
+ toast.error("Complete todos los campos requeridos.");
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+ await api.super.manageUser(
+ selectedUser._id,
+ modalName,
+ modalEmail,
+ modalRole,
+ modalPassword || null
+ );
+ toast.success("¡Usuario actualizado correctamente!");
+ setShowManageModal(false);
+ loadUsers();
+ } catch (err) {
+ toast.error("Error al actualizar usuario", {
+ description: err.message || "No se pudo guardar los cambios."
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDeleteUser = async (userId, userName) => {
+ const currentUser = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ if (userId === currentUser._id) {
+ toast.error("No puede eliminar su propio usuario activo.");
+ return;
+ }
+
+ if (!window.confirm(`¿Está seguro de que desea eliminar permanentemente al usuario "${userName}"? Esta acción no se puede deshacer.`)) {
+ return;
+ }
+
+ try {
+ await api.super.deleteUser(userId);
+ toast.success("Usuario eliminado", {
+ description: `La cuenta de "${userName}" fue eliminada de la plataforma.`
+ });
+ setShowManageModal(false);
+ loadUsers();
+ } catch (err) {
+ toast.error("Error al eliminar usuario", {
+ description: err.message || "No se pudo procesar la solicitud."
+ });
+ }
+ };
+
+ const filteredUsers = users.filter(u =>
+ u.name.toLowerCase().includes(searchQuery.toLowerCase().trim()) ||
+ u.email.toLowerCase().includes(searchQuery.toLowerCase().trim())
+ );
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Gestión de Usuarios
+
+
+ Administración centralizada de cuentas de técnicos y administradores de todos los talleres.
+
+
+
+
+
+
+
+
+ Directorio General de Personal
+
+ Total: {users.length}
+
+
+ {/* Filtro rápido de búsqueda */}
+
+
+
+ setSearchQuery(e.target.value)}
+ className="bg-background/80 border-border text-xs w-full pl-9"
+ />
+
+
+
+ {filteredUsers.length > 0 ? (
+
+
+
+
+ Usuario
+ Organización / Taller
+ Sucursal / Venue
+ Rol del Sistema
+ Fecha Alta
+ Acciones
+
+
+
+ {filteredUsers.map((item) => {
+ let roleBadge = "secondary";
+ let roleLabel = "Técnico";
+ if (item.role === "superadmin") {
+ roleBadge = "success";
+ roleLabel = "Super Admin";
+ } else if (item.role === "admin") {
+ roleBadge = "warning";
+ roleLabel = "Administrador";
+ }
+
+ const isCurrentSuper = item.role === "superadmin";
+
+ return (
+
+
+ {item.name}
+ {item.email}
+
+ {item.organizationId?.name || "Plataforma Central"}
+ {item.venueId?.name || "Taller Global"}
+
+ {isCurrentSuper ? (
+
+ {roleLabel}
+
+ ) : (
+
+
+
+
+ {roleLabel}
+
+
+
+
+
+ handleRoleChange(item._id, "tecnico")} className="text-xs transition-colors cursor-pointer flex items-center gap-1.5">
+
+ Técnico
+
+ handleRoleChange(item._id, "admin")} className="text-xs transition-colors cursor-pointer flex items-center gap-1.5">
+
+ Administrador
+
+
+
+ )}
+
+ {new Date(item.createdAt).toLocaleDateString("es-AR")}
+
+
+ handleOpenManageModal(item)}
+ className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/40 cursor-pointer rounded-full"
+ title="Editar / Configurar usuario"
+ >
+
+
+ {!isCurrentSuper && (
+ handleDeleteUser(item._id, item.name)}
+ className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 cursor-pointer rounded-full"
+ title="Eliminar usuario"
+ >
+
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+ ) : (
+
+ No se encontraron usuarios que coincidan con la búsqueda.
+
+ )}
+
+
+
+ {/* MODAL DE EDICIÓN Y GESTIÓN (Para SuperAdmin) */}
+ {showManageModal && selectedUser && (
+
+
+
setShowManageModal(false)}
+ variant="ghost"
+ size="icon"
+ className="absolute top-4 right-4 text-muted-foreground hover:text-foreground cursor-pointer rounded-full h-8 w-8"
+ >
+
+
+
+
+
+ Gestionar Cuenta de Usuario
+
+
+ Modifique los accesos, rol y credenciales de la cuenta.
+
+
+
+
+
+
+ Nombre Completo
+ setModalName(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+ Email de Acceso
+ setModalEmail(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+ Rol del Sistema
+ setModalRole(e.target.value)}
+ disabled={selectedUser.role === "superadmin"}
+ className="w-full h-9 rounded-md border border-border bg-background/85 text-xs px-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-foreground"
+ >
+ Técnico (Inquilino)
+ Administrador (Taller)
+ Super Administrador (Plataforma)
+
+
+
+
+
+ Nueva Contraseña
+ dejar vacío para no cambiar
+
+
+ setModalPassword(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs pr-10"
+ />
+ setShowPassword(!showPassword)}
+ className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+ {showPassword ? : }
+
+
+
+
+
+ {selectedUser.role !== "superadmin" ? (
+
handleDeleteUser(selectedUser._id, selectedUser.name)}
+ className="text-xs text-destructive hover:text-destructive hover:bg-destructive/10 cursor-pointer flex items-center gap-1"
+ >
+
+ Eliminar
+
+ ) : (
+
+ )}
+
+
+ setShowManageModal(false)}
+ className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+ Cancelar
+
+
+
+ {submitting ? "Guardando..." : "Guardar Cambios"}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/views/admin/VenueAccountsPage.jsx b/frontend/src/views/admin/VenueAccountsPage.jsx
new file mode 100644
index 0000000..3288e6e
--- /dev/null
+++ b/frontend/src/views/admin/VenueAccountsPage.jsx
@@ -0,0 +1,371 @@
+import { useState, useEffect } from "react";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import {
+ KeyRound,
+ Plus,
+ Trash2,
+ Shield,
+ Store,
+ X,
+ Save,
+ Eye,
+ EyeOff,
+ Info
+} from "lucide-react";
+import { api } from "@/services/api";
+import { supabase } from "@/services/supabaseClient";
+
+export default function VenueAccountsPage() {
+ const user = JSON.parse(localStorage.getItem("repairit_user") || "{}");
+ const isMultiTaller = user.subscriptionPlan === "Multi-Taller Pro";
+
+ const [accounts, setAccounts] = useState([]);
+ const [venues, setVenues] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ // Form states
+ const [showModal, setShowModal] = useState(false);
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [venueId, setVenueId] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ const fetchData = async () => {
+ if (!isMultiTaller) {
+ setLoading(false);
+ return;
+ }
+ try {
+ setLoading(true);
+ const accountsData = await api.venueAccounts.getAll();
+ setAccounts(accountsData);
+
+ const venuesData = await api.auth.getVenues();
+ setVenues(venuesData);
+ if (venuesData.length > 0) {
+ setVenueId(venuesData[0]._id);
+ }
+ } catch (error) {
+ toast.error("Error al cargar datos", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchData();
+ }, []);
+
+ const handleCreate = async (e) => {
+ e.preventDefault();
+ if (!name || !email || !password || !venueId) {
+ toast.error("Complete todos los campos requeridos.");
+ return;
+ }
+
+ try {
+ setSubmitting(true);
+ await api.venueAccounts.create({
+ name,
+ email,
+ password,
+ venueId
+ });
+ toast.success("¡Cuenta de Acceso Creada!", {
+ description: `Se registró la cuenta "${name}" correctamente.`
+ });
+ setShowModal(false);
+ setName("");
+ setEmail("");
+ setPassword("");
+ fetchData();
+ } catch (error) {
+ toast.error("Error al crear cuenta", {
+ description: error.message || "No se pudo registrar la cuenta en Supabase."
+ });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleDelete = async (accountId, accountName) => {
+ if (!window.confirm(`¿Está seguro de que desea eliminar la cuenta "${accountName}"? El dispositivo perderá acceso inmediato al sistema.`)) {
+ return;
+ }
+
+ try {
+ await api.venueAccounts.delete(accountId);
+ toast.success("Cuenta eliminada", {
+ description: `La cuenta de acceso "${accountName}" fue removida correctamente.`
+ });
+ fetchData();
+ } catch (error) {
+ toast.error("Error al eliminar cuenta", {
+ description: error.message || "No se pudo procesar la solicitud."
+ });
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Cargando cuentas de acceso...
+
+
+ );
+ }
+
+ // Vista cuando no tienen el plan correcto
+ if (!isMultiTaller) {
+ return (
+
+
+
+
+ Cuentas de Acceso de Sucursal
+
+
+ Administración de cuentas para las PCs de cada una de sus sedes.
+
+
+
+
+
+
+
+
+
Función Exclusiva de Multi-Taller Pro
+
+ La creación de usuarios de acceso independientes para las computadoras de cada sucursal requiere una licencia activa del plan Multi-Taller Pro .
+
+
+ Mejore su plan en la pestaña de perfil para habilitar esta funcionalidad.
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Cuentas de Acceso de Sucursal
+
+
+ Configure credenciales de PC independientes para limitar el acceso del personal de cada sede.
+
+
+
setShowModal(true)}
+ className="bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-2.5 px-4 rounded-lg flex items-center gap-1.5 cursor-pointer shadow-lg shadow-primary/15 transition-all select-none self-start sm:self-auto"
+ >
+
+ Crear Cuenta de Acceso
+
+
+
+ {/* Info Warning */}
+
+
+
+
¿Cómo funcionan estas cuentas?
+
+ Estas credenciales están diseñadas para ser ingresadas en las computadoras fijas del taller de cada sede.
+ El personal técnico que inicie sesión con estas cuentas **solo podrá ver, editar y registrar información correspondiente a su sucursal asociada**. No tendrán acceso a la facturación, configuración global ni a otras sedes.
+
+
+
+
+ {/* Table Card */}
+
+
+ {accounts.length === 0 ? (
+
+
+
+
+
No hay cuentas de acceso adicionales registradas.
+
Presione el botón superior para crear la primera cuenta.
+
+ ) : (
+
+
+
+
+ Nombre Identificador
+ Email de Acceso
+ Sucursal Asociada
+ Rol Asignado
+ Fecha Alta
+ Acciones
+
+
+
+ {accounts.map((acc) => (
+
+ {acc.name}
+ {acc.email}
+
+
+
+ {acc.venueId?.name || "Desconocida"}
+
+
+
+ Técnico
+
+
+ {new Date(acc.created_at).toLocaleDateString("es-AR")}
+
+
+ handleDelete(acc._id, acc.name)}
+ className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 cursor-pointer rounded-full"
+ title="Eliminar cuenta"
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Modal Creación */}
+ {showModal && (
+
+
+
setShowModal(false)}
+ variant="ghost"
+ size="icon"
+ className="absolute top-4 right-4 text-muted-foreground hover:text-foreground cursor-pointer rounded-full h-8 w-8"
+ >
+
+
+
+
+
+ Registrar Cuenta de Acceso
+
+
+ Crea credenciales específicas para una de las sucursales de tu organización.
+
+
+
+
+
+
+ Nombre Identificador
+ setName(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+ Email de Acceso
+ setEmail(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
+ />
+
+
+
+
Contraseña
+
+ setPassword(e.target.value)}
+ className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs pr-10"
+ />
+ setShowPassword(!showPassword)}
+ className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+ {showPassword ? : }
+
+
+
+
+
+ Sucursal Asociada
+ setVenueId(e.target.value)}
+ className="w-full h-9 rounded-md border border-border bg-background/85 text-xs px-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary text-foreground"
+ >
+ {venues.map((v) => (
+
+ {v.name}
+
+ ))}
+
+
+
+
+ setShowModal(false)}
+ className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+ Cancelar
+
+
+
+ {submitting ? "Creando..." : "Crear Acceso"}
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/views/public/LandingPage.jsx b/frontend/src/views/public/LandingPage.jsx
index f38dad4..4ba05df 100644
--- a/frontend/src/views/public/LandingPage.jsx
+++ b/frontend/src/views/public/LandingPage.jsx
@@ -102,7 +102,7 @@ export default function LandingPage() {
{/* Ejemplo rápido para testing */}
- ¿Querés probar? Ingresá setTrackingId("demo-id")} className="text-primary hover:underline cursor-pointer font-bold">demo-id para ver una reparación simulada.
+ ¿Querés probar? Ingresá { setTrackingId("demo-id"); navigate("/seguimiento/demo-id"); }} className="text-primary hover:underline cursor-pointer font-bold">demo-id para ver una reparación simulada.
diff --git a/frontend/src/views/public/TrackingPage.jsx b/frontend/src/views/public/TrackingPage.jsx
index 3c0f6a2..643c671 100644
--- a/frontend/src/views/public/TrackingPage.jsx
+++ b/frontend/src/views/public/TrackingPage.jsx
@@ -1,9 +1,8 @@
import { useParams, Link } from "react-router-dom";
import { useState, useEffect } from "react";
-import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
+import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
@@ -19,15 +18,27 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
+import { api } from "@/services/api";
+import {
+ ArrowLeft,
+ CheckCircle2,
+ Clock,
+ FileText,
+ Stethoscope,
+ Laptop,
+ AlertCircle,
+ ShieldCheck,
+ Download
+} from "lucide-react";
// Lista de estados en orden secuencial
const STEPS = [
- { key: "ingresado", label: "Ingresado", desc: "Equipo recibido" },
- { key: "diagnostico", label: "En Diagnóstico", desc: "Evaluando fallas" },
- { key: "presupuestado", label: "Presupuestado", desc: "Costo listo" },
- { key: "reparacion", label: "En Reparación", desc: "Técnico trabajando" },
- { key: "listo", label: "Listo para Entregar", desc: "Pruebas superadas" },
- { key: "entregado", label: "Entregado", desc: "Retirado por cliente" }
+ { key: "ingresado", label: "Ingresado" },
+ { key: "diagnostico", label: "En Diagnóstico" },
+ { key: "presupuestado", label: "Presupuestado" },
+ { key: "reparacion", label: "En Reparación" },
+ { key: "listo", label: "Listo para Entregar" },
+ { key: "entregado", label: "Entregado" }
];
export default function TrackingPage() {
@@ -35,396 +46,269 @@ export default function TrackingPage() {
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState(null);
- useEffect(() => {
- // Simular llamada a la API
- const timer = setTimeout(() => {
- if (id === "demo-id") {
- setOrder({
- id: "demo-id",
- nanoId: "RT-2026-ASUS",
- client: "Edgar Javier Ortiz",
- dni: "44375912",
- phone: "+54 381 1234567",
- email: "edgar.ortiz@gmail.com",
- device: "Notebook ASUS ROG Strix G15",
- serial: "SN-98218-ASUS",
- report: "Calentamiento excesivo (alcanza 95°C) y reinicios aleatorios durante sesiones de renderizado o juegos.",
- currentStatus: "listo", // Empezar en presupuestado para poder probar el flujo de aprobación
- dateIngress: "05/06/2026",
- budget: {
- items: [
- { desc: "Mantenimiento térmico preventivo (limpieza + cambio de pasta térmica Arctic MX-6)", price: 15000 },
- { desc: "Reemplazo de Cooler FAN original (lado izquierdo - fallando rodamientos)", price: 25000 }
- ],
- approved: false, // Iniciar sin aprobar para el testing interactivo
- dateApproved: null
- },
- history: [
- { date: "05/06/2026 10:30", text: "Equipo ingresado al sistema por recepción física." },
- { date: "06/06/2026 14:15", text: "Diagnóstico finalizado. Se detectó cooler izquierdo trabado y pasta térmica original cristalizada." }
- ]
- });
- } else {
- // Orden genérica para cualquier otro ID
- setOrder({
- id: id,
- nanoId: id.toUpperCase(),
- client: "Cliente Registrado",
- dni: "29.987.654",
- phone: "+54 381 4567890",
- email: "cliente.registrado@email.com",
- device: "Dispositivo Tecnológico Genérico",
- serial: "SN-PENDIENTE",
- report: "Diagnóstico inicial pendiente de revisión.",
- currentStatus: "diagnostico",
- dateIngress: "08/06/2026",
- budget: null,
- history: [
- { date: "08/06/2026 08:00", text: "Equipo ingresado. En espera de evaluación del departamento técnico." }
- ]
- });
- }
+ const loadOrder = async () => {
+ try {
+ setLoading(true);
+ const data = await api.orders.track(id);
+ setOrder(data);
+ } catch (error) {
+ console.error(error);
+ setOrder(null);
+ } finally {
setLoading(false);
- }, 1200); // Demora para visualizar el skeleton de carga
+ }
+ };
- return () => clearTimeout(timer);
+ useEffect(() => {
+ if (id) {
+ loadOrder();
+ }
}, [id]);
- // Manejo de la aprobación del presupuesto en el cliente
- const handleApproveBudget = () => {
+ // Aprobación online del presupuesto
+ const handleApproveBudget = async () => {
if (!order) return;
- // Simular aprobación
- setOrder(prev => ({
- ...prev,
- currentStatus: "reparacion", // Pasa a reparación al aprobar
- budget: {
- ...prev.budget,
- approved: true,
- dateApproved: new Date().toLocaleDateString("es-AR")
- },
- history: [
- ...prev.history,
- {
- date: new Date().toLocaleString("es-AR"),
- text: "Presupuesto aprobado de forma online por el cliente. Inicio de trabajos técnicos autorizado."
- }
- ]
- }));
-
- toast.success("¡Presupuesto Aprobado!", {
- description: "El departamento técnico ha sido notificado para iniciar la reparación."
- });
+ try {
+ await api.orders.approveBudget(order.trackingCode);
+ toast.success("¡Presupuesto Aprobado!", {
+ description: "El laboratorio técnico iniciará las tareas de reparación."
+ });
+ loadOrder();
+ } catch (error) {
+ toast.error("Error al aprobar presupuesto", {
+ description: error.message || "Por favor, intente nuevamente."
+ });
+ }
};
// Obtener el índice del estado actual
- const currentStepIndex = order ? STEPS.findIndex(s => s.key === order.currentStatus) : 0;
- // Porcentaje del progreso (basado en el índice)
+ const foundIndex = order ? STEPS.findIndex(s => s.key === order.status || (s.key === "reparacion" && order.status === "en_reparacion")) : 0;
+ const currentStepIndex = foundIndex >= 0 ? foundIndex : 0;
const progressPercent = order ? (currentStepIndex / (STEPS.length - 1)) * 100 : 0;
// Renderizador de Skeleton de carga
if (loading) {
return (
-
-
-
- {/* Skeleton de la Línea de Tiempo */}
+
+
-
-
);
}
- return (
-
-
- {/* Cabecera del Seguimiento */}
-
-
-
-
- ← Volver
-
-
- Orden de Servicio
-
-
- Seguimiento de Equipo: {order.nanoId}
-
+ if (!order) {
+ return (
+
+
+
+
Orden no encontrada
+
+ No se encontró información para el código "{id}" .
+
+
+
+ Volver al Inicio
+
+
+ );
+ }
+
+ const clientName = order.clientId?.name || "Cliente";
+
+ return (
+
+
+ {/* Botón de retorno simple */}
+
- {/* LINEA DE TIEMPO DEL ESTADO CON PROGRESS BAR DE SHADCN */}
-
-
-
-
- {/* Componente Progress nativo de shadcn - Centrado al píxel */}
-
-
+ {/* Tarjeta Principal de Resumen del Dispositivo */}
+
+
+
+
+
+
+ Orden #{order.trackingCode}
+
+ {order.deviceType} {order.deviceModel}
+
+
+
+
+ {STEPS[currentStepIndex]?.label || order.status}
+
+
+
+ {/* Línea de Progreso Limpia */}
+
+
+ Progreso del servicio
+ {Math.round(progressPercent)}%
+
+
+
+
+ {/* Pasos limpios horizontal */}
+
{STEPS.map((step, idx) => {
- const isCompleted = idx <= currentStepIndex;
- const isActive = idx === currentStepIndex;
-
+ const isDone = idx <= currentStepIndex;
return (
-
- {/* Línea vertical de conexión en móvil */}
- {idx < STEPS.length - 1 && (
-
- )}
-
- {/* Círculo indicador */}
-
- {isActive ? (
-
- ) : isCompleted ? (
-
-
-
- ) : null}
-
-
- {/* Etiquetas */}
-
-
- {step.label}
-
-
- {step.desc}
-
-
-
+
+ {step.label}
+
);
})}
-
- {/* DETALLES Y PRESUPUESTO */}
-
+ {/* Grid de 2 Columnas: Detalle Técnico + Presupuesto */}
+
- {/* Columna Izquierda (2/3): Datos del Cliente, Equipo e Historial */}
-
+ {/* Columna Principal (2/3): Diagnóstico e Historial */}
+
+
+ {/* Falla Reportada y Diagnóstico en 1 sola tarjeta limpia */}
+
+
+
+
Falla Reportada
+
+ {order.issue}
+
+
- {/* Datos del Cliente con Card de shadcn */}
-
-
-
- Datos del Cliente
-
-
-
-
-
- Cliente
- {order.client}
-
-
- DNI / Identificación
- {order.dni}
-
-
- Teléfono
- {order.phone}
-
-
- Email
- {order.email}
-
+ {order.diagnosis && (
+
+
+ Diagnóstico del Técnico
+
+
+ {order.diagnosis}
+
-
+ )}
+
+
+ Cliente: {clientName}
+ •
+ Sede: {order.venueId?.name || "Taller Central"}
+
- {/* Ficha Técnica con Card de shadcn */}
-
-
-
- Detalles del Dispositivo
-
-
-
-
-
-
Dispositivo
-
{order.device}
+ {/* Historial Compacto */}
+
+
+ Historial de Trazabilidad
+
+
+
+ {order.history.map((log, idx) => (
+
+
{log.date}
+
{log.text}
-
- Número de Serie
- {order.serial}
-
-
-
Falla Reportada
-
- {order.report}
-
-
-
-
-
-
- {/* Historial de Trazabilidad con Card de shadcn */}
-
-
-
- Historial del Servicio
-
-
-
-
- {order.history.map((log, index) => (
-
-
-
-
-
-
{log.date}
-
{log.text}
-
-
- ))}
-
-
+ ))}
+
- {/* Columna Derecha (1/3): Presupuesto y Acciones */}
-
-
- {/* Ficha de Presupuesto con Card de shadcn */}
-
-
-
- Presupuesto del Servicio
+ {/* Columna Derecha (1/3): Presupuesto y Aprobación */}
+
+
+
+
+
+ Presupuesto
{order.budget ? (
-
- {/* Desglose */}
-
+
+
{order.budget.items.map((item, idx) => (
-
-
{item.desc}
-
${item.price.toLocaleString("es-AR")}
+
+ {item.desc}
+ ${item.price.toLocaleString("es-AR")}
))}
- {/* Total */}
-
-
Total Estimado
-
- ${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}
+
+ Total
+
+ ${order.budget.items.reduce((s, i) => s + i.price, 0).toLocaleString("es-AR")}
- {/* Estado de Aprobación mediante Alert de shadcn */}
{order.budget.approved ? (
-
-
-
-
- Aprobado
-
- Autorizado el {order.budget.dateApproved}
-
-
- ) : (
-
-
-
-
-
- Espera de Aprobación
-
- Requiere autorización del cliente para iniciar tareas.
-
-
-
- {/* Modal de Confirmación AlertDialog para Aprobar */}
-
-
-
- Aprobar Presupuesto
-
-
-
-
-
- ¿Confirmar aprobación del presupuesto?
-
-
- Al aprobar, autorizás al taller a realizar el recambio de cooler y mantenimiento térmico especificado por un total de ${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")} .
-
-
-
- Cancelar
-
- Confirmar Aprobación
-
-
-
-
+
+
+ Presupuesto Aprobado
+ ) : (
+
+
+
+ Aprobar Presupuesto
+
+
+
+
+
+ ¿Aprobar Presupuesto de Reparación?
+
+
+ Autorizas la realización del servicio por un total de ${order.budget.items.reduce((s, i) => s + i.price, 0).toLocaleString("es-AR")} .
+
+
+
+ Cancelar
+
+ Confirmar Aprobación
+
+
+
+
)}
) : (
-
-
- El técnico aún no ha cargado el presupuesto detallado de mano de obra y repuestos.
+
+
+ Presupuesto pendiente de evaluación por el laboratorio técnico.
-
- Pendiente de Carga
+
+ En Evaluación
)}
- {/* Acciones */}
-
+
toast.info("Módulo de exportación PDF disponible en la Fase VI.")}
- className="w-full bg-background hover:bg-muted text-foreground border-border text-xs font-bold py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center gap-2 select-none"
+ variant="ghost"
+ onClick={() => toast.info("Comprobante en formato digital disponible.")}
+ className="w-full text-xs font-semibold text-muted-foreground hover:text-foreground flex items-center justify-center gap-1.5 h-8 cursor-pointer"
>
-
-
-
- Exportar Presupuesto (PDF)
+ Descargar Ficha PDF
-
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index cf48d56..0a315a0 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -15,4 +15,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ server: {
+ allowedHosts: true,
+ },
})
diff --git a/frontend/worker.js b/frontend/worker.js
new file mode 100644
index 0000000..50ccfb5
--- /dev/null
+++ b/frontend/worker.js
@@ -0,0 +1,16 @@
+export default {
+ async fetch(request, env, ctx) {
+ const url = new URL(request.url);
+
+ // Intentar servir el recurso estático desde el binding ASSETS
+ let response = await env.ASSETS.fetch(request);
+
+ // Soporte SPA: Si no existe (404), servir index.html para que React Router maneje la ruta
+ if (response.status === 404) {
+ const indexRequest = new Request(new URL("/index.html", request.url), request);
+ response = await env.ASSETS.fetch(indexRequest);
+ }
+
+ return response;
+ }
+};
diff --git a/frontend/wrangler.toml b/frontend/wrangler.toml
new file mode 100644
index 0000000..c69affc
--- /dev/null
+++ b/frontend/wrangler.toml
@@ -0,0 +1,7 @@
+name = "repairit"
+main = "worker.js"
+compatibility_date = "2026-06-09"
+
+[assets]
+directory = "./dist"
+binding = "ASSETS"