diff --git a/DER.md b/DER.md new file mode 100644 index 0000000..fb257a4 --- /dev/null +++ b/DER.md @@ -0,0 +1,105 @@ +# Modelo Entidad-Relación: RepairIT (Multi-Sucursal & Normalizado) + +Este diagrama representa el modelo de datos de la base de datos de **RepairIT**, estructurado de forma normalizada (3NF) para evitar redundancias de clientes y permitir la gestión multi-sucursal y multi-inquilino. + +```mermaid +erDiagram + ORGANIZATIONS ||--o{ VENUES : "posee" + ORGANIZATIONS ||--o{ USERS : "tiene personal" + ORGANIZATIONS ||--o{ CLIENTS : "registra" + + VENUES ||--o{ VENUE_USERS : "asigna a" + USERS ||--o{ VENUE_USERS : "trabaja en" + + VENUES ||--o{ CLIENTS : "registra en sucursal" + CLIENTS ||--o{ ORDERS : "crea" + VENUES ||--o{ ORDERS : "procesa en" + + VENUES ||--o{ INVENTORY : "posee" + ORDERS ||--o{ ORDER_INVENTORY : "consume" + INVENTORY ||--o{ ORDER_INVENTORY : "es consumido por" + + ORGANIZATIONS { + uuid id PK + string name + string subscription_plan + string subscription_status + timestamp created_at + } + + VENUES { + uuid id PK + uuid organization_id FK + string name + string email + string phone + string address + timestamp created_at + } + + USERS { + uuid id PK + uuid organization_id FK + string name + string email UK + string password_hash + string role + timestamp created_at + } + + VENUE_USERS { + uuid id PK + uuid venue_id FK + uuid user_id FK + timestamp created_at + } + + CLIENTS { + uuid id PK + uuid organization_id FK + uuid venue_id FK + string name + string dni UK + string email + string phone + timestamp created_at + timestamp updated_at + } + + ORDERS { + uuid id PK + uuid venue_id FK + uuid client_id FK + string tracking_code UK + string device_type + string device_brand_model + string device_serial + text reported_fault + string status + decimal budget_amount + boolean budget_approved + timestamp budget_approved_at + timestamp created_at + timestamp updated_at + } + + INVENTORY { + uuid id PK + uuid venue_id FK + string name + text description + integer quantity + integer min_quantity + decimal price + timestamp created_at + } + + ORDER_INVENTORY { + uuid id PK + uuid order_id FK + uuid inventory_id FK + integer quantity_used + decimal unit_price_at_use + timestamp created_at + } +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7d80ac..35cf3e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@fontsource-variable/geist": "^5.2.9", + "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -2881,6 +2882,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -4903,6 +4988,15 @@ "node": ">=18.18.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5b646df..dfa8d38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@fontsource-variable/geist": "^5.2.9", + "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000..7797f7c --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3b5fd60..dfe49cf 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,7 @@ +import { useState, useEffect } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; +import { supabase } from "@/services/supabaseClient"; // Layouts import PublicLayout from "@/components/layout/PublicLayout"; @@ -14,11 +16,104 @@ import TermsPage from "@/views/public/TermsPage"; // Vistas Privadas (Dashboard) import DashboardPage from "@/views/admin/DashboardPage"; +import NewOrderPage from "@/views/admin/NewOrderPage"; import OrdersPage from "@/views/admin/OrdersPage"; +import ClientsPage from "@/views/admin/ClientsPage"; import InventoryPage from "@/views/admin/InventoryPage"; import ProfilePage from "@/views/admin/ProfilePage"; +import SuperAdminUsersPage from "@/views/admin/SuperAdminUsersPage"; +import VenueAccountsPage from "@/views/admin/VenueAccountsPage"; +import POSPage from "@/views/admin/POSPage"; export default function App() { + const [sessionLoading, setSessionLoading] = useState(true); + + useEffect(() => { + const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { + if (session) { + localStorage.setItem("repairit_token", session.access_token); + + const localUser = JSON.parse(localStorage.getItem("repairit_user") || "{}"); + if ( + !localUser._id || + !localUser.venueId || + localUser.venueId === "undefined" || + localUser._id !== session.user.id || + localUser.token !== session.access_token + ) { + try { + const { data: profile, error: profileErr } = await supabase + .from("profiles") + .select("id, name, role, organization_id, venue_id") + .eq("id", session.user.id) + .single(); + + if (profileErr) throw profileErr; + + let subPlan = "Sin Plan"; + let subStatus = "inactivo"; + + if (profile.organization_id) { + try { + const { data: org } = await supabase + .from("organizations") + .select("subscription_plan, subscription_status") + .eq("id", profile.organization_id) + .single(); + if (org) { + subPlan = org.subscription_plan; + subStatus = org.subscription_status; + } + } catch (orgErr) { + console.error("Error al sincronizar organización:", orgErr); + } + } + + if (profile) { + const userData = { + _id: profile.id, + name: profile.name, + email: session.user.email, + role: profile.role, + organizationId: profile.organization_id, + venueId: profile.venue_id, + subscriptionPlan: subPlan, + subscriptionStatus: subStatus, + token: session.access_token, + }; + localStorage.setItem("repairit_user", JSON.stringify(userData)); + + if (event === "SIGNED_IN") { + window.location.href = "/dashboard"; + } + } + } catch (err) { + console.error("Error al sincronizar perfil en onAuthStateChange:", err); + } + } + } else { + localStorage.removeItem("repairit_token"); + localStorage.removeItem("repairit_user"); + } + setSessionLoading(false); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + if (sessionLoading) { + return ( +
+
+
+

Cargando sesión segura...

+
+
+ ); + } + return ( @@ -35,9 +130,14 @@ export default function App() { {/* Entorno Administrativo (dashboard.repairit.cloud) */} }> } /> + } /> } /> + } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/components/layout/DashboardLayout.jsx b/frontend/src/components/layout/DashboardLayout.jsx index 46d6309..82b4ea5 100644 --- a/frontend/src/components/layout/DashboardLayout.jsx +++ b/frontend/src/components/layout/DashboardLayout.jsx @@ -1,7 +1,14 @@ +import { useState, useEffect } from "react"; import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { SidebarProvider, Sidebar, @@ -29,15 +36,60 @@ import { LogOut, Wrench, Store, - Settings + Settings, + PlusCircle, + Users, + ChevronDown, + KeyRound, + ShoppingCart } from "lucide-react"; import { toast } from "sonner"; +import { api } from "@/services/api"; +import { ShieldAlert } from "lucide-react"; + export default function DashboardLayout() { const navigate = useNavigate(); const location = useLocation(); + const user = JSON.parse(localStorage.getItem("repairit_user") || "{}"); + const isSuperAdmin = user.role === "superadmin"; + + const [venues, setVenues] = useState([]); + const [activeVenue, setActiveVenue] = useState(null); + + useEffect(() => { + if (!isSuperAdmin) { + const loadVenues = async () => { + try { + const profile = await api.auth.getProfile(); + setActiveVenue(profile); + const data = await api.auth.getVenues(); + setVenues(data); + } catch (err) { + console.error("Error al cargar sucursales en header:", err.message); + } + }; + loadVenues(); + } + }, [isSuperAdmin]); + + const handleHeaderSwitchVenue = async (venueId) => { + try { + const updatedUser = await api.auth.switchVenue(venueId); + const localUser = JSON.parse(localStorage.getItem("repairit_user") || "{}"); + localStorage.setItem("repairit_user", JSON.stringify({ ...localUser, venueId: updatedUser.venueId })); + toast.success("Sucursal cambiada correctamente"); + setTimeout(() => { + window.location.reload(); + }, 500); + } catch (err) { + toast.error("Error al cambiar de sucursal", { description: err.message }); + } + }; + const handleLogout = () => { + api.auth.logout(); toast.success("Sesión cerrada correctamente."); navigate("/"); }; @@ -45,6 +97,45 @@ export default function DashboardLayout() { // Determinar los Breadcrumbs basados en la ruta actual const getBreadcrumbs = () => { const path = location.pathname; + if (isSuperAdmin) { + if (path === "/dashboard/usuarios") { + return ( + + + + Panel + + + + + Gestión de Usuarios + + + ); + } + return ( + + + Suscripciones de Clientes + + + ); + } + if (path === "/dashboard/nuevo-ingreso") { + return ( + + + + Panel + + + + + Ingreso de Equipos + + + ); + } if (path === "/dashboard/ordenes") { return ( @@ -60,6 +151,21 @@ export default function DashboardLayout() { ); } + if (path === "/dashboard/clientes") { + return ( + + + + Panel + + + + + Directorio de Clientes + + + ); + } if (path === "/dashboard/inventario") { return ( @@ -110,10 +216,7 @@ export default function DashboardLayout() { {/* Logo de la Empresa */}
-
- -
- + RepairIT
@@ -122,36 +225,94 @@ export default function DashboardLayout() { + {isSuperAdmin ? ( + <> + + + + + Suscripciones + + + - - - - - Inicio - - - + + + + + Usuarios + + + + + ) : ( + <> + + + + + Inicio + + + - - - - - Órdenes de Servicio - - - + + + + + Nuevo Ingreso + + + - - - - - Inventario de Insumos - - - + + + + + Órdenes de Servicio + + + + + + + + Clientes + + + + + + + + Inventario de Insumos + + + + + + + + Caja + + + + + {user.role === "admin" && user.subscriptionPlan === "Multi-Taller Pro" && ( + + + + + Cuentas de Acceso + + + + )} + + )} @@ -159,24 +320,34 @@ export default function DashboardLayout() {
- EO + {isSuperAdmin + ? "SA" + : activeVenue && activeVenue.name + ? activeVenue.name.split(" ").map(n => n[0]).join("").substring(0, 2).toUpperCase() + : "TL"}
- Edgar Ortiz - Administrador + + {isSuperAdmin ? "Plataforma Central" : activeVenue ? activeVenue.name : "Cargando Taller..."} + + + {isSuperAdmin ? "Super Admin" : `${user.name || "Usuario"} (${user.role === "admin" ? "Admin" : "Técnico"})`} +
- + {!isSuperAdmin && ( + + )}
-
- Taller Central • Activo +
+ {isSuperAdmin ? ( +
+ Plataforma RepairIT • SuperAdmin +
+ ) : ( +
+ Sucursal: + {venues.length > 1 ? ( + + + + + + {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) */} +
+ + +
+ +
+ {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 ? ( +
+ + + + + + + + + + + + + {filteredClients.map((client) => { + const clientOrders = orders.filter(o => { + const oClientId = typeof o.clientId === "object" ? o.clientId._id : o.clientId; + return oClientId === client._id; + }); + return ( + + + + + + + + + ); + })} + +
ID ClienteNombreIdentificación (DNI)ContactoÓrdenesAcción
{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} + + +
+ + + +
+
+
+ ) : ( +
+ 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."} + +
+ +
+
+ + setClientName(e.target.value)} + placeholder="Edgar Ortiz" + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs" + /> +
+ +
+
+ + setClientDni(e.target.value)} + placeholder="44.375.912" + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono disabled:opacity-60" + /> +
+
+ + setClientPhone(e.target.value)} + placeholder="+54 381 1234567" + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs" + /> +
+
+ +
+ + setClientEmail(e.target.value)} + placeholder="edgar@repairit.cloud" + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs" + /> +
+ +
+ {editingId && ( + + )} + +
+
+
+
+ )} +
+ + {/* 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} + +
+ +
+ +
+ {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 ? ( +
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
TallerSucursalesTécnicosPlan ContratadoAcceso / LicenciaAlta
{org.name}{org.venuesCount || 0}{org.usersCount || 0} + + + + + + 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 + + + + + + + + + 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. -

- + + {orders.length > 0 ? ( +
+ + + + + + + + + + + {orders.slice(0, 5).map((o) => { + const clientName = o.clientId && typeof o.clientId === "object" ? o.clientId.name : "N/A"; + return ( + + + + + + + ); + })} + +
N° OrdenClienteEquipoEstado
{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. +

+ +
+ )}
- {/* 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) +
+ +
- - -
- + {/* 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. -

+ +
+ + + + +
+ + + +
- - +
+ + {/* Tabla de Resultados */} + {filteredItems.length > 0 ? ( +
+ + + + + + + + + + + + + + + {filteredItems.map((item) => { + const minQty = item.min_quantity || item.minQuantity || 0; + const { label, variant, colorClass } = getStockStatus(item.quantity || 0, minQty); + return ( + + + + + + + + + + + ); + })} + +
CódigoInsumoCategoríaPrecio VentaStock ActualMínimoEstadoAcciones
{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 */} + + + +
+ + {/* Editar */} + + + {/* Borrar */} + +
+
+
+ ) : ( +
+ No se encontraron insumos que coincidan con la búsqueda. +
+ )} + + {/* MODAL PARA CREACIÓN / EDICIÓN */} + {showModal && ( +
+
+ + +
+

+ {editingId ? "Editar Detalle de Insumo" : "Registrar Nuevo Insumo"} +

+

+ Complete las especificaciones del artículo para actualizar el stock. +

+
+ +
+ +
+ + setItemName(e.target.value)} + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs" + /> +
+ +
+
+ + setItemCode(e.target.value)} + onKeyDown={handleCodeKeyDown} + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono" + /> +
+ +
+ + +
+
+ +
+
+ + setItemQty(e.target.value)} + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono" + /> +
+ +
+ + setItemMinQty(e.target.value)} + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono" + /> +
+ +
+ + setItemPrice(e.target.value)} + className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono" + /> +
+
+ +
+ +