feat: Integración completa de Caja (POS), Inventario, Diagnósticos y RLS Supabase

This commit is contained in:
Edgar Javier Ortiz 2026-08-19 17:35:56 -03:00
parent 63dc9163a5
commit 3e96a0e095
23 changed files with 6501 additions and 872 deletions

105
DER.md Normal file
View File

@ -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
}
```

View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1 @@
/* /index.html 200

View File

@ -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 (
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-xs text-muted-foreground font-light">Cargando sesión segura...</p>
</div>
</div>
);
}
return (
<BrowserRouter>
<Routes>
@ -35,9 +130,14 @@ export default function App() {
{/* Entorno Administrativo (dashboard.repairit.cloud) */}
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardPage />} />
<Route path="nuevo-ingreso" element={<NewOrderPage />} />
<Route path="ordenes" element={<OrdersPage />} />
<Route path="clientes" element={<ClientsPage />} />
<Route path="inventario" element={<InventoryPage />} />
<Route path="perfil" element={<ProfilePage />} />
<Route path="usuarios" element={<SuperAdminUsersPage />} />
<Route path="cuentas" element={<VenueAccountsPage />} />
<Route path="caja" element={<POSPage />} />
</Route>
</Routes>

View File

@ -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 (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Gestión de Usuarios</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Suscripciones de Clientes</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
if (path === "/dashboard/nuevo-ingreso") {
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Ingreso de Equipos</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
if (path === "/dashboard/ordenes") {
return (
<BreadcrumbList>
@ -60,6 +151,21 @@ export default function DashboardLayout() {
</BreadcrumbList>
);
}
if (path === "/dashboard/clientes") {
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Directorio de Clientes</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
if (path === "/dashboard/inventario") {
return (
<BreadcrumbList>
@ -110,10 +216,7 @@ export default function DashboardLayout() {
{/* Logo de la Empresa */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold">
<Wrench className="w-4 h-4" />
</div>
<span className="font-outfit text-lg font-black tracking-tight text-foreground">
<span className="font-outfit text-xl font-black tracking-tight text-foreground">
Repair<span className="text-primary">IT</span>
</span>
</div>
@ -122,36 +225,94 @@ export default function DashboardLayout() {
<SidebarContent className="p-4 pt-6">
<SidebarMenu className="space-y-1">
{isSuperAdmin ? (
<>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard"}>
<NavLink to="/dashboard" end className="flex items-center gap-3 w-full">
<LayoutDashboard className="w-4 h-4" />
<span>Suscripciones</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard"}>
<NavLink to="/dashboard" end className="flex items-center gap-3 w-full">
<LayoutDashboard className="w-4 h-4" />
<span>Inicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/usuarios"}>
<NavLink to="/dashboard/usuarios" className="flex items-center gap-3 w-full">
<Users className="w-4 h-4" />
<span>Usuarios</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
</>
) : (
<>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard"}>
<NavLink to="/dashboard" end className="flex items-center gap-3 w-full">
<LayoutDashboard className="w-4 h-4" />
<span>Inicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/ordenes"}>
<NavLink to="/dashboard/ordenes" className="flex items-center gap-3 w-full">
<ClipboardList className="w-4 h-4" />
<span>Órdenes de Servicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/nuevo-ingreso"}>
<NavLink to="/dashboard/nuevo-ingreso" className="flex items-center gap-3 w-full">
<PlusCircle className="w-4 h-4" />
<span>Nuevo Ingreso</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/inventario"}>
<NavLink to="/dashboard/inventario" className="flex items-center gap-3 w-full">
<Package className="w-4 h-4" />
<span>Inventario de Insumos</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/ordenes"}>
<NavLink to="/dashboard/ordenes" className="flex items-center gap-3 w-full">
<ClipboardList className="w-4 h-4" />
<span>Órdenes de Servicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/clientes"}>
<NavLink to="/dashboard/clientes" className="flex items-center gap-3 w-full">
<Users className="w-4 h-4" />
<span>Clientes</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/inventario"}>
<NavLink to="/dashboard/inventario" className="flex items-center gap-3 w-full">
<Package className="w-4 h-4" />
<span>Inventario de Insumos</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/caja"}>
<NavLink to="/dashboard/caja" className="flex items-center gap-3 w-full">
<ShoppingCart className="w-4 h-4" />
<span>Caja</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
{user.role === "admin" && user.subscriptionPlan === "Multi-Taller Pro" && (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/cuentas"}>
<NavLink to="/dashboard/cuentas" className="flex items-center gap-3 w-full">
<KeyRound className="w-4 h-4" />
<span>Cuentas de Acceso</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</>
)}
</SidebarMenu>
</SidebarContent>
@ -159,24 +320,34 @@ export default function DashboardLayout() {
<div className="flex items-center justify-between px-2 w-full gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center font-bold text-xs text-primary uppercase shrink-0">
EO
{isSuperAdmin
? "SA"
: activeVenue && activeVenue.name
? activeVenue.name.split(" ").map(n => n[0]).join("").substring(0, 2).toUpperCase()
: "TL"}
</div>
<div className="text-xs truncate min-w-0">
<span className="text-foreground font-bold block leading-none truncate">Edgar Ortiz</span>
<span className="text-muted-foreground block text-[10px] mt-1 truncate">Administrador</span>
<span className="text-foreground font-bold block leading-none truncate">
{isSuperAdmin ? "Plataforma Central" : activeVenue ? activeVenue.name : "Cargando Taller..."}
</span>
<span className="text-muted-foreground block text-[10px] mt-1 truncate">
{isSuperAdmin ? "Super Admin" : `${user.name || "Usuario"} (${user.role === "admin" ? "Admin" : "Técnico"})`}
</span>
</div>
</div>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer shrink-0"
title="Configuración de Taller"
>
<NavLink to="/dashboard/perfil">
<Settings className="w-4 h-4" />
</NavLink>
</Button>
{!isSuperAdmin && (
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer shrink-0"
title="Configuración de Taller"
>
<NavLink to="/dashboard/perfil">
<Settings className="w-4 h-4" />
</NavLink>
</Button>
)}
</div>
<Button
@ -203,8 +374,42 @@ export default function DashboardLayout() {
{getBreadcrumbs()}
</Breadcrumb>
</div>
<div className="text-[10px] text-muted-foreground font-mono select-none">
Taller Central &bull; Activo
<div className="flex items-center gap-3">
{isSuperAdmin ? (
<div className="text-[10px] text-muted-foreground font-mono select-none">
Plataforma RepairIT • SuperAdmin
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground font-mono select-none">Sucursal:</span>
{venues.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 border border-border/80 px-2 text-[10px] font-bold rounded flex items-center gap-1 select-none cursor-pointer">
{activeVenue ? activeVenue.name : "Cargando..."}
<ChevronDown className="w-3 h-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border text-foreground text-[10px]">
{venues.map((v) => (
<DropdownMenuItem
key={v._id}
onClick={() => handleHeaderSwitchVenue(v._id)}
className="text-[10px] transition-colors cursor-pointer flex items-center justify-between gap-4"
>
<span>{v.name}</span>
{v._id === activeVenue?._id && <span className="text-primary font-bold">✓</span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="text-[10px] text-foreground font-bold font-mono">
{activeVenue ? activeVenue.name : "Cargando..."}
</span>
)}
</div>
)}
</div>
</header>

View File

@ -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." };
},
},
};

View File

@ -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);

View File

@ -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 (
<div className="space-y-6">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<Users className="w-7 h-7 text-primary shrink-0" />
<span>Directorio de Clientes</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Gestión de fichas de clientes y acceso a su historial de reparaciones técnico.
</p>
</div>
{/* Selector de Pestañas (Tabs) */}
<div className="flex border-b border-border/60 gap-6">
<button
onClick={() => 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
</button>
<button
onClick={() => 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"}
</button>
</div>
<div className="animate-fade-in pt-2">
{activeTab === "control" ? (
<Card className="bg-card/20 border-border p-6 shadow-sm space-y-4">
{/* Buscador */}
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between border-b border-border/50 pb-4">
<div className="relative w-full sm:max-w-xs flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<Input
type="text"
placeholder="Buscar por nombre, DNI, teléfono..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background/80 border-border text-xs w-full pl-9"
/>
</div>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono font-bold">
Total: {clients.length} Clientes
</span>
</div>
{/* Listado de Clientes */}
{filteredClients.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-3 px-2">ID Cliente</th>
<th className="py-3 px-2">Nombre</th>
<th className="py-3 px-2">Identificación (DNI)</th>
<th className="py-3 px-2">Contacto</th>
<th className="py-3 px-2 text-center">Órdenes</th>
<th className="py-3 px-2 text-right">Acción</th>
</tr>
</thead>
<tbody>
{filteredClients.map((client) => {
const clientOrders = orders.filter(o => {
const oClientId = typeof o.clientId === "object" ? o.clientId._id : o.clientId;
return oClientId === client._id;
});
return (
<tr key={client._id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
<td className="py-3.5 px-2 font-mono font-bold text-primary">{client._id.substring(client._id.length - 6).toUpperCase()}</td>
<td className="py-3.5 px-2 font-medium">{client.name}</td>
<td className="py-3.5 px-2 font-mono">{client.dni}</td>
<td className="py-3.5 px-2">
<div className="flex flex-col">
<span>{client.phone}</span>
<span className="text-[10px] text-muted-foreground">{client.email}</span>
</div>
</td>
<td className="py-3.5 px-2 text-center">
<Badge variant={clientOrders.length > 0 ? "default" : "secondary"} className="text-[10px] font-bold">
{clientOrders.length}
</Badge>
</td>
<td className="py-3.5 px-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => 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}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleEditClick(client)}
className="text-muted-foreground hover:text-primary hover:bg-primary/10"
title="Editar ficha de cliente"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDeleteRequest(client)}
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/15"
title="Eliminar cliente"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-xs font-light">
No se encontraron clientes con los filtros actuales.
</div>
)}
</Card>
) : (
/* Formulario de Alta / Edición */
<div className="max-w-2xl mx-auto w-full">
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm space-y-6">
<div className="space-y-1">
<CardTitle className="font-outfit text-lg font-bold text-foreground flex items-center gap-2 border-0 pb-0">
<PlusCircle className="w-5 h-5 text-primary" />
{editingId ? "Editar Ficha de Cliente" : "Alta de Ficha de Cliente"}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{editingId ? `Modificá los datos de contacto y facturación de ${clientName}.` : "Registrá un nuevo cliente en la base de datos."}
</CardDescription>
</div>
<form onSubmit={handleRegisterClient} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="c-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</Label>
<Input
id="c-name"
type="text"
required
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Edgar Ortiz"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="c-dni" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">DNI / Identificación</Label>
<Input
id="c-dni"
type="text"
required
disabled={editingId !== null}
value={clientDni}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="c-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono</Label>
<Input
id="c-phone"
type="text"
required
value={clientPhone}
onChange={(e) => setClientPhone(e.target.value)}
placeholder="+54 381 1234567"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="c-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email</Label>
<Input
id="c-email"
type="email"
required
value={clientEmail}
onChange={(e) => setClientEmail(e.target.value)}
placeholder="edgar@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="flex gap-3 pt-2">
{editingId && (
<Button
type="button"
variant="outline"
onClick={handleCancelEdit}
className="w-full border-border text-xs py-2 rounded-md transition-all select-none cursor-pointer"
>
Cancelar
</Button>
)}
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 rounded-md transition-all select-none cursor-pointer"
>
{editingId ? "Guardar Cambios" : "Registrar Cliente"}
</Button>
</div>
</form>
</Card>
</div>
)}
</div>
{/* Modal / Diálogo para visualización de Órdenes del Cliente */}
{showOrdersModal && selectedClientForOrders && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<Card className="bg-background border-border max-w-2xl w-full max-h-[80vh] overflow-y-auto shadow-2xl p-6 space-y-4">
<div className="flex items-center justify-between border-b border-border/50 pb-3">
<div className="space-y-1 text-left">
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center gap-2 border-0 pb-0">
<ClipboardList className="w-4.5 h-4.5 text-primary" />
Reparaciones de {selectedClientForOrders.name}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Ficha de cliente: {selectedClientForOrders._id.substring(selectedClientForOrders._id.length - 6).toUpperCase()} &bull; DNI: {selectedClientForOrders.dni}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowOrdersModal(false);
setSelectedClientForOrders(null);
}}
className="text-muted-foreground hover:text-foreground text-xs"
>
Cerrar
</Button>
</div>
<div className="space-y-3">
{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 (
<div key={o._id} className="border border-border/55 bg-card/30 p-4 rounded-lg flex justify-between items-start gap-4">
<div className="text-left space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-primary text-xs">{o.trackingCode}</span>
<span className="text-[10px] text-muted-foreground">{o.date}</span>
</div>
<span className="font-medium text-xs text-foreground block">{o.deviceModel} ({o.deviceType})</span>
<p className="text-[10px] text-muted-foreground leading-normal mt-1">{o.issue}</p>
</div>
<Badge variant={o.status === "listo" ? "success" : o.status === "ingresado" ? "secondary" : "outline"} className="text-[9px] uppercase font-bold py-0.5 px-2 border">
{o.status.toUpperCase()}
</Badge>
</div>
);
})}
</div>
</Card>
</div>
)}
{/* Diálogo de alerta para confirmación de eliminación */}
<AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
<AlertDialogContent className="bg-background border-border text-left">
<AlertDialogHeader>
<AlertDialogTitle className="font-outfit text-lg font-bold text-foreground">
¿Confirmás la eliminación del cliente?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs text-muted-foreground leading-normal">
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-border text-xs">Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteClient}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground text-xs font-bold"
>
Sí, Eliminar Todo
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -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 (
<div className="space-y-8 animate-fade-in">
<div className="space-y-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
</div>
<Skeleton className="h-96 w-full rounded-2xl" />
</div>
);
}
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<ShieldCheck className="w-7 h-7 text-primary shrink-0" />
<span>Plataforma de Suscripciones</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Administración centralizada de inquilinos, licencias operativas y planes de suscripción de talleres.
</p>
</div>
{/* Tarjetas de Métricas de Plataforma */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Talleres / Inquilinos</span>
<Building2 className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">{totalOrgs}</span>
<span className="text-[10px] text-primary block font-medium mt-1">Registrados en la plataforma</span>
</CardContent>
</Card>
<Card className="bg-emerald-500/5 border-emerald-500/20 shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-emerald-450 uppercase tracking-wider">Planes Activos</span>
<ShieldCheck className="h-4 w-4 text-emerald-400" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-emerald-400 block">{activeOrgs}</span>
<span className="text-[10px] text-emerald-400 block font-medium mt-1">Talleres con acceso vigente</span>
</CardContent>
</Card>
<Card className="bg-destructive/5 border-destructive/20 shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-destructive/80 uppercase tracking-wider">Suspendidos / Vencidos</span>
<AlertCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-destructive block">{suspendedOrgs}</span>
<span className="text-[10px] text-destructive/80 block font-medium mt-1">Talleres bloqueados</span>
</CardContent>
</Card>
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Sucursales Totales</span>
<Wrench className="h-4 w-4 text-muted-foreground/60" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">{totalVenues}</span>
<span className="text-[10px] text-muted-foreground block font-medium mt-1">Puntos de servicio activos</span>
</CardContent>
</Card>
</div>
{/* Listado y control de planes */}
<Card className="bg-card/20 border-border p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border/50 pb-3">
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center gap-2 border-0 pb-0">
<CreditCard className="w-4.5 h-4.5 text-muted-foreground" />
Control de Cuentas & Licencias
</CardTitle>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono font-bold">Total: {organizations.length}</span>
</div>
{/* Filtro rápido de búsqueda */}
<div className="flex gap-3 py-2 border-b border-border/30">
<div className="relative w-full sm:max-w-xs flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<Input
type="text"
placeholder="Buscar por nombre de taller..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background/80 border-border text-xs w-full pl-9"
/>
</div>
</div>
{filteredOrgs.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-3 px-2">Taller</th>
<th className="py-3 px-2">Sucursales</th>
<th className="py-3 px-2">Técnicos</th>
<th className="py-3 px-2">Plan Contratado</th>
<th className="py-3 px-2">Acceso / Licencia</th>
<th className="py-3 px-2">Alta</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={org._id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
<td className="py-3.5 px-2 font-medium text-foreground text-sm">{org.name}</td>
<td className="py-3.5 px-2 font-mono text-muted-foreground">{org.venuesCount || 0}</td>
<td className="py-3.5 px-2 font-mono text-muted-foreground">{org.usersCount || 0}</td>
<td className="py-3.5 px-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border px-2 text-xs font-semibold rounded flex items-center gap-1">
{org.subscriptionPlan}
<ChevronDown className="w-3 h-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border text-foreground">
<DropdownMenuItem onClick={() => handlePlanChange(org._id, "Taller Básico")} className="text-xs transition-colors cursor-pointer">Taller Básico</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePlanChange(org._id, "Taller Premium")} className="text-xs transition-colors cursor-pointer">Taller Premium</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePlanChange(org._id, "Multi-Taller Pro")} className="text-xs transition-colors cursor-pointer">Multi-Taller Pro</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
<td className="py-3.5 px-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border px-2 text-xs font-semibold rounded flex items-center gap-1">
<Badge variant={statusBadge} className="text-[9px] uppercase font-bold px-1.5 py-0.5 border">
{statusLabel}
</Badge>
<ChevronDown className="w-3 h-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border text-foreground">
<DropdownMenuItem onClick={() => handleStatusChange(org._id, "activo")} className="text-xs transition-colors cursor-pointer">Activo</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange(org._id, "suspendido")} className="text-xs transition-colors cursor-pointer">Suspendido</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange(org._id, "vencido")} className="text-xs transition-colors cursor-pointer">Vencido</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
<td className="py-3.5 px-2 text-muted-foreground font-mono">{org.createdAt ? new Date(org.createdAt).toLocaleDateString("es-AR") : "N/A"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-xs font-light">
No se encontraron talleres/inquilinos registrados.
</div>
)}
</div>
</Card>
</div>
);
}
// Render para Técnicos / Administradores comunes
if (loading) {
return (
<div className="space-y-8 animate-fade-in">
@ -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 (
<div className="space-y-8 animate-fade-in">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Panel de Control
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<LayoutDashboard className="w-7 h-7 text-primary shrink-0" />
<span>Panel de Control</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
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.
</p>
</div>
{/* Grid de Estadísticas Rápidas mediante Cards de shadcn/ui */}
{/* Grid de Estadísticas Rápidas */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Card 1: Órdenes Activas */}
@ -68,20 +369,20 @@ export default function DashboardPage() {
<Wrench className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">12</span>
<span className="text-[10px] text-primary block font-medium mt-1">4 en diagnóstico, 8 en reparación</span>
<span className="text-3xl font-outfit font-black text-foreground block">{activeOrdersCount}</span>
<span className="text-[10px] text-primary block font-medium mt-1">{activeOrdersDetail}</span>
</CardContent>
</Card>
{/* Card 2: Entregados este Mes */}
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Entregas del Mes</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Entregados Totales</span>
<PackageCheck className="h-4 w-4 text-emerald-400" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">45</span>
<span className="text-[10px] text-emerald-400 block font-medium mt-1">+15% respecto al mes anterior</span>
<span className="text-3xl font-outfit font-black text-foreground block">{deliveredCount}</span>
<span className="text-[10px] text-emerald-400 block font-medium mt-1">Dispositivos retirados por clientes</span>
</CardContent>
</Card>
@ -92,20 +393,20 @@ export default function DashboardPage() {
<ShieldAlert className="h-4 w-4 text-muted-foreground/60" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">84</span>
<span className="text-[10px] text-muted-foreground block font-medium mt-1">Distribuidos en 14 categorías</span>
<span className="text-3xl font-outfit font-black text-foreground block">{totalItemsCount}</span>
<span className="text-[10px] text-muted-foreground block font-medium mt-1">{totalItemsDetail}</span>
</CardContent>
</Card>
{/* Card 4: Alertas de Stock */}
<Card className="bg-destructive/5 border-destructive/20 shadow-md">
<Card className={lowStockCount > 0 ? "bg-destructive/5 border-destructive/20 shadow-md" : "bg-card/45 border-border shadow-md"}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-destructive/80 uppercase tracking-wider">Alertas de Stock</span>
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Alertas de Stock</span>
<AlertCircle className={lowStockCount > 0 ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground/60"} />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-destructive block">2</span>
<span className="text-[10px] text-destructive/80 block font-medium mt-1">Requieren compra urgente</span>
<span className={lowStockCount > 0 ? "text-3xl font-outfit font-black text-destructive block" : "text-3xl font-outfit font-black text-foreground block"}>{lowStockCount}</span>
<span className={lowStockCount > 0 ? "text-[10px] text-destructive/80 block font-medium mt-1" : "text-[10px] text-muted-foreground block font-medium mt-1"}>{lowStockDetail}</span>
</CardContent>
</Card>
@ -113,27 +414,61 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Tabla de Órdenes Recientes mediante Card de shadcn */}
{/* Tabla de Órdenes Recientes */}
<Card className="lg:col-span-2 bg-card/20 border-border shadow-sm">
<CardHeader className="border-b border-border/50 py-4 flex flex-row items-center justify-between">
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
Ingresos Recientes
</CardTitle>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono">Actualizado hace 5m</span>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono">Últimos 5 registros</span>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-xs font-light leading-relaxed max-w-sm">
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.
</p>
<Button asChild size="sm" variant="outline" className="mt-4 border-border text-xs gap-1 font-bold">
<Link to="/dashboard/ordenes">
Ir a Registro de Dispositivos <ArrowUpRight className="w-3.5 h-3.5" />
</Link>
</Button>
<CardContent className="pt-4">
{orders.length > 0 ? (
<div className="overflow-x-auto w-full">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/40 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-2">N° Orden</th>
<th className="py-2">Cliente</th>
<th className="py-2">Equipo</th>
<th className="py-2 text-right">Estado</th>
</tr>
</thead>
<tbody>
{orders.slice(0, 5).map((o) => {
const clientName = o.clientId && typeof o.clientId === "object" ? o.clientId.name : "N/A";
return (
<tr key={o._id} className="border-b border-border/20 hover:bg-card/10">
<td className="py-2.5 font-mono font-bold text-primary">{o.trackingCode}</td>
<td className="py-2.5 font-medium">{clientName}</td>
<td className="py-2.5 text-muted-foreground">{o.deviceModel}</td>
<td className="py-2.5 text-right">
<Badge variant={o.status === "listo" ? "success" : o.status === "ingresado" ? "secondary" : "outline"} className="text-[9px] uppercase font-bold py-0.5 px-2 border">
{o.status}
</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12">
<p className="text-xs text-muted-foreground leading-normal font-light">
No hay órdenes de servicio cargadas. Ve a la pestaña de "Registrar Ingreso" para añadir un ingreso técnico.
</p>
<Button asChild size="sm" variant="outline" className="mt-4 border-border text-xs gap-1 font-bold">
<Link to="/dashboard/ordenes">
Ir a Registro de Dispositivos <ArrowUpRight className="w-3.5 h-3.5" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Alertas de Stock Detalladas mediante Alert de shadcn/ui */}
{/* Alertas de Stock Detalladas */}
<Card className="bg-card/20 border-border shadow-sm">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
@ -142,31 +477,26 @@ export default function DashboardPage() {
</CardHeader>
<CardContent className="pt-6 space-y-4">
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
<AlertCircle className="h-4 w-4 stroke-destructive" />
<div className="ml-2">
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
<span>Pasta Térmica Arctic MX-6</span>
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
</AlertTitle>
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
Mínimo requerido: 5 jeringas | Actual en stock: 2 jeringas.
</AlertDescription>
{lowStockItems.length > 0 ? (
lowStockItems.map((item) => (
<Alert key={item._id} variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
<AlertCircle className="h-4 w-4 stroke-destructive" />
<div className="ml-2 w-full text-left">
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
<span>{item.name}</span>
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
</AlertTitle>
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
Mínimo requerido: {item.minQuantity || item.min_quantity} | Actual en stock: {item.quantity}.
</AlertDescription>
</div>
</Alert>
))
) : (
<div className="text-center py-8 text-muted-foreground text-xs font-light">
No hay alertas de stock críticas en esta sucursal.
</div>
</Alert>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
<AlertCircle className="h-4 w-4 stroke-destructive" />
<div className="ml-2">
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
<span>Alcohol Isopropílico 1L</span>
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
</AlertTitle>
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
Mínimo requerido: 3 botellas | Actual en stock: 1 botella.
</AlertDescription>
</div>
</Alert>
)}
</CardContent>
</Card>

View File

@ -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 (
<div className="space-y-6 animate-fade-in">
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Inventario de Insumos
</h1>
<p className="text-xs text-muted-foreground font-light">
Control de stock y alertas de reposición de componentes técnicos.
</p>
<div className="space-y-6">
{/* Cabecera */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<Package className="w-7 h-7 text-primary shrink-0" />
<span>Control de Inventario</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Monitorea el stock de repuestos e insumos del taller, importa listas de precios y exporta informes.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex items-center gap-2 bg-primary/10 border border-primary/20 rounded-lg px-3.5 py-2 text-[11px] text-primary font-semibold select-none self-start sm:self-auto shadow-sm">
<Scan className="w-4 h-4 animate-pulse" />
Lector de Código Activo (Escanea para buscar o registrar)
</div>
<Button
onClick={handleOpenCreateModal}
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"
>
<PlusCircle className="w-4 h-4" />
Registrar Insumo
</Button>
</div>
</div>
<Card className="bg-card/25 border-border shadow-md">
<CardContent className="flex flex-col items-center justify-center py-20 text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<Package className="w-6 h-6" />
{/* Grid de Estadísticas */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Total de Insumos</span>
<Package className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<span className="text-2xl font-outfit font-black text-foreground block">{items.length}</span>
<span className="text-[10px] text-muted-foreground block font-light mt-1">Componentes registrados</span>
</CardContent>
</Card>
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Valor Estimado Stock</span>
<span className="text-xs font-black text-primary font-mono">$</span>
</CardHeader>
<CardContent>
<span className="text-2xl font-outfit font-black text-foreground block">
${totalValue.toLocaleString("es-AR")}
</span>
<span className="text-[10px] text-muted-foreground block font-light mt-1">Valor de venta acumulado</span>
</CardContent>
</Card>
<Card className={`${criticalItems > 0 ? "bg-destructive/5 border-destructive/20" : "bg-card/45 border-border"} shadow-md`}>
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
<span className={`text-xs font-semibold uppercase tracking-wider ${criticalItems > 0 ? "text-destructive/80" : "text-muted-foreground"}`}>Alertas Críticas</span>
<AlertCircle className={`h-4 w-4 ${criticalItems > 0 ? "text-destructive animate-pulse" : "text-muted-foreground/60"}`} />
</CardHeader>
<CardContent>
<span className={`text-2xl font-outfit font-black block ${criticalItems > 0 ? "text-destructive" : "text-foreground"}`}>{criticalItems}</span>
<span className="text-[10px] text-muted-foreground block font-light mt-1">Bajo el mínimo sugerido</span>
</CardContent>
</Card>
</div>
{/* Barra de herramientas */}
<Card className="bg-card/20 border-border p-6 shadow-sm space-y-4">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between border-b border-border/50 pb-4">
<div className="relative w-full md:max-w-xs flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<Input
type="text"
placeholder="Buscar insumo o código..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background/80 border-border text-xs w-full pl-9 focus-visible:ring-1 focus-visible:ring-primary"
/>
</div>
<div className="space-y-1">
<p className="text-foreground/90 font-bold text-sm">Módulo de Inventario en Desarrollo</p>
<p className="max-w-md mx-auto text-xs text-muted-foreground leading-relaxed font-light">
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.
</p>
<div className="flex flex-wrap md:flex-nowrap gap-2.5 w-full md:w-auto">
<select
value={categoryFilter}
onChange={(e) => 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"
>
<option value="todas">Todas las categorías</option>
<option value="Insumos">Insumos</option>
<option value="Repuestos">Repuestos</option>
<option value="Herramientas">Herramientas</option>
<option value="Accesorios">Accesorios</option>
</select>
<select
value={stockFilter}
onChange={(e) => 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"
>
<option value="todos">Todos los niveles</option>
<option value="normal">Stock Normal</option>
<option value="bajo">Bajo Stock</option>
<option value="sin">Sin Stock</option>
</select>
<div className="flex gap-2 w-full sm:w-auto">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleImportCSV}
className="hidden"
/>
<Button
variant="outline"
onClick={() => 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"
>
<Upload className="w-3.5 h-3.5" />
Importar
</Button>
<Button
variant="outline"
onClick={handleExportCSV}
className="h-9 border-border/80 text-xs font-semibold flex items-center gap-1.5 cursor-pointer w-full sm:w-auto"
title="Exportar catálogo a CSV"
>
<Download className="w-3.5 h-3.5" />
Exportar
</Button>
</div>
</div>
<Button variant="outline" className="text-xs font-bold border-border" onClick={() => toast.info("Módulo en desarrollo.")}>
Simular Escaneo de Stock
</Button>
</CardContent>
</div>
{/* Tabla de Resultados */}
{filteredItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-3 px-2">Código</th>
<th className="py-3 px-2">Insumo</th>
<th className="py-3 px-2">Categoría</th>
<th className="py-3 px-2">Precio Venta</th>
<th className="py-3 px-2">Stock Actual</th>
<th className="py-3 px-2">Mínimo</th>
<th className="py-3 px-2">Estado</th>
<th className="py-3 px-2 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => {
const minQty = item.min_quantity || item.minQuantity || 0;
const { label, variant, colorClass } = getStockStatus(item.quantity || 0, minQty);
return (
<tr key={item._id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
<td className="py-3.5 px-2 font-mono text-muted-foreground">{item.code}</td>
<td className="py-3.5 px-2">
<span className="font-bold text-foreground text-sm block leading-tight">{item.name}</span>
{item.description && (
<span className="text-[10px] text-muted-foreground font-light block max-w-xs truncate">{item.description}</span>
)}
</td>
<td className="py-3.5 px-2">
<Badge variant="secondary" className="text-[9px] uppercase font-bold px-1.5 py-0.5">{item.category}</Badge>
</td>
<td className="py-3.5 px-2 font-mono text-foreground font-bold">${(item.price || 0).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td className="py-3.5 px-2 font-mono font-bold text-foreground text-sm">{item.quantity}</td>
<td className="py-3.5 px-2 font-mono text-muted-foreground">{minQty}</td>
<td className="py-3.5 px-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase border ${colorClass}`}>
{label}
</span>
</td>
<td className="py-3.5 px-2 text-right">
<div className="flex justify-end items-center gap-1">
{/* Ajustes rápidos */}
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Plus className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Minus className="w-3.5 h-3.5" />
</Button>
<div className="w-px h-4 bg-border mx-1" />
{/* Editar */}
<Button
variant="ghost"
size="icon"
onClick={() => handleOpenEditModal(item)}
className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted/40 cursor-pointer rounded-full"
title="Editar insumo"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
{/* Borrar */}
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-xs font-light">
No se encontraron insumos que coincidan con la búsqueda.
</div>
)}
</Card>
{/* MODAL PARA CREACIÓN / EDICIÓN */}
{showModal && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6 space-y-4 animate-in fade-in-50 zoom-in-95 duration-150 relative">
<Button
onClick={() => 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"
>
<X className="w-4 h-4" />
</Button>
<div className="space-y-1">
<h3 className="font-outfit text-base font-bold text-foreground">
{editingId ? "Editar Detalle de Insumo" : "Registrar Nuevo Insumo"}
</h3>
<p className="text-xs text-muted-foreground font-light">
Complete las especificaciones del artículo para actualizar el stock.
</p>
</div>
<form onSubmit={handleSaveItem} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="item-name" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Nombre del Insumo</Label>
<Input
id="item-name"
type="text"
required
placeholder="Ej. Pantalla LCD iPhone 13"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="item-code" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground flex justify-between">
<span>Código SKU / Barras</span>
</Label>
<Input
id="item-code"
type="text"
placeholder="Escanea o escribe código"
value={itemCode}
onChange={(e) => setItemCode(e.target.value)}
onKeyDown={handleCodeKeyDown}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="item-category" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Categoría</Label>
<select
id="item-category"
value={itemCategory}
onChange={(e) => setItemCategory(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"
>
<option value="Insumos">Insumos</option>
<option value="Repuestos">Repuestos</option>
<option value="Herramientas">Herramientas</option>
<option value="Accesorios">Accesorios</option>
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="item-qty" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Stock Inicial</Label>
<Input
id="item-qty"
type="number"
min="0"
required
value={itemQty}
onChange={(e) => setItemQty(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="item-min" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Stock Mínimo</Label>
<Input
id="item-min"
type="number"
min="0"
required
value={itemMinQty}
onChange={(e) => setItemMinQty(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="item-price" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Precio Venta</Label>
<Input
id="item-price"
type="number"
min="0"
step="0.01"
required
value={itemPrice}
onChange={(e) => setItemPrice(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="item-desc" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Descripción o Notas</Label>
<Textarea
id="item-desc"
placeholder="Especificaciones, marca o notas del insumo..."
value={itemDesc}
onChange={(e) => setItemDesc(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs h-20"
/>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border/40">
<Button
type="button"
variant="outline"
onClick={() => setShowModal(false)}
className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancelar
</Button>
<Button
type="submit"
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-5 rounded-md cursor-pointer flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
{submitting ? "Guardando..." : "Guardar Insumo"}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -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() {
</Button>
</form>
<div className="relative flex py-1 items-center">
<div className="flex-grow border-t border-border/60"></div>
<span className="flex-shrink mx-3 text-[10px] text-muted-foreground uppercase tracking-widest font-bold">O continuar con</span>
<div className="flex-grow border-t border-border/60"></div>
</div>
<Button
type="button"
onClick={handleGoogleLogin}
variant="outline"
className="w-full border-border/80 hover:bg-card/65 hover:text-foreground text-xs font-semibold py-2.5 rounded-lg flex items-center justify-center gap-2 transition-all cursor-pointer select-none"
>
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
/>
</svg>
Iniciar sesión con Google
</Button>
<div className="text-center pt-2">
<Button
variant="link"

View File

@ -0,0 +1,275 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
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 { Separator } from "@/components/ui/separator";
import { api } from "@/services/api";
import { toast } from "sonner";
import { PlusCircle } from "lucide-react";
export default function NewOrderPage() {
const navigate = useNavigate();
// 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 [accessories, setAccessories] = useState("");
const [cosmeticCondition, setCosmeticCondition] = useState("");
const [issue, setIssue] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
// Registrar orden
const handleRegisterOrder = async (e) => {
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 (
<div className="space-y-6">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<PlusCircle className="w-7 h-7 text-primary shrink-0" />
<span>Ingreso de Equipos</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Registro rápido de dispositivos recibidos para diagnóstico y reparación.
</p>
</div>
<div className="max-w-2xl mx-auto w-full pt-2">
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm space-y-6">
<div className="space-y-1">
<CardTitle className="font-outfit text-lg font-bold text-foreground flex items-center gap-2 border-0 pb-0">
<PlusCircle className="w-5 h-5 text-primary" />
Nuevo Ingreso Técnico
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Completá el perfil del cliente y las especificaciones del dispositivo.
</CardDescription>
</div>
<form onSubmit={handleRegisterOrder} className="space-y-4">
{/* Sección: Datos del Cliente */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">1. Datos del Cliente</span>
<div className="space-y-1.5">
<Label htmlFor="client-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</Label>
<Input
id="client-name"
type="text"
required
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Edgar Ortiz"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="client-dni" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">DNI / Identificación</Label>
<Input
id="client-dni"
type="text"
required
value={clientDni}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="client-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono</Label>
<Input
id="client-phone"
type="text"
required
value={clientPhone}
onChange={(e) => setClientPhone(e.target.value)}
placeholder="+54 381 1234567"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="client-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email</Label>
<Input
id="client-email"
type="email"
required
value={clientEmail}
onChange={(e) => setClientEmail(e.target.value)}
placeholder="edgar@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<Separator className="border-border/60" />
{/* Sección: Datos del Equipo */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">2. Datos del Equipo</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="device-type" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Tipo de Dispositivo</Label>
<select
id="device-type"
value={deviceType}
onChange={(e) => 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"
>
<option value="Notebook">Notebook</option>
<option value="PC Escritorio">PC Escritorio</option>
<option value="Consola">Consola</option>
<option value="Celular">Celular</option>
<option value="Otro">Otro</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-model" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Marca y Modelo</Label>
<Input
id="device-model"
type="text"
required
value={deviceModel}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="device-accessories" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Accesorios Recibidos</Label>
<Input
id="device-accessories"
type="text"
value={accessories}
onChange={(e) => setAccessories(e.target.value)}
placeholder="Ej. Cargador original, funda protectora"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-cosmetic" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Estado Cosmético</Label>
<Input
id="device-cosmetic"
type="text"
value={cosmeticCondition}
onChange={(e) => setCosmeticCondition(e.target.value)}
placeholder="Ej. Rayón en tapa superior, marcas de uso"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-issue" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Falla Reportada</Label>
<Textarea
id="device-issue"
required
rows={3}
value={issue}
onChange={(e) => 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"
/>
</div>
</div>
{/* Checkbox de Recepción */}
<div className="flex items-start space-x-2 pt-2">
<Checkbox
id="terms"
checked={acceptTerms}
onCheckedChange={(checked) => setAcceptTerms(checked)}
className="border-border focus-visible:ring-primary"
/>
<div className="grid gap-1 leading-none">
<Label
htmlFor="terms"
className="text-[10px] text-muted-foreground font-light leading-tight select-none cursor-pointer"
>
Confirmo la recepción física del equipo y autorizo la apertura del dispositivo para su diagnóstico.
</Label>
</div>
</div>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 rounded-md transition-all select-none cursor-pointer mt-2"
>
Registrar Ingreso
</Button>
</form>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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 = `
<html>
<head>
<title>Ticket de Venta #${saleId.substring(0, 8)}</title>
<style>
@page {
margin: 0;
}
body {
font-family: 'Courier New', Courier, monospace;
width: 260px;
margin: 0 auto;
padding: 15px 5px;
font-size: 11px;
color: #000;
line-height: 1.3;
}
.text-center { text-align: center; }
.text-right { text-align: right; }
.bold { font-weight: bold; }
.divider { border-top: 1px dashed #000; margin: 8px 0; }
.header-title { font-size: 15px; font-weight: bold; margin-bottom: 2px; text-transform: uppercase; }
.item-row { display: flex; justify-content: space-between; margin-bottom: 5px; align-items: flex-start; }
.item-name { flex: 1; padding-right: 8px; word-break: break-word; }
.item-qty { width: 30px; text-align: center; font-weight: bold; }
.item-price { width: 75px; text-align: right; font-weight: bold; }
.footer { margin-top: 15px; font-size: 10px; }
</style>
</head>
<body>
<div class="text-center header-title">REPAIRIT</div>
<div class="text-center bold">TICKET DE VENTA</div>
<div class="text-center bold">${activeVenue?.name ? activeVenue.name.toUpperCase() : "SUCURSAL CENTRAL"}</div>
<div class="divider"></div>
<div>Fecha: ${ticketDate.toLocaleString("es-AR")}</div>
<div>Ticket: ${saleId.substring(0, 8).toUpperCase()}</div>
<div class="divider"></div>
<div class="bold" style="margin-bottom: 6px;">ITEMS:</div>
${soldItems.map(item => `
<div class="item-row">
<span class="item-name">${item.name}</span>
<span class="item-qty">x${item.quantity}</span>
<span class="item-price">$${(item.price * item.quantity).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
`).join("")}
<div class="divider"></div>
<div style="margin-bottom: 4px;">Medio de Pago: ${methodUsed.toUpperCase()}</div>
<div class="item-row bold" style="font-size: 13px; margin-top: 6px; border-top: 1px dashed #000; padding-top: 4px;">
<span>TOTAL:</span>
<span class="text-right">$${saleTotal.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div class="divider"></div>
<div class="text-center footer">
¡Gracias por su compra!
</div>
<script>
window.onload = function() {
window.print();
setTimeout(function() { window.close(); }, 500);
};
</script>
</body>
</html>
`;
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 (
<div className="flex h-[65vh] items-center justify-center bg-background/50 text-foreground">
<div className="text-center space-y-4">
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto shadow-lg shadow-primary/20" />
<p className="text-xs text-muted-foreground font-medium tracking-wide">Cargando módulo de Caja...</p>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="space-y-1">
<h2 className="font-outfit text-2xl font-black tracking-tight text-foreground flex items-center gap-2.5">
<ShoppingCart className="w-6 h-6 text-primary shrink-0" />
<span>Caja</span>
</h2>
<p className="text-xs text-muted-foreground font-light">
Vende insumos del inventario rápidamente y revisa el registro histórico de caja.
</p>
</div>
{/* Tab Toggle buttons */}
<div className="flex items-center bg-card border border-border rounded-lg p-1 self-start md:self-auto shadow-sm select-none">
<button
onClick={() => 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"
}`}
>
<ShoppingCart className="w-3.5 h-3.5" />
Nueva Venta
</button>
<button
onClick={() => 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"
}`}
>
<History className="w-4 h-4" />
Historial de Ventas
</button>
</div>
</div>
{activeTab === "venta" ? (
/* VISTA DE NUEVA VENTA (POS) */
<div className="space-y-6">
<div className="flex items-center gap-2.5 bg-emerald-500/10 border border-emerald-500/20 rounded-full px-4 py-1.5 text-[11px] text-emerald-400 font-medium select-none self-start sm:self-auto max-w-max shadow-sm">
<Scan className="w-4 h-4 animate-pulse text-emerald-400" />
<span>Escáner de Código Activo <span className="font-mono text-[10px] text-emerald-500 font-bold">(Lectura Global)</span></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Columna Izquierda: Catálogo e Insumos */}
<div className="lg:col-span-2 space-y-4">
<Card className="bg-card/30 border-border/70 shadow-xl backdrop-blur-md">
<CardHeader className="pb-4">
<div className="relative w-full flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3.5 pointer-events-none" />
<Input
ref={searchInputRef}
id="search-input"
type="text"
placeholder="Buscar producto por nombre o escanear código de barras... [F2]"
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</CardHeader>
<CardContent className="p-0">
{filteredItems.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-xs font-light space-y-2">
<Sparkles className="w-8 h-8 opacity-20 mx-auto text-primary" />
<p>No se encontraron insumos disponibles en el inventario.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3.5 max-h-[calc(100vh-280px)] overflow-y-auto">
{filteredItems.map((item) => {
const isOutOfStock = item.quantity <= 0;
return (
<div
key={item._id}
onClick={() => !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"
}`}
>
<div className="space-y-1.5 pr-2">
<span className="text-xs font-bold text-foreground block leading-tight">{item.name}</span>
<span className="text-[10px] text-muted-foreground font-mono bg-muted/30 px-2 py-0.5 rounded-md border border-border/50 inline-block">{item.code}</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] text-muted-foreground font-light">Stock:</span>
<Badge
variant={isOutOfStock ? "destructive" : item.quantity < item.minQuantity ? "warning" : "secondary"}
className="text-[9px] px-1.5 py-0 font-bold"
>
{item.quantity} un
</Badge>
</div>
</div>
<div className="text-right">
<span className="text-sm font-black font-outfit text-primary block">
${item.price?.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
<span className="text-[9px] text-muted-foreground block font-light">Agregar</span>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
{/* Columna Derecha: Carrito de Compras activo */}
<div className="space-y-4">
<Card className="bg-card/30 border-border/80 shadow-2xl flex flex-col backdrop-blur-md">
<CardHeader className="border-b border-border/60 pb-3.5">
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center justify-between">
<span className="flex items-center gap-2">
<ShoppingCart className="w-4.5 h-4.5 text-primary" />
Detalle de Venta
</span>
{cart.length > 0 && (
<Badge variant="secondary" className="text-[10px] font-bold">
{cart.reduce((a, b) => a + b.quantity, 0)} ítems
</Badge>
)}
</CardTitle>
<CardDescription className="text-[11px] font-light">
Resumen de productos seleccionados para cobro
</CardDescription>
</CardHeader>
<CardContent className="p-4 flex-grow flex flex-col justify-between space-y-4">
{/* Items del Carrito */}
<div className="flex-grow overflow-y-auto max-h-[160px] space-y-2 pr-1">
{cart.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center text-muted-foreground space-y-2 py-12">
<ShoppingCart className="w-10 h-10 opacity-20 text-muted-foreground" />
<p className="text-xs font-medium">El carrito está vacío</p>
<p className="text-[10px] text-muted-foreground/60">Haz clic en productos del catálogo o escanéalos con el lector.</p>
</div>
) : (
cart.map((item) => (
<div key={item.id} className="flex justify-between items-center bg-muted/20 p-2.5 rounded-xl border border-border/50 text-xs">
<div className="space-y-0.5 max-w-[150px]">
<span className="font-bold text-foreground block truncate leading-tight">{item.name}</span>
<span className="text-[10px] text-primary font-mono font-semibold">${item.price.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border border-border/80 rounded-lg bg-background/90 shadow-sm">
<button
onClick={() => updateCartQty(item.id, -1)}
className="h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
<Minus className="w-3 h-3" />
</button>
<span className="w-7 text-center font-bold text-foreground font-mono text-[11px]">{item.quantity}</span>
<button
onClick={() => updateCartQty(item.id, 1)}
className="h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
>
<Plus className="w-3 h-3" />
</button>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => removeFromCart(item.id)}
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10 rounded-full cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
))
)}
</div>
{/* Totalizador y Métodos de Pago */}
<div className="border-t border-border/60 pt-3.5 space-y-3.5">
<div className="flex justify-between items-baseline bg-primary/5 p-3 rounded-xl border border-primary/20">
<span className="text-xs font-bold tracking-wider text-muted-foreground uppercase">TOTAL:</span>
<span className="text-2xl font-black font-outfit text-primary font-mono">
${total.toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
{/* Métodos de Pago */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Medio de Pago [F4]</span>
<div className="grid grid-cols-2 gap-2 text-xs">
<button
type="button"
onClick={() => 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"
}`}
>
<DollarSign className="w-4 h-4 shrink-0 text-primary" />
<span>Efectivo</span>
</button>
<button
type="button"
onClick={() => 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"
}`}
>
<Smartphone className="w-4 h-4 shrink-0 text-primary" />
<span>Transf.</span>
</button>
<button
type="button"
onClick={() => 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"
}`}
>
<CreditCard className="w-4 h-4 shrink-0 text-primary" />
<span>Débito</span>
</button>
<button
type="button"
onClick={() => 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"
}`}
>
<Wallet className="w-4 h-4 shrink-0 text-primary" />
<span>Crédito</span>
</button>
</div>
</div>
{/* Calculadora de Vuelto (Solo Efectivo) */}
{paymentMethod === "efectivo" && total > 0 && (
<div className="bg-muted/25 border border-border/60 rounded-xl p-3 space-y-2 animate-in slide-in-from-top-1 duration-150">
<Label htmlFor="cash-received" className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground flex justify-between">
<span>Dinero Recibido</span>
{cashReceived && Number(cashReceived) === total && (
<span className="text-emerald-400 font-bold">Pago Justo</span>
)}
</Label>
<div className="flex gap-2">
<div className="relative flex-grow">
<span className="absolute left-3 top-2.5 text-xs font-mono text-muted-foreground">$</span>
<Input
id="cash-received"
type="number"
placeholder="0,00"
value={cashReceived}
onChange={(e) => 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"
/>
</div>
<Button
type="button"
variant="secondary"
onClick={() => 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
</Button>
</div>
{cashReceived && Number(cashReceived) > total && (
<div className="flex justify-between items-baseline pt-1">
<span className="text-[10px] text-muted-foreground font-light">Vuelto a entregar:</span>
<span className="text-sm font-black font-outfit text-emerald-400 font-mono">
${(Number(cashReceived) - total).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
)}
{cashReceived && Number(cashReceived) < total && (
<div className="text-[10px] text-destructive font-semibold">
Faltan: ${(total - Number(cashReceived)).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
</div>
)}
{/* Confirmar Cobro */}
<Button
onClick={handleCheckout}
disabled={submitting || cart.length === 0 || (paymentMethod === "efectivo" && cashReceived && Number(cashReceived) < total)}
className="w-full bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-3.5 rounded-xl cursor-pointer shadow-lg shadow-primary/20 transition-all flex items-center justify-center gap-2 h-11"
>
<Printer className="w-4 h-4" />
{submitting ? "Confirmando..." : "Confirmar y Emitir Ticket [F8]"}
</Button>
{/* Atajos de teclado informativos */}
<div className="bg-muted/15 border border-border/40 rounded-xl p-3 text-[9px] text-muted-foreground space-y-1.5">
<span className="font-bold uppercase tracking-wider flex items-center gap-1.5 text-foreground/80">
<Keyboard className="w-3.5 h-3.5 text-primary" />
Atajos de Teclado Rápidos
</span>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div className="flex justify-between"><span>[F2] Buscar</span> <span className="font-mono text-foreground font-bold">[F2]</span></div>
<div className="flex justify-between"><span>[F4] Medio Pago</span> <span className="font-mono text-foreground font-bold">[F4]</span></div>
<div className="flex justify-between"><span>[F9 / +] Sumar Cant.</span> <span className="font-mono text-foreground font-bold">[F9]</span></div>
<div className="flex justify-between"><span>[F3 / -] Restar Cant.</span> <span className="font-mono text-foreground font-bold">[F3]</span></div>
<div className="flex justify-between col-span-2 pt-1 border-t border-border/40 mt-0.5">
<span>[F8] Confirmar y Cobrar</span> <span className="font-mono text-primary font-bold">[F8]</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
) : (
/* VISTA DE HISTORIAL DE VENTAS */
<div className="space-y-4">
{/* Barra de Filtros del Historial */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 bg-card/30 backdrop-blur-md border border-border/70 p-4.5 rounded-2xl shadow-sm">
<div className="space-y-0.5">
<h3 className="text-sm font-bold text-foreground flex items-center gap-2">
<History className="w-4 h-4 text-primary" />
Registro de Caja Diaria
</h3>
<p className="text-[11px] text-muted-foreground font-light">Ventas y egresos de stock realizados en la plataforma.</p>
</div>
{/* Dropdown de sucursales (Solo si tiene Multi-Taller Pro y es Admin) */}
{isMultiTaller && isAdmin && (
<div className="flex items-center gap-2">
<Store className="w-4 h-4 text-muted-foreground shrink-0" />
<select
value={selectedVenueFilter}
onChange={(e) => 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"
>
<option value="todas">Todas las Sucursales</option>
{venues.map((v) => (
<option key={v._id} value={v._id}>
{v.name}
</option>
))}
</select>
</div>
)}
</div>
{/* Tabla de Historial */}
<Card className="bg-card/30 backdrop-blur-md border-border/70 shadow-md rounded-2xl">
<CardContent className="p-0">
{salesLoading ? (
<div className="text-center py-16 text-xs font-light text-muted-foreground">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
Cargando transacciones de caja...
</div>
) : sales.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-xs font-light space-y-1">
<AlertCircle className="w-8 h-8 opacity-30 mx-auto text-muted-foreground mb-1" />
<p>No se encontraron transacciones en esta sucursal.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 bg-muted/15 text-muted-foreground font-medium uppercase text-[10px] tracking-wider">
<th className="py-4 px-5">Código / Ticket</th>
<th className="py-4 px-5">Fecha y Hora</th>
<th className="py-4 px-5">Sucursal</th>
<th className="py-4 px-5">Cajero</th>
<th className="py-4 px-5 max-w-xs">Artículos Vendidos</th>
<th className="py-4 px-5">Método de Pago</th>
<th className="py-4 px-5 text-right">Monto Cobrado</th>
<th className="py-4 px-5 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{sales.map((sale) => (
<tr key={sale._id} className="hover:bg-muted/10 transition-colors">
<td className="py-4 px-5 font-bold font-mono text-[11px] text-foreground">
#{sale._id.substring(0, 8).toUpperCase()}
</td>
<td className="py-4 px-5 text-muted-foreground font-mono text-[10px]">
{new Date(sale.created_at).toLocaleString("es-AR")}
</td>
<td className="py-4 px-5">
<div className="flex items-center gap-1.5">
<Store className="w-3.5 h-3.5 text-primary/70 shrink-0" />
<span className="text-foreground font-medium">{sale.venueName}</span>
</div>
</td>
<td className="py-4 px-5 text-muted-foreground">{sale.cajero}</td>
<td className="py-4 px-5 max-w-xs">
<span className="text-foreground font-light leading-tight">
{sale.items?.map((i) => `${i.name} (x${i.quantity})`).join(", ")}
</span>
</td>
<td className="py-4 px-5 uppercase">
<Badge variant="secondary" className="text-[9px] font-bold px-2 py-0.5">
{sale.payment_method}
</Badge>
</td>
<td className="py-4 px-5 text-right font-mono font-bold text-foreground text-sm">
${Number(sale.total).toLocaleString("es-AR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
<td className="py-4 px-5 text-right">
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Printer className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2">
<Store className="w-8 h-8 text-primary" />
Perfil de Taller (Venue)
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<Store className="w-7 h-7 text-primary shrink-0" />
<span>Perfil de Taller</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Gestión del perfil del local y estado de la suscripción de la sucursal.
@ -38,15 +154,15 @@ export default function ProfilePage() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Columna Izquierda (2/3): Datos principales del taller */}
<div className="lg:col-span-2 space-y-6">
{/* Card 1: Datos de la Sucursal */}
{/* Card 1: Datos de la Sucursal Activa */}
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm">
<CardHeader className="p-0 pb-5 border-b border-border/50">
<CardTitle className="font-outfit text-base font-bold text-foreground flex items-center gap-2 border-0 pb-0">
Datos del Establecimiento
Datos del Establecimiento Activo
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Información de contacto que se mostrará en los recibos y el seguimiento del cliente.
@ -57,24 +173,24 @@ export default function ProfilePage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="venue-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre del Taller</Label>
<Input
<Input
id="venue-name"
type="text"
required
value={venueName}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="venue-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email de Contacto</Label>
<Input
<Input
id="venue-email"
type="email"
required
value={email}
onChange={(e) => 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() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="venue-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono Comercial</Label>
<Input
<Input
id="venue-phone"
type="text"
required
value={phone}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="venue-address" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Dirección Física</Label>
<Input
<Input
id="venue-address"
type="text"
required
value={address}
onChange={(e) => 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() {
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
<Button
type="submit"
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-6 rounded-md transition-all cursor-pointer flex items-center gap-2 select-none"
>
<Save className="w-4 h-4" />
@ -121,21 +237,126 @@ export default function ProfilePage() {
</CardContent>
</Card>
{/* Card de Sucursales de la Organización */}
<Card className="bg-card/30 border-border p-6 shadow-xl backdrop-blur-sm space-y-4">
<div className="border-b border-border/50 pb-3 flex items-center justify-between">
<div>
<CardTitle className="font-outfit text-base font-bold text-foreground">
Sucursales de la Organización
</CardTitle>
<CardDescription className="text-xs text-muted-foreground mt-1">
Gestioná o alterná las sedes de servicio de tu negocio.
</CardDescription>
</div>
{user.subscriptionPlan === "Multi-Taller Pro" && (
<Button
onClick={handleOpenCreateModal}
className="bg-primary/10 border border-primary/20 hover:bg-primary/20 text-primary text-[10px] font-extrabold uppercase py-1.5 px-3 rounded flex items-center gap-1 shrink-0 cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Agregar Sucursal
</Button>
)}
</div>
<CardContent className="p-0">
{user.subscriptionPlan !== "Multi-Taller Pro" ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center space-y-3 bg-muted/5 rounded-lg border border-dashed border-border/50">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Store className="w-5 h-5" />
</div>
<div className="space-y-2 flex flex-col items-center">
<h4 className="font-outfit text-sm font-bold text-foreground">Gestión de Sucursales Limitada</h4>
<p className="text-xs text-muted-foreground max-w-sm font-light leading-relaxed mb-1">
Mejore su plan al nivel <span className="text-primary font-semibold">Multi-Taller Pro</span> para poder gestionar y cambiar entre múltiples sedes de su negocio.
</p>
<Button
onClick={() => 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
</Button>
</div>
</div>
) : venues.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{venues.map((v) => {
const isActive = v.name === venueName;
return (
<div
key={v._id}
className={`p-4 rounded-lg border text-xs space-y-2 flex flex-col justify-between ${
isActive
? "bg-primary/5 border-primary/45 shadow-sm"
: "bg-card/20 border-border/50"
}`}
>
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<span className="font-bold text-foreground truncate text-sm">{v.name}</span>
{isActive && (
<Badge className="bg-primary/15 text-primary border border-primary/30 text-[8px] font-bold uppercase py-0.5 px-1 shrink-0">
Activa
</Badge>
)}
</div>
<p className="text-muted-foreground font-light leading-tight">{v.address}</p>
<p className="text-muted-foreground font-mono text-[10px]">Tel: {v.phone}</p>
<p className="text-muted-foreground font-mono text-[10px] truncate">{v.email}</p>
</div>
<div className="flex items-center justify-between border-t border-border/25 pt-2.5 mt-1 gap-2">
<Button
onClick={() => 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"
>
<Edit className="w-3.5 h-3.5" />
Editar
</Button>
{!isActive && (
<Button
onClick={() => 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"
>
<RefreshCw className="w-3 h-3" />
Activar
</Button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-6 text-xs text-muted-foreground font-light">
No hay sucursales adicionales registradas.
</div>
)}
</CardContent>
</Card>
</div>
{/* Columna Derecha (1/3): Suscripción */}
<div className="space-y-6">
{/* Card 2: Suscripción */}
<Card className="bg-card/25 border-border p-6 shadow-sm space-y-4">
<h3 className="font-outfit text-base font-bold text-foreground border-b border-border pb-3 flex items-center gap-2">
Suscripción
</h3>
<div className="space-y-3.5 text-xs font-light">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Plan Licenciado</span>
<Badge className="bg-primary hover:bg-primary text-primary-foreground text-[10px] font-extrabold uppercase">Multi-Taller Pro</Badge>
<Badge className="bg-primary hover:bg-primary text-primary-foreground text-[10px] font-extrabold uppercase">
{user.subscriptionPlan || "Sin Plan"}
</Badge>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
<span className="text-muted-foreground">Vencimiento</span>
@ -143,17 +364,246 @@ export default function ProfilePage() {
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
<span className="text-muted-foreground">Estado del Pago</span>
<span className="text-emerald-400 font-bold flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 fill-emerald-400/10" />
Al Día
<span className={user.subscriptionStatus === "activo" ? "text-emerald-400 font-bold flex items-center gap-1.5" : "text-destructive font-bold flex items-center gap-1.5"}>
<CheckCircle className={user.subscriptionStatus === "activo" ? "w-3.5 h-3.5 fill-emerald-400/10" : "w-3.5 h-3.5 fill-destructive/10"} />
{user.subscriptionStatus === "activo" ? "Al Día" : "Inactivo"}
</span>
</div>
</div>
<div className="pt-2 border-t border-border/30">
<Button
variant="outline"
size="sm"
onClick={() => setShowPlansModal(true)}
className="w-full text-[10px] font-bold uppercase border-border/80 hover:bg-card/50 transition-all cursor-pointer"
>
Ver / Cambiar Plan
</Button>
</div>
</Card>
</div>
</div>
{/* MODAL DE SUCURSAL (Creación / Edición) */}
{showModal && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6 space-y-4 animate-in fade-in-50 zoom-in-95 duration-150">
<div className="flex items-center justify-between border-b border-border/50 pb-3">
<h3 className="font-outfit text-base font-bold text-foreground">
{modalMode === "create" ? "Registrar Nueva Sucursal" : "Editar Detalles de Sucursal"}
</h3>
<Button
variant="ghost"
size="icon"
onClick={() => setShowModal(false)}
className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer"
>
<X className="w-4 h-4" />
</Button>
</div>
<form onSubmit={handleModalSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="modal-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre del Taller / Sucursal</Label>
<Input
id="modal-name"
type="text"
required
value={modalName}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="modal-address" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Dirección Física</Label>
<Input
id="modal-address"
type="text"
required
value={modalAddress}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="modal-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono de Contacto</Label>
<Input
id="modal-phone"
type="text"
required
value={modalPhone}
onChange={(e) => setModalPhone(e.target.value)}
placeholder="Ej. +54 381 4889900"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="modal-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email Comercial</Label>
<Input
id="modal-email"
type="email"
required
value={modalEmail}
onChange={(e) => setModalEmail(e.target.value)}
placeholder="Ej. yerbabuena@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border/40">
<Button
type="button"
variant="outline"
onClick={() => setShowModal(false)}
className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancelar
</Button>
<Button
type="submit"
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-5 rounded-md cursor-pointer flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
{modalMode === "create" ? "Crear Sucursal" : "Guardar Cambios"}
</Button>
</div>
</form>
</div>
</div>
)}
{/* MODAL DE PLANES (Comparador de Precios) */}
{showPlansModal && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-4xl w-full p-6 space-y-6 animate-in fade-in-50 zoom-in-95 duration-150 relative">
<Button
onClick={() => 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"
>
<X className="w-4 h-4" />
</Button>
<div className="text-center space-y-1.5">
<h3 className="font-outfit text-xl font-bold text-foreground">
Planes de Suscripción RepairIT
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto font-light">
Elija el plan que mejor se adapte al tamaño y necesidades de su taller de reparación técnica.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-2">
{/* Plan Básico */}
<div className={`border rounded-xl p-5 flex flex-col justify-between space-y-4 transition-all ${
user.subscriptionPlan === "Sin Plan" || user.subscriptionPlan === "Taller Básico"
? "border-primary/50 bg-primary/5 ring-1 ring-primary/20"
: "border-border/60 bg-card/25"
}`}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Taller Básico</span>
{(user.subscriptionPlan === "Sin Plan" || user.subscriptionPlan === "Taller Básico") && (
<Badge className="bg-primary/20 text-primary hover:bg-primary/20 text-[8px] font-bold uppercase border border-primary/30">Activo</Badge>
)}
</div>
<div>
<span className="font-outfit text-2xl font-black text-foreground">Gratis</span>
<span className="text-[10px] text-muted-foreground font-light"> / para siempre</span>
</div>
<ul className="text-[11px] text-muted-foreground font-light space-y-2 border-t border-border/40 pt-3">
<li className="flex items-center gap-2">✓ 1 Sucursal física</li>
<li className="flex items-center gap-2">✓ Hasta 100 órdenes / mes</li>
<li className="flex items-center gap-2">✓ Inventario y stock básico</li>
<li className="flex items-center gap-2">✓ Consulta de tracking pública</li>
</ul>
</div>
</div>
{/* Plan Premium */}
<div className={`border rounded-xl p-5 flex flex-col justify-between space-y-4 transition-all relative overflow-hidden ${
user.subscriptionPlan === "Taller Premium"
? "border-primary/50 bg-primary/5 ring-1 ring-primary/20"
: "border-border/60 bg-card/25"
}`}>
<div className="absolute top-0 right-0 bg-primary text-primary-foreground text-[8px] font-black uppercase py-0.5 px-3 rounded-bl-lg tracking-wider">Popular</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Taller Premium</span>
{user.subscriptionPlan === "Taller Premium" && (
<Badge className="bg-primary/20 text-primary hover:bg-primary/20 text-[8px] font-bold uppercase border border-primary/30">Activo</Badge>
)}
</div>
<div>
<span className="font-outfit text-2xl font-black text-foreground">$19</span>
<span className="text-[10px] text-muted-foreground font-light"> / mes</span>
</div>
<ul className="text-[11px] text-muted-foreground font-light space-y-2 border-t border-border/40 pt-3">
<li className="flex items-center gap-2 text-foreground font-medium">✓ 1 Sucursal física</li>
<li className="flex items-center gap-2">✓ Órdenes y equipos ilimitados</li>
<li className="flex items-center gap-2">✓ Inventario con alertas críticas</li>
<li className="flex items-center gap-2">✓ Dashboard métricas avanzado</li>
<li className="flex items-center gap-2">✓ Soporte prioritario 24/7</li>
</ul>
</div>
</div>
{/* Plan Multi-Taller Pro */}
<div className={`border rounded-xl p-5 flex flex-col justify-between space-y-4 transition-all ${
user.subscriptionPlan === "Multi-Taller Pro"
? "border-primary/50 bg-primary/5 ring-1 ring-primary/20"
: "border-border/60 bg-card/25"
}`}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Multi-Taller Pro</span>
{user.subscriptionPlan === "Multi-Taller Pro" && (
<Badge className="bg-primary/20 text-primary hover:bg-primary/20 text-[8px] font-bold uppercase border border-primary/30">Activo</Badge>
)}
</div>
<div>
<span className="font-outfit text-2xl font-black text-foreground">$39</span>
<span className="text-[10px] text-muted-foreground font-light"> / mes</span>
</div>
<ul className="text-[11px] text-muted-foreground font-light space-y-2 border-t border-border/40 pt-3">
<li className="flex items-center gap-2 text-primary font-bold">✓ Hasta 5 Sucursales</li>
<li className="flex items-center gap-2">✓ Selector rápido en barra superior</li>
<li className="flex items-center gap-2">✓ 4 cuentas Técnicos + 1 Admin</li>
<li className="flex items-center gap-2">✓ Órdenes e Inventario ilimitados</li>
<li className="flex items-center gap-2">✓ Reportes centralizados multisede</li>
</ul>
</div>
</div>
</div>
<div className="text-center pt-2 border-t border-border/40">
<Button
onClick={() => {
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
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="space-y-8 animate-fade-in">
<div className="space-y-2">
<Skeleton className="h-9 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<Skeleton className="h-96 w-full rounded-2xl" />
</div>
);
}
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2.5">
<Users className="w-7 h-7 text-primary shrink-0" />
<span>Gestión de Usuarios</span>
</h1>
<p className="text-xs text-muted-foreground font-light">
Administración centralizada de cuentas de técnicos y administradores de todos los talleres.
</p>
</div>
<Card className="bg-card/20 border-border p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border/50 pb-3">
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center gap-2 border-0 pb-0">
<Users className="w-4.5 h-4.5 text-muted-foreground" />
Directorio General de Personal
</CardTitle>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono font-bold">Total: {users.length}</span>
</div>
{/* Filtro rápido de búsqueda */}
<div className="flex gap-3 py-2 border-b border-border/30">
<div className="relative w-full sm:max-w-xs flex items-center">
<Search className="w-4 h-4 text-muted-foreground absolute left-3 pointer-events-none" />
<Input
type="text"
placeholder="Buscar por nombre o email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="bg-background/80 border-border text-xs w-full pl-9"
/>
</div>
</div>
{filteredUsers.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-3 px-2">Usuario</th>
<th className="py-3 px-2">Organización / Taller</th>
<th className="py-3 px-2">Sucursal / Venue</th>
<th className="py-3 px-2">Rol del Sistema</th>
<th className="py-3 px-2">Fecha Alta</th>
<th className="py-3 px-2 text-right">Acciones</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={item._id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
<td className="py-3.5 px-2">
<span className="font-medium text-foreground text-sm block">{item.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{item.email}</span>
</td>
<td className="py-3.5 px-2 font-medium text-foreground">{item.organizationId?.name || "Plataforma Central"}</td>
<td className="py-3.5 px-2 text-muted-foreground">{item.venueId?.name || "Taller Global"}</td>
<td className="py-3.5 px-2">
{isCurrentSuper ? (
<Badge variant={roleBadge} className="text-[9px] uppercase font-bold px-1.5 py-0.5 border">
{roleLabel}
</Badge>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border px-2 text-xs font-semibold rounded flex items-center gap-1">
<Badge variant={roleBadge} className="text-[9px] uppercase font-bold px-1.5 py-0.5 border">
{roleLabel}
</Badge>
<ChevronDown className="w-3 h-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card border-border text-foreground">
<DropdownMenuItem onClick={() => handleRoleChange(item._id, "tecnico")} className="text-xs transition-colors cursor-pointer flex items-center gap-1.5">
<UserCheck className="w-3.5 h-3.5 text-muted-foreground" />
Técnico
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRoleChange(item._id, "admin")} className="text-xs transition-colors cursor-pointer flex items-center gap-1.5">
<UserCog className="w-3.5 h-3.5 text-warning" />
Administrador
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</td>
<td className="py-3.5 px-2 text-muted-foreground font-mono">{new Date(item.createdAt).toLocaleDateString("es-AR")}</td>
<td className="py-3.5 px-2 text-right">
<div className="flex justify-end gap-1.5">
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Edit className="w-3.5 h-3.5" />
</Button>
{!isCurrentSuper && (
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="text-center py-12 text-muted-foreground text-xs font-light">
No se encontraron usuarios que coincidan con la búsqueda.
</div>
)}
</div>
</Card>
{/* MODAL DE EDICIÓN Y GESTIÓN (Para SuperAdmin) */}
{showManageModal && selectedUser && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6 space-y-4 animate-in fade-in-50 zoom-in-95 duration-150 relative">
<Button
onClick={() => 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"
>
<X className="w-4 h-4" />
</Button>
<div className="space-y-1">
<h3 className="font-outfit text-base font-bold text-foreground">
Gestionar Cuenta de Usuario
</h3>
<p className="text-xs text-muted-foreground font-light">
Modifique los accesos, rol y credenciales de la cuenta.
</p>
</div>
<form onSubmit={handleUpdateUser} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="manage-name" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Nombre Completo</Label>
<Input
id="manage-name"
type="text"
required
placeholder="Ej. Juan Pérez"
value={modalName}
onChange={(e) => setModalName(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="manage-email" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Email de Acceso</Label>
<Input
id="manage-email"
type="email"
required
placeholder="Ej. juan@taller.com"
value={modalEmail}
onChange={(e) => setModalEmail(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="manage-role" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Rol del Sistema</Label>
<select
id="manage-role"
value={modalRole}
onChange={(e) => 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"
>
<option value="tecnico">Técnico (Inquilino)</option>
<option value="admin">Administrador (Taller)</option>
<option value="superadmin">Super Administrador (Plataforma)</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="manage-password" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground flex justify-between">
<span>Nueva Contraseña</span>
<span className="text-[9px] text-muted-foreground font-light lowercase">dejar vacío para no cambiar</span>
</Label>
<div className="relative">
<Input
id="manage-password"
type={showPassword ? "text" : "password"}
placeholder="Contraseña nueva (mín. 6 caracteres)"
value={modalPassword}
onChange={(e) => setModalPassword(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex justify-between items-center pt-2 border-t border-border/40">
{selectedUser.role !== "superadmin" ? (
<Button
type="button"
variant="ghost"
onClick={() => handleDeleteUser(selectedUser._id, selectedUser.name)}
className="text-xs text-destructive hover:text-destructive hover:bg-destructive/10 cursor-pointer flex items-center gap-1"
>
<Trash2 className="w-3.5 h-3.5" />
Eliminar
</Button>
) : (
<div />
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setShowManageModal(false)}
className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancelar
</Button>
<Button
type="submit"
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-5 rounded-md cursor-pointer flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
{submitting ? "Guardando..." : "Guardar Cambios"}
</Button>
</div>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="flex h-[60vh] items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-xs text-muted-foreground font-light">Cargando cuentas de acceso...</p>
</div>
</div>
);
}
// Vista cuando no tienen el plan correcto
if (!isMultiTaller) {
return (
<div className="p-6 space-y-6 max-w-4xl">
<div className="space-y-1">
<h2 className="font-outfit text-2xl font-black tracking-tight text-foreground flex items-center gap-2.5">
<KeyRound className="w-6 h-6 text-primary shrink-0" />
<span>Cuentas de Acceso de Sucursal</span>
</h2>
<p className="text-xs text-muted-foreground font-light">
Administración de cuentas para las PCs de cada una de sus sedes.
</p>
</div>
<Card className="bg-card/25 border-border border-dashed p-10 text-center flex flex-col items-center justify-center space-y-4 shadow-xl">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Shield className="w-7 h-7" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-outfit text-lg font-bold text-foreground">Función Exclusiva de Multi-Taller Pro</h3>
<p className="text-xs text-muted-foreground font-light leading-relaxed">
La creación de usuarios de acceso independientes para las computadoras de cada sucursal requiere una licencia activa del plan <span className="text-primary font-bold">Multi-Taller Pro</span>.
</p>
<p className="text-xs text-muted-foreground font-light">
Mejore su plan en la pestaña de perfil para habilitar esta funcionalidad.
</p>
</div>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6 max-w-6xl">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<h2 className="font-outfit text-2xl font-black tracking-tight text-foreground flex items-center gap-2.5">
<KeyRound className="w-6 h-6 text-primary shrink-0" />
<span>Cuentas de Acceso de Sucursal</span>
</h2>
<p className="text-xs text-muted-foreground font-light">
Configure credenciales de PC independientes para limitar el acceso del personal de cada sede.
</p>
</div>
<Button
onClick={() => 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"
>
<Plus className="w-4 h-4" />
Crear Cuenta de Acceso
</Button>
</div>
{/* Info Warning */}
<div className="bg-primary/5 border border-primary/20 rounded-lg p-4 flex gap-3 text-xs text-muted-foreground font-light leading-relaxed">
<Info className="w-4 h-4 text-primary shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-foreground font-medium">¿Cómo funcionan estas cuentas?</p>
<p>
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.
</p>
</div>
</div>
{/* Table Card */}
<Card className="bg-card/30 border-border shadow-xl backdrop-blur-sm">
<CardContent className="p-0">
{accounts.length === 0 ? (
<div className="text-center py-12 space-y-2">
<div className="w-12 h-12 rounded-full bg-muted/20 flex items-center justify-center mx-auto text-muted-foreground">
<Store className="w-6 h-6" />
</div>
<p className="text-xs font-light text-muted-foreground">No hay cuentas de acceso adicionales registradas.</p>
<p className="text-[10px] text-muted-foreground/60">Presione el botón superior para crear la primera cuenta.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 bg-muted/15 text-muted-foreground font-medium uppercase text-[10px] tracking-wider">
<th className="py-4 px-5">Nombre Identificador</th>
<th className="py-4 px-5">Email de Acceso</th>
<th className="py-4 px-5">Sucursal Asociada</th>
<th className="py-4 px-5">Rol Asignado</th>
<th className="py-4 px-5">Fecha Alta</th>
<th className="py-4 px-5 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{accounts.map((acc) => (
<tr key={acc._id} className="hover:bg-muted/10 transition-colors">
<td className="py-4 px-5 font-bold text-foreground">{acc.name}</td>
<td className="py-4 px-5 font-mono text-muted-foreground">{acc.email}</td>
<td className="py-4 px-5">
<div className="flex items-center gap-1.5">
<Store className="w-3.5 h-3.5 text-primary/70" />
<span className="text-foreground">{acc.venueId?.name || "Desconocida"}</span>
</div>
</td>
<td className="py-4 px-5">
<Badge variant="secondary" className="text-[9px] uppercase font-bold px-1.5 py-0.5">Técnico</Badge>
</td>
<td className="py-4 px-5 text-muted-foreground font-mono text-[10px]">
{new Date(acc.created_at).toLocaleDateString("es-AR")}
</td>
<td className="py-4 px-5 text-right">
<Button
variant="ghost"
size="icon"
onClick={() => 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"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Modal Creación */}
{showModal && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-border rounded-xl shadow-2xl max-w-md w-full p-6 space-y-4 animate-in fade-in-50 zoom-in-95 duration-150 relative">
<Button
onClick={() => 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"
>
<X className="w-4 h-4" />
</Button>
<div className="space-y-1">
<h3 className="font-outfit text-base font-bold text-foreground">
Registrar Cuenta de Acceso
</h3>
<p className="text-xs text-muted-foreground font-light">
Crea credenciales específicas para una de las sucursales de tu organización.
</p>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Nombre Identificador</Label>
<Input
id="name"
type="text"
required
placeholder="Ej. Técnico Yerba Buena"
value={name}
onChange={(e) => setName(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Email de Acceso</Label>
<Input
id="email"
type="email"
required
placeholder="Ej. yerbabuena@repairit.cloud"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Contraseña</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
required
placeholder="Contraseña del equipo"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground cursor-pointer"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="venue" className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">Sucursal Asociada</Label>
<select
id="venue"
value={venueId}
onChange={(e) => 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) => (
<option key={v._id} value={v._id}>
{v.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-border/40">
<Button
type="button"
variant="outline"
onClick={() => setShowModal(false)}
className="text-xs border-border/80 text-muted-foreground hover:text-foreground cursor-pointer"
>
Cancelar
</Button>
<Button
type="submit"
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-5 rounded-md cursor-pointer flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
{submitting ? "Creando..." : "Crear Acceso"}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -102,7 +102,7 @@ export default function LandingPage() {
{/* Ejemplo rápido para testing */}
<div className="text-xs text-muted-foreground font-medium">
¿Querés probar? Ingresá <span onClick={() => setTrackingId("demo-id")} className="text-primary hover:underline cursor-pointer font-bold">demo-id</span> para ver una reparación simulada.
¿Querés probar? Ingresá <span onClick={() => { setTrackingId("demo-id"); navigate("/seguimiento/demo-id"); }} className="text-primary hover:underline cursor-pointer font-bold">demo-id</span> para ver una reparación simulada.
</div>
</div>

View File

@ -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 (
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
<div className="border-b border-border pb-6">
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-96" />
</div>
</div>
{/* Skeleton de la Línea de Tiempo */}
<div className="flex-1 max-w-4xl w-full mx-auto px-6 py-12 space-y-6 animate-fade-in">
<Skeleton className="h-6 w-28" />
<Skeleton className="h-28 w-full rounded-2xl" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<Skeleton className="h-44 w-full rounded-2xl" />
<Skeleton className="h-44 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
<div className="space-y-8">
<Skeleton className="h-[380px] w-full rounded-2xl" />
</div>
<Skeleton className="h-24 w-full rounded-2xl" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton className="md:col-span-2 h-64 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
</div>
);
}
return (
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
{/* Cabecera del Seguimiento */}
<div className="border-b border-border pb-6">
<div className="space-y-1.5">
<div className="flex items-center gap-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider flex items-center gap-1">
&larr; Volver
</Link>
<Separator orientation="vertical" className="h-3" />
<span className="text-primary text-xs font-bold uppercase tracking-wider">Orden de Servicio</span>
</div>
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Seguimiento de Equipo: <span className="text-muted-foreground">{order.nanoId}</span>
</h1>
if (!order) {
return (
<div className="flex-1 max-w-md w-full mx-auto px-6 py-20 text-center space-y-5 animate-fade-in">
<div className="w-12 h-12 rounded-2xl bg-destructive/10 border border-destructive/20 flex items-center justify-center mx-auto text-destructive">
<AlertCircle className="w-6 h-6" />
</div>
<div className="space-y-1">
<h2 className="font-outfit text-lg font-bold text-foreground">Orden no encontrada</h2>
<p className="text-xs text-muted-foreground font-light">
No se encontró información para el código <span className="font-mono font-bold text-foreground">"{id}"</span>.
</p>
</div>
<Button asChild variant="outline" className="text-xs font-bold rounded-xl px-5 h-9">
<Link to="/">Volver al Inicio</Link>
</Button>
</div>
);
}
const clientName = order.clientId?.name || "Cliente";
return (
<div className="flex-1 max-w-4xl w-full mx-auto px-5 py-8 space-y-6 animate-fade-in">
{/* Botón de retorno simple */}
<div>
<Link to="/" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors font-medium">
<ArrowLeft className="w-3.5 h-3.5" /> Volver al Inicio
</Link>
</div>
{/* LINEA DE TIEMPO DEL ESTADO CON PROGRESS BAR DE SHADCN */}
<Card className="bg-card/20 border-border p-8 rounded-2xl relative overflow-hidden shadow-xl">
<div className="relative flex flex-col gap-8 w-full">
<div className="relative flex flex-col md:flex-row justify-between items-start md:items-center gap-8 md:gap-0 w-full">
{/* Componente Progress nativo de shadcn - Centrado al píxel */}
<div className="absolute left-[calc(100%/12)] right-[calc(100%/12)] top-[12px] -translate-y-1/2 h-1 pointer-events-none z-0 hidden md:block">
<Progress value={progressPercent} className="h-1 bg-border [&>div]:bg-primary" />
{/* Tarjeta Principal de Resumen del Dispositivo */}
<Card className="bg-card/40 border-border/70 p-6 rounded-2xl shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-border/50 pb-5">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shrink-0">
<Laptop className="w-5 h-5" />
</div>
<div>
<span className="text-[10px] text-muted-foreground uppercase font-mono tracking-wider font-semibold">Orden #{order.trackingCode}</span>
<h1 className="font-outfit text-xl font-bold text-foreground">
{order.deviceType} {order.deviceModel}
</h1>
</div>
</div>
<Badge variant="outline" className="text-xs font-bold px-3 py-1 bg-primary/10 text-primary border-primary/20 uppercase tracking-wider self-start sm:self-auto rounded-lg">
{STEPS[currentStepIndex]?.label || order.status}
</Badge>
</div>
{/* Línea de Progreso Limpia */}
<div className="pt-5 space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground font-light">Progreso del servicio</span>
<span className="font-mono font-bold text-primary">{Math.round(progressPercent)}%</span>
</div>
<Progress value={progressPercent} className="h-2 bg-muted [&>div]:bg-primary rounded-full" />
{/* Pasos limpios horizontal */}
<div className="hidden sm:flex justify-between pt-1 text-[11px]">
{STEPS.map((step, idx) => {
const isCompleted = idx <= currentStepIndex;
const isActive = idx === currentStepIndex;
const isDone = idx <= currentStepIndex;
return (
<div
key={step.key}
className="relative z-10 flex items-start md:flex-col md:items-center gap-4 md:gap-2.5 text-left md:text-center flex-1 w-full md:w-auto"
>
{/* Línea vertical de conexión en móvil */}
{idx < STEPS.length - 1 && (
<div className="absolute left-[11px] top-[24px] bottom-[-32px] w-[2px] bg-border z-0 md:hidden">
<div className={`w-full bg-primary transition-all duration-300 ${idx < currentStepIndex ? "h-full" : "h-0"}`} />
</div>
)}
{/* Círculo indicador */}
<div
className={`w-6 h-6 rounded-full flex items-center justify-center border-2 transition-all duration-300 shrink-0 ${isActive
? "bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/30 scale-105"
: isCompleted
? "bg-primary border-primary text-primary-foreground"
: "bg-background border-border text-muted-foreground"
}`}
>
{isActive ? (
<span className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
) : isCompleted ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
) : null}
</div>
{/* Etiquetas */}
<div className="space-y-0.5 pt-0.5 md:pt-0">
<span className={`text-xs font-bold block ${isCompleted ? "text-foreground" : "text-muted-foreground"}`}>
{step.label}
</span>
<span className="text-[10px] text-muted-foreground font-light block leading-tight md:max-w-[125px] md:mx-auto">
{step.desc}
</span>
</div>
</div>
<span key={step.key} className={`font-medium ${isDone ? "text-foreground font-semibold" : "text-muted-foreground/60"}`}>
{step.label}
</span>
);
})}
</div>
</div>
</Card>
{/* DETALLES Y PRESUPUESTO */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Grid de 2 Columnas: Detalle Técnico + Presupuesto */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Columna Izquierda (2/3): Datos del Cliente, Equipo e Historial */}
<div className="lg:col-span-2 space-y-8">
{/* Columna Principal (2/3): Diagnóstico e Historial */}
<div className="md:col-span-2 space-y-5">
{/* Falla Reportada y Diagnóstico en 1 sola tarjeta limpia */}
<Card className="bg-card/40 border-border/70 p-5 rounded-2xl shadow-sm space-y-4">
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider block">Falla Reportada</span>
<p className="text-xs text-foreground/90 font-light bg-muted/20 p-3 rounded-xl border border-border/40 leading-relaxed">
{order.issue}
</p>
</div>
{/* Datos del Cliente con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Datos del Cliente
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5 text-xs">
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Cliente</span>
<span className="text-foreground text-sm mt-1 block font-medium">{order.client}</span>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">DNI / Identificación</span>
<span className="text-foreground text-sm mt-1 block font-mono">{order.dni}</span>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Teléfono</span>
<span className="text-foreground text-sm mt-1 block">{order.phone}</span>
</div>
<div className="sm:col-span-3">
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Email</span>
<span className="text-foreground text-sm mt-1 block">{order.email}</span>
</div>
{order.diagnosis && (
<div className="space-y-1.5 pt-2 border-t border-border/50">
<span className="text-[10px] font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1.5">
<Stethoscope className="w-3.5 h-3.5" /> Diagnóstico del Técnico
</span>
<p className="text-xs text-foreground font-light bg-purple-500/5 p-3 rounded-xl border border-purple-500/20 leading-relaxed">
{order.diagnosis}
</p>
</div>
</CardContent>
)}
<div className="text-[11px] text-muted-foreground pt-1 flex items-center gap-2">
<span>Cliente: <strong className="text-foreground font-medium">{clientName}</strong></span>
<span>&bull;</span>
<span>Sede: <strong className="text-foreground font-medium">{order.venueId?.name || "Taller Central"}</strong></span>
</div>
</Card>
{/* Ficha Técnica con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Detalles del Dispositivo
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-5 text-xs">
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Dispositivo</span>
<span className="text-foreground text-sm mt-1 block font-medium">{order.device}</span>
{/* Historial Compacto */}
<Card className="bg-card/40 border-border/70 p-5 rounded-2xl shadow-sm space-y-3">
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-primary" /> Historial de Trazabilidad
</h3>
<div className="space-y-3 pt-1">
{order.history.map((log, idx) => (
<div key={idx} className="flex items-start gap-2.5 text-xs border-b border-border/30 pb-2 last:border-0 last:pb-0">
<span className="font-mono text-[10px] text-muted-foreground shrink-0 mt-0.5">{log.date}</span>
<p className="text-foreground/90 font-light leading-snug">{log.text}</p>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Número de Serie</span>
<span className="text-foreground/90 text-sm mt-1 block font-mono font-bold">{order.serial}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Falla Reportada</span>
<p className="text-foreground/90 text-sm mt-2 leading-relaxed font-light bg-background/50 p-3.5 rounded-lg border border-border/40">
{order.report}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Historial de Trazabilidad con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Historial del Servicio
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="space-y-6 relative before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-[1px] before:bg-border">
{order.history.map((log, index) => (
<div key={index} className="flex gap-4 relative z-10 text-xs">
<div className="w-[23px] h-[23px] rounded-full bg-background border border-border flex items-center justify-center shrink-0 shadow-sm">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
</div>
<div className="space-y-1">
<span className="text-muted-foreground font-mono font-medium block">{log.date}</span>
<p className="text-foreground/95 text-sm font-light leading-relaxed">{log.text}</p>
</div>
</div>
))}
</div>
</CardContent>
))}
</div>
</Card>
</div>
{/* Columna Derecha (1/3): Presupuesto y Acciones */}
<div className="space-y-8">
{/* Ficha de Presupuesto con Card de shadcn */}
<Card className="bg-card/30 border-border p-6 shadow-xl flex flex-col justify-between h-full">
<div className="space-y-5">
<h3 className="font-outfit text-lg font-bold text-foreground border-b border-border pb-2.5">
Presupuesto del Servicio
{/* Columna Derecha (1/3): Presupuesto y Aprobación */}
<div className="space-y-5">
<Card className="bg-card/40 border-border/70 p-5 rounded-2xl shadow-sm flex flex-col justify-between h-full space-y-4">
<div className="space-y-3.5">
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-1.5 border-b border-border/50 pb-2.5">
<FileText className="w-3.5 h-3.5 text-primary" /> Presupuesto
</h3>
{order.budget ? (
<div className="space-y-5">
{/* Desglose */}
<div className="space-y-3.5">
<div className="space-y-3.5">
<div className="space-y-2">
{order.budget.items.map((item, idx) => (
<div key={idx} className="text-xs space-y-1 border-b border-border/40 pb-2">
<p className="text-foreground/95 leading-relaxed font-light">{item.desc}</p>
<span className="text-primary font-bold block">${item.price.toLocaleString("es-AR")}</span>
<div key={idx} className="flex justify-between items-start text-xs border-b border-border/30 pb-1.5">
<span className="text-foreground/80 font-light pr-2 leading-tight">{item.desc}</span>
<span className="font-mono font-bold text-primary shrink-0">${item.price.toLocaleString("es-AR")}</span>
</div>
))}
</div>
{/* Total */}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground font-semibold uppercase">Total Estimado</span>
<span className="text-xl font-outfit font-extrabold text-foreground">
${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}
<div className="flex justify-between items-baseline pt-1">
<span className="text-xs font-bold text-muted-foreground uppercase">Total</span>
<span className="text-lg font-black font-outfit text-primary font-mono">
${order.budget.items.reduce((s, i) => s + i.price, 0).toLocaleString("es-AR")}
</span>
</div>
{/* Estado de Aprobación mediante Alert de shadcn */}
{order.budget.approved ? (
<Alert className="bg-emerald-950/20 border-emerald-500/25 text-emerald-400 p-3 shadow-sm">
<svg className="w-4 h-4 shrink-0 stroke-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<AlertTitle className="text-xs font-bold font-outfit ml-2">Aprobado</AlertTitle>
<AlertDescription className="text-[10px] opacity-90 ml-2">
Autorizado el {order.budget.dateApproved}
</AlertDescription>
</Alert>
) : (
<div className="space-y-3 pt-2">
<Alert variant="destructive" className="bg-amber-950/20 border-amber-500/30 text-amber-400 p-3 shadow-sm">
<svg className="w-4 h-4 stroke-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<AlertTitle className="text-xs font-bold font-outfit ml-2">Espera de Aprobación</AlertTitle>
<AlertDescription className="text-[10px] opacity-90 ml-2">
Requiere autorización del cliente para iniciar tareas.
</AlertDescription>
</Alert>
{/* Modal de Confirmación AlertDialog para Aprobar */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer">
Aprobar Presupuesto
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-card border-border shadow-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="font-outfit text-lg font-bold text-foreground">
¿Confirmar aprobación del presupuesto?
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground text-xs font-light">
Al aprobar, autorizás al taller a realizar el recambio de cooler y mantenimiento térmico especificado por un total de <strong className="text-foreground font-bold">${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-background border-border text-foreground hover:bg-muted font-bold text-xs">Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleApproveBudget} className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs">
Confirmar Aprobación
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 p-2.5 rounded-xl text-xs font-bold flex items-center gap-2">
<ShieldCheck className="w-4 h-4 shrink-0" />
<span>Presupuesto Aprobado</span>
</div>
) : (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-2.5 rounded-xl shadow-md cursor-pointer mt-1">
Aprobar Presupuesto
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-card border-border shadow-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="font-outfit text-base font-bold text-foreground">
¿Aprobar Presupuesto de Reparación?
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground text-xs font-light">
Autorizas la realización del servicio por un total de <strong className="text-foreground font-bold">${order.budget.items.reduce((s, i) => s + i.price, 0).toLocaleString("es-AR")}</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-background border-border text-foreground text-xs font-bold rounded-lg">Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleApproveBudget} className="bg-primary hover:bg-primary/90 text-primary-foreground text-xs font-bold rounded-lg">
Confirmar Aprobación
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
) : (
<div className="text-center py-8 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed font-light">
El técnico aún no ha cargado el presupuesto detallado de mano de obra y repuestos.
<div className="text-center py-6 space-y-2">
<p className="text-xs text-muted-foreground font-light">
Presupuesto pendiente de evaluación por el laboratorio técnico.
</p>
<Badge variant="secondary" className="px-3 py-1 font-bold text-[10px] uppercase tracking-wider text-muted-foreground">
Pendiente de Carga
<Badge variant="secondary" className="text-[9px] font-bold uppercase tracking-wider">
En Evaluación
</Badge>
</div>
)}
</div>
{/* Acciones */}
<div className="pt-4 border-t border-border mt-6">
<div className="pt-3 border-t border-border/40">
<Button
variant="outline"
onClick={() => 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"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar Presupuesto (PDF)
<Download className="w-3.5 h-3.5" /> Descargar Ficha PDF
</Button>
</div>
</Card>
</Card>
</div>
</div>

View File

@ -15,4 +15,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
allowedHosts: true,
},
})

16
frontend/worker.js Normal file
View File

@ -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;
}
};

7
frontend/wrangler.toml Normal file
View File

@ -0,0 +1,7 @@
name = "repairit"
main = "worker.js"
compatibility_date = "2026-06-09"
[assets]
directory = "./dist"
binding = "ASSETS"