feat: Integración completa de Caja (POS), Inventario, Diagnósticos y RLS Supabase
This commit is contained in:
parent
63dc9163a5
commit
3e96a0e095
105
DER.md
Normal file
105
DER.md
Normal 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
|
||||
}
|
||||
```
|
||||
94
frontend/package-lock.json
generated
94
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
1
frontend/public/_redirects
Normal file
1
frontend/public/_redirects
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* /index.html 200
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 • 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>
|
||||
|
||||
|
|
|
|||
897
frontend/src/services/api.js
Normal file
897
frontend/src/services/api.js
Normal 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." };
|
||||
},
|
||||
},
|
||||
};
|
||||
10
frontend/src/services/supabaseClient.js
Normal file
10
frontend/src/services/supabaseClient.js
Normal 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);
|
||||
491
frontend/src/views/admin/ClientsPage.jsx
Normal file
491
frontend/src/views/admin/ClientsPage.jsx
Normal 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()} • 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
275
frontend/src/views/admin/NewOrderPage.jsx
Normal file
275
frontend/src/views/admin/NewOrderPage.jsx
Normal 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
864
frontend/src/views/admin/POSPage.jsx
Normal file
864
frontend/src/views/admin/POSPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
424
frontend/src/views/admin/SuperAdminUsersPage.jsx
Normal file
424
frontend/src/views/admin/SuperAdminUsersPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
frontend/src/views/admin/VenueAccountsPage.jsx
Normal file
371
frontend/src/views/admin/VenueAccountsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
← 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>•</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>
|
||||
|
|
|
|||
|
|
@ -15,4 +15,7 @@ export default defineConfig({
|
|||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
16
frontend/worker.js
Normal file
16
frontend/worker.js
Normal 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
7
frontend/wrangler.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
name = "repairit"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2026-06-09"
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
Loading…
Reference in New Issue
Block a user