This commit is contained in:
David Arranz 2026-06-01 20:48:12 +02:00
parent 0e193ee9ce
commit 823e5cebe1
57 changed files with 1755 additions and 785 deletions

View File

@ -19,7 +19,7 @@ export function createApp(config: ConfigType): Application {
// En desarrollo reflejamos el Origin entrante (permite credenciales) // En desarrollo reflejamos el Origin entrante (permite credenciales)
origin: true, origin: true,
credentials: true, credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposedHeaders: [ exposedHeaders: [
"Content-Disposition", "Content-Disposition",
"Content-Type", "Content-Type",
@ -34,7 +34,7 @@ export function createApp(config: ConfigType): Application {
const prodCors: CorsOptions = { const prodCors: CorsOptions = {
origin: config.server.frontendUrl, origin: config.server.frontendUrl,
credentials: true, credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
exposedHeaders: [ exposedHeaders: [
"Content-Disposition", "Content-Disposition",
"Content-Type", "Content-Type",

View File

@ -50,6 +50,7 @@
"react-error-boundary": "^6.1.1", "react-error-boundary": "^6.1.1",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
"react-i18next": "^17.0.2", "react-i18next": "^17.0.2",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"react-secure-storage": "^1.3.2", "react-secure-storage": "^1.3.2",
"sequelize": "^6.37.8", "sequelize": "^6.37.8",

View File

@ -0,0 +1,108 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import * as React from "react";
interface Company {
id: string;
name: string;
taxName: string;
initials: string;
}
const companies: Company[] = [
{ id: "company-1", name: "Mi Empresa", taxName: "Mi Empresa S.L.", initials: "ME" },
{ id: "company-2", name: "Comercial Norte", taxName: "Comercial Norte S.L.", initials: "CN" },
{
id: "company-3",
name: "Servicios Globales",
taxName: "Servicios Globales S.L.",
initials: "SG",
},
];
interface AppCompanySwitcherProps {
className?: string;
}
export const AppCompanySwitcher = ({ className }: AppCompanySwitcherProps) => {
const [selectedCompany, setSelectedCompany] = React.useState<Company>(companies[0]);
return (
<div className={cn("w-full", className)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
className={cn(
"flex h-14 w-full items-center gap-3 rounded-none px-3 text-left hover:bg-muted transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-blue-600 text-sm font-semibold text-white">
{selectedCompany.initials}
</span>
<span className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="block truncate text-sm font-semibold leading-5 text-foreground">
{selectedCompany.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{selectedCompany.taxName}
</span>
</span>
<ChevronDownIcon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground group-data-[collapsible=icon]:hidden"
/>
</button>
}
/>
<DropdownMenuContent align="start" className="w-64" side="bottom">
<DropdownMenuGroup>
<DropdownMenuLabel>Empresa activa</DropdownMenuLabel>
<DropdownMenuSeparator />
{companies.map((company) => {
const isSelected = company.id === selectedCompany.id;
return (
<DropdownMenuItem
className="gap-3 py-2"
key={company.id}
onSelect={() => setSelectedCompany(company)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-foreground">
{company.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{company.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{company.taxName}
</span>
</span>
{isSelected ? (
<CheckIcon aria-hidden="true" className="size-4 text-blue-600" />
) : null}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};

View File

@ -0,0 +1,28 @@
import { SidebarInset, SidebarProvider } from "@repo/shadcn-ui/components";
import type * as React from "react";
import { Outlet } from "react-router-dom";
import { AppMain } from "./app-main";
import { AppSidebar } from "./app-sidebar";
import { AppTopbar } from "./app-topbar";
export const AppLayout = () => {
return (
<SidebarProvider
style={
{
"--sidebar-width": "16rem",
"--sidebar-width-mobile": "18rem",
} as React.CSSProperties
}
>
<AppSidebar />
<SidebarInset className="flex h-screen min-w-0 flex-col overflow-hidden bg-background">
<AppTopbar />
<AppMain>
<Outlet />
</AppMain>
</SidebarInset>
</SidebarProvider>
);
};

View File

@ -0,0 +1,11 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type * as React from "react";
interface AppMainProps {
children: React.ReactNode;
className?: string;
}
export const AppMain = ({ children, className }: AppMainProps) => {
return <main className={cn("min-h-0 flex-1 overflow-auto bg-muted", className)}>{children}</main>;
};

View File

@ -0,0 +1,162 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import type { AppSidebarNavSection, AppSidebarSectionAccent } from "./app-sidebar.config";
interface AppSidebarNavProps {
sections: AppSidebarNavSection[];
}
interface SidebarSectionAccentStyles {
sectionBorder: string;
sectionLabel: string;
activeItem: string;
activeIcon: string;
inactiveIcon: string;
}
export const AppSidebarNav = ({ sections }: AppSidebarNavProps) => {
const [openSections, setOpenSections] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(sections.map((s) => [s.title, true]))
);
const sectionAccentStyles: Record<AppSidebarSectionAccent, SidebarSectionAccentStyles> = {
blue: {
sectionBorder: "border-l-blue-500",
sectionLabel: "text-blue-700",
activeItem: "border-l-blue-500 bg-blue-50 text-blue-700 hover:bg-blue-50 hover:text-blue-700",
activeIcon: "text-blue-600",
inactiveIcon: "text-muted-foreground",
},
green: {
sectionBorder: "border-l-emerald-500",
sectionLabel: "text-emerald-700",
activeItem:
"border-l-emerald-500 bg-emerald-50 text-emerald-700 hover:bg-emerald-50 hover:text-emerald-700",
activeIcon: "text-emerald-600",
inactiveIcon: "text-muted-foreground",
},
amber: {
sectionBorder: "border-l-amber-500",
sectionLabel: "text-amber-700",
activeItem:
"border-l-amber-500 bg-amber-50 text-amber-700 hover:bg-amber-50 hover:text-amber-700",
activeIcon: "text-amber-600",
inactiveIcon: "text-muted-foreground",
},
violet: {
sectionBorder: "border-l-violet-500",
sectionLabel: "text-violet-700",
activeItem:
"border-l-violet-500 bg-violet-50 text-violet-700 hover:bg-violet-50 hover:text-violet-700",
activeIcon: "text-violet-600",
inactiveIcon: "text-muted-foreground",
},
};
return (
<nav className="space-y-2 pt-4">
{sections.map((section) => {
const isOpen = openSections[section.title];
const styles = sectionAccentStyles[section.accent];
return (
<SidebarGroup
className={cn("px-0 py-0 border-l-[3px]", styles.sectionBorder)}
key={section.title}
>
<Collapsible
onOpenChange={(open) =>
setOpenSections((current) => ({
...current,
[section.title]: open,
}))
}
open={isOpen}
>
<SidebarGroupLabel
className={cn(
"px-3 py-2 h-auto group-data-[collapsible=icon]:sr-only",
styles.sectionLabel
)}
>
<CollapsibleTrigger
render={
<button
className={cn(
"flex w-full items-center justify-between text-[11px] font-semibold uppercase tracking-wider",
"hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded",
styles.sectionLabel
)}
type="button"
>
<span>{section.title}</span>
<ChevronDownIcon
aria-hidden="true"
className={cn(
"size-3.5 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
}
/>
</SidebarGroupLabel>
<SidebarGroupContent>
<CollapsibleContent>
<SidebarMenu className="px-1">
{section.items.map((item) => {
const Icon = item.icon;
const isActive = false;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
className={cn(
"h-9 rounded-md px-3 text-sm font-medium text-foreground/80",
"hover:bg-muted hover:text-foreground",
isActive && styles.activeItem
)}
isActive={isActive}
render={
<Link to={item.href}>
<Icon
aria-hidden="true"
className={cn(
"size-4 shrink-0",
isActive ? styles.activeIcon : styles.inactiveIcon
)}
/>
<span className="truncate group-data-[collapsible=icon]:hidden">
{item.title}
</span>
</Link>
}
/>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</CollapsibleContent>
</SidebarGroupContent>
</Collapsible>
</SidebarGroup>
);
})}
</nav>
);
};

View File

@ -0,0 +1,104 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
ChevronRightIcon,
CoinsIcon,
PercentIcon,
SettingsIcon,
SlidersHorizontalIcon,
UserCogIcon,
} from "lucide-react";
import { Link } from "react-router-dom";
interface AppSidebarSettingsItem {
title: string;
href: string;
icon: typeof UserCogIcon;
}
const settingsItems: AppSidebarSettingsItem[] = [
{ title: "Usuarios y roles", href: "/settings/users", icon: UserCogIcon },
{ title: "Empresas", href: "/settings/companies", icon: Building2Icon },
{ title: "Impuestos", href: "/settings/taxes", icon: PercentIcon },
{ title: "Monedas", href: "/settings/currencies", icon: CoinsIcon },
{ title: "Parámetros", href: "/settings", icon: SlidersHorizontalIcon },
];
interface AppSidebarSettingsMenuProps {
className?: string;
}
export const AppSidebarSettingsMenu = ({ className }: AppSidebarSettingsMenuProps) => {
return (
<SidebarMenu className={cn(className)}>
<SidebarMenuItem>
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<SidebarMenuButton
className={cn(
"h-10 rounded-md border border-border bg-muted/50 text-foreground",
"hover:bg-muted hover:text-foreground"
)}
>
<SettingsIcon aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate group-data-[collapsible=icon]:hidden">
Configuración
</span>
<ChevronRightIcon
aria-hidden="true"
className="ml-auto size-4 text-muted-foreground group-data-[collapsible=icon]:hidden"
/>
</SidebarMenuButton>
}
/>
}
/>
<TooltipContent side="right">Configuración</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-56" side="right">
<DropdownMenuGroup>
<DropdownMenuLabel>Configuración</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{settingsItems.map((item) => {
const Icon = item.icon;
return (
<DropdownMenuItem
key={item.href}
render={
<Link className="flex items-center gap-2" to={item.href}>
<Icon aria-hidden="true" className="size-4" />
<span>{item.title}</span>
</Link>
}
/>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
};

View File

@ -0,0 +1,97 @@
import type { LucideIcon } from "lucide-react";
import {
BanknoteIcon,
BarChart3Icon,
BoxesIcon,
Building2Icon,
ClipboardListIcon,
CoinsIcon,
CreditCardIcon,
FileTextIcon,
LandmarkIcon,
PackageIcon,
PercentIcon,
ReceiptIcon,
RefreshCcwIcon,
ShoppingCartIcon,
SlidersHorizontalIcon,
TruckIcon,
UserCogIcon,
UsersIcon,
WarehouseIcon,
} from "lucide-react";
export interface AppSidebarNavItem {
title: string;
href: string;
icon: LucideIcon;
}
export type AppSidebarSectionAccent = "blue" | "green" | "amber" | "violet";
export interface AppSidebarNavSection {
title: string;
accent: AppSidebarSectionAccent;
items: AppSidebarNavItem[];
}
export const appSidebarNavSections: AppSidebarNavSection[] = [
{
title: "Ventas",
accent: "blue",
items: [
{ title: "Proformas", href: "/proformas", icon: FileTextIcon },
{ title: "Pedidos de venta", href: "/sales-orders", icon: ShoppingCartIcon },
{ title: "Clientes", href: "/customers", icon: UsersIcon },
{ title: "Facturación", href: "/customer-invoices", icon: ReceiptIcon },
{ title: "Cobros", href: "/collections", icon: CreditCardIcon },
{ title: "Devoluciones", href: "/sales-returns", icon: RefreshCcwIcon },
],
},
{
title: "Compras",
accent: "green",
items: [
{ title: "Pedidos de compra", href: "/purchase-orders", icon: ClipboardListIcon },
{ title: "Proveedores", href: "/suppliers", icon: TruckIcon },
{ title: "Facturas de compra", href: "/supplier-invoices", icon: ReceiptIcon },
{ title: "Pagos", href: "/payments", icon: BanknoteIcon },
{ title: "Devoluciones", href: "/purchase-returns", icon: RefreshCcwIcon },
],
},
{
title: "Inventario",
accent: "amber",
items: [
{ title: "Productos", href: "/products", icon: PackageIcon },
{ title: "Almacenes", href: "/warehouses", icon: WarehouseIcon },
{ title: "Movimientos", href: "/stock-movements", icon: BoxesIcon },
{ title: "Ajustes de stock", href: "/stock-adjustments", icon: SlidersHorizontalIcon },
{ title: "Categorías", href: "/product-categories", icon: ClipboardListIcon },
],
},
{
title: "Contabilidad",
accent: "violet",
items: [
{ title: "Plan contable", href: "/accounting/chart", icon: LandmarkIcon },
{ title: "Asientos contables", href: "/accounting/entries", icon: FileTextIcon },
{ title: "Diario", href: "/accounting/journal", icon: ClipboardListIcon },
{ title: "Informes financieros", href: "/accounting/reports", icon: BarChart3Icon },
],
},
];
export interface AppSidebarSettingsItem {
title: string;
href: string;
icon: LucideIcon;
}
export const appSidebarSettingsItems: AppSidebarSettingsItem[] = [
{ title: "Usuarios y roles", href: "/settings/users", icon: UserCogIcon },
{ title: "Empresas", href: "/settings/companies", icon: Building2Icon },
{ title: "Impuestos", href: "/settings/taxes", icon: PercentIcon },
{ title: "Monedas", href: "/settings/currencies", icon: CoinsIcon },
{ title: "Parámetros", href: "/settings", icon: SlidersHorizontalIcon },
];

View File

@ -0,0 +1,40 @@
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { AppCompanySwitcher } from "./app-company-switcher";
import { appSidebarNavSections } from "./app-sidebar.config";
import { AppSidebarNav } from "./app-sidebar-nav";
import { AppSidebarSettingsMenu } from "./app-sidebar-settings-menu";
interface AppSidebarProps {
className?: string;
}
export const AppSidebar = ({ className }: AppSidebarProps) => {
return (
<Sidebar
className={cn("border-r border-border bg-background text-foreground", className)}
collapsible="icon"
>
<SidebarHeader className="border-b border-border p-0">
<AppCompanySwitcher className="w-full" />
</SidebarHeader>
<SidebarContent className="px-3">
<AppSidebarNav sections={appSidebarNavSections} />
</SidebarContent>
<SidebarFooter className="border-t border-border p-3">
<AppSidebarSettingsMenu />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
};

View File

@ -0,0 +1,109 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Separator,
SidebarTrigger,
} from "@repo/shadcn-ui/components";
import { BellIcon, ChevronDownIcon, MessageSquareIcon, SearchIcon } from "lucide-react";
export const AppTopbar = () => {
return (
<header className="flex h-14 shrink-0 items-center border-b border-border bg-background">
<div className="flex h-full min-w-0 flex-1 items-center gap-3 px-4">
<SidebarTrigger className="size-9 shrink-0" />
<div className="relative w-full max-w-xl">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
className="h-9 rounded-md border-border bg-muted/40 pl-9 pr-12 text-sm"
placeholder="Búsqueda global..."
type="search"
/>
<kbd className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[0.68rem] text-muted-foreground md:inline-flex">
K
</kbd>
</div>
</div>
<Separator className="h-full" orientation="vertical" />
<div className="flex h-full items-center">
<Button
aria-label="Ver notificaciones"
className="relative h-full w-12 rounded-none"
size="icon"
type="button"
variant="ghost"
>
<BellIcon aria-hidden="true" className="size-5" />
<span className="absolute right-2.5 top-2.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-yellow-400 text-[0.62rem] font-semibold text-yellow-900">
3
</span>
</Button>
<Separator className="h-full" orientation="vertical" />
<Button
aria-label="Ver mensajes"
className="relative h-full w-12 rounded-none"
size="icon"
type="button"
variant="ghost"
>
<MessageSquareIcon aria-hidden="true" className="size-5" />
<span className="absolute right-2.5 top-2.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-blue-500 text-[0.62rem] font-semibold text-white">
7
</span>
</Button>
<Separator className="h-full" orientation="vertical" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Abrir menú de usuario"
className="h-full gap-3 rounded-none px-4"
type="button"
variant="ghost"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-sm font-semibold text-amber-800">
AM
</span>
<span className="hidden min-w-0 text-left md:block">
<span className="block truncate text-sm font-semibold leading-5">
Ana Martínez
</span>
<span className="block truncate text-xs font-normal text-muted-foreground">
Administrador
</span>
</span>
<ChevronDownIcon className="hidden size-4 text-muted-foreground md:block" />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Mi cuenta</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Perfil</DropdownMenuItem>
<DropdownMenuItem>Preferencias</DropdownMenuItem>
<DropdownMenuItem>Cerrar sesión</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};

View File

@ -0,0 +1,4 @@
export * from './app-layout';
export * from './app-main';
export * from './app-sidebar';
export * from './app-topbar';

View File

@ -1,13 +1,14 @@
// apps/web/src/routes/app-routes.tsx
import type { IModuleClient } from "@erp/core/client"; import type { IModuleClient } from "@erp/core/client";
import { AppLayout } from "@repo/rdx-ui/components";
import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import { ModuleRoutes } from "@/components/module-routes"; import { ModuleRoutes } from "@/components/module-routes";
import { AppLayout } from "@/layout";
import ShadcnShowcasePage from "@/pages/shadcn-ui-page"; import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page"; import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
import { ErrorPage, LoginForm } from "../pages"; import { ErrorPage, LoginForm } from "../pages";
import { modules } from "../register-modules"; // Aquí ca import { modules } from "../register-modules";
function groupModulesByLayout(modules: IModuleClient[]) { function groupModulesByLayout(modules: IModuleClient[]) {
const groups: Record<string, IModuleClient[]> = { const groups: Record<string, IModuleClient[]> = {
@ -15,14 +16,11 @@ function groupModulesByLayout(modules: IModuleClient[]) {
app: [], app: [],
}; };
if (modules) { for (const module of modules) {
for (const module of modules) { const layout = typeof module.layout === "string" ? module.layout : "app";
if (typeof module.layout !== "string") continue;
const layout = module.layout || "app"; groups[layout] = groups[layout] ?? [];
groups[layout] = groups[layout] || []; groups[layout].push(module);
groups[layout].push(module);
}
} }
return groups; return groups;
@ -38,27 +36,29 @@ export const getAppRouter = () => {
return createBrowserRouter( return createBrowserRouter(
createRoutesFromElements( createRoutesFromElements(
<Route path="/"> <Route path="/">
<Route element={<Navigate replace to="/proformas" />} index />
{/* Auth Layout */} {/* Auth Layout */}
<Route path="/auth"> <Route path="auth">
<Route element={<Navigate to="login" />} index /> <Route element={<Navigate replace to="login" />} index />
<Route element={<LoginForm />} path="login" /> <Route element={<LoginForm />} path="login" />
<Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" /> <Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" />
</Route> </Route>
{/* App Layout */} {/* App Layout */}
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
{/* Dynamic Module Routes */}
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
{/* Test */} {/* Test */}
<Route element={<ShadcnShowcasePage />} path="/shadcnui" /> <Route element={<ShadcnShowcasePage />} path="shadcnui" />
<Route element={<TailwindV4ShowcasePage />} path="/tailwindcss4" /> <Route element={<TailwindV4ShowcasePage />} path="tailwindcss4" />
{/* Main Layout */} {/* Static / provisional routes */}
<Route element={<ErrorPage />} path="/dashboard" /> <Route element={<ErrorPage />} path="dashboard" />
<Route element={<ErrorPage />} path="/settings" /> <Route element={<ErrorPage />} path="settings" />
<Route element={<ErrorPage />} path="/catalog" /> <Route element={<ErrorPage />} path="catalog" />
<Route element={<ErrorPage />} path="/quotes" /> <Route element={<ErrorPage />} path="quotes" />
{/* Dynamic module routes. Keep this last. */}
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
</Route> </Route>
</Route> </Route>
) )

View File

@ -0,0 +1,2 @@
export * from './page-form-header';
export * from './page-keyboard-shortcuts-button';

View File

@ -0,0 +1,78 @@
// packages/rdx-ui/src/components/form-actions/erp-form-header.tsx
import { Badge, Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ArrowLeftIcon } from "lucide-react";
import type { ReactNode } from "react";
export interface PageFormHeaderProps {
title: string;
backLabel: string;
onBack?: () => void;
statusLabel?: string;
showStatus?: boolean;
actions?: ReactNode;
children?: ReactNode;
className?: string;
contentClassName?: string;
}
export const PageFormHeader = ({
title,
backLabel,
onBack,
statusLabel,
showStatus = false,
actions,
children,
className,
contentClassName,
}: PageFormHeaderProps) => {
return (
<header className={cn("sticky top-0 z-50 border-b bg-background", className)}>
<div
className={cn(
"flex h-14 items-center justify-between gap-6 border-b px-4 py-2 sm:px-6 md:h-18",
contentClassName
)}
>
<div className="flex min-w-0 items-center gap-3">
<Button
aria-label={backLabel}
disabled={!onBack}
onClick={onBack}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeftIcon aria-hidden="true" className="size-5" />
</Button>
<div className="flex min-w-0 items-center gap-4">
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl">
{title}
</h1>
{statusLabel ? (
<Badge
className={cn(
"border-amber-500 bg-amber-200 text-amber-900 transition-opacity duration-200",
showStatus ? "opacity-100" : "pointer-events-none opacity-0"
)}
>
{statusLabel}
</Badge>
) : null}
</div>
</div>
{actions ? <div className="flex items-center gap-4">{actions}</div> : null}
</div>
{children}
</header>
);
};

View File

@ -0,0 +1,50 @@
import { Button, Tooltip, TooltipContent, TooltipTrigger } from "@repo/shadcn-ui/components";
import { KeyboardIcon } from "lucide-react";
export interface PageKeyboardShortcut {
keys: string;
label: string;
}
export interface PageKeyboardShortcutsButtonProps {
label: string;
shortcuts: PageKeyboardShortcut[];
className?: string;
}
export const PageKeyboardShortcutsButton = ({
label,
shortcuts,
className,
}: PageKeyboardShortcutsButtonProps) => {
if (shortcuts.length === 0) return null;
return (
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={label}
className={className}
size="icon"
type="button"
variant="outline"
>
<KeyboardIcon aria-hidden="true" className="size-4" />
</Button>
}
/>
<TooltipContent className="max-w-xs" side="bottom">
<div className="space-y-1 text-xs">
{shortcuts.map((shortcut) => (
<div key={`${shortcut.keys}-${shortcut.label}`}>
<kbd className="rounded bg-muted px-1">{shortcut.keys}</kbd> {shortcut.label}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
);
};

View File

@ -1,2 +1,3 @@
export * from "./form-actions";
export * from "./form-debug.tsx"; export * from "./form-debug.tsx";
export * from "./simple-search-input.tsx"; export * from "./simple-search-input.tsx";

View File

@ -51,14 +51,16 @@ export const PageHeader = ({
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl"> <h1 className="min-w-0 truncate text-xl font-bold tracking-tight text-foreground lg:text-2xl">
{title} {title}
</h1> </h1>
{statusSlot} {statusSlot}
</div> </div>
{description && <p className="text-sm text-muted-foreground">{description}</p>} {description && (
<p className="text-sm font-medium text-muted-foreground">{description}</p>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,77 +0,0 @@
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n.ts";
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
export type CancelFormButtonProps = {
to?: string;
onCancel?: () => void | Promise<void>;
label?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
disabled?: boolean;
"data-testid"?: string;
};
export const CancelFormButton = ({
to,
onCancel,
label,
variant = "outline",
size = "default",
className,
disabled = false,
"data-testid": dataTestId = "cancel-button",
}: CancelFormButtonProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { requestConfirm } = useUnsavedChangesContext();
const [isRunning, setIsRunning] = useState(false);
const defaultLabel = t("common.cancel");
const computedDisabled = disabled || isRunning;
const handleClick = useCallback(async () => {
if (computedDisabled) return;
const ok = requestConfirm ? await requestConfirm() : true;
if (!ok) return;
try {
setIsRunning(true);
if (onCancel) {
await onCancel();
return;
}
if (to) {
navigate(to);
}
} finally {
setIsRunning(false);
}
}, [computedDisabled, requestConfirm, onCancel, to, navigate]);
return (
<Button
className={cn("cursor-pointer", className)}
data-testid={dataTestId}
disabled={computedDisabled}
onClick={handleClick}
size={size}
type="button"
variant={variant}
>
<XIcon aria-hidden="true" className="mr-2 h-3 w-3" />
<span>{label ?? defaultLabel}</span>
</Button>
);
};

View File

@ -1,174 +0,0 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
ArrowLeftIcon,
CopyIcon,
EyeIcon,
MoreHorizontalIcon,
RotateCcwIcon,
Trash2Icon,
} from "lucide-react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { CancelFormButton, type CancelFormButtonProps } from "./cancel-form-button";
import { type SubmitButtonProps, SubmitFormButton } from "./submit-form-button";
type Align = "start" | "center" | "end" | "between";
type GroupSubmitButtonProps = Omit<SubmitButtonProps, "isLoading" | "preventDoubleSubmit">;
export type FormCommitButtonGroupProps = {
className?: string;
align?: Align; // default "end"
gap?: string; // default "gap-2"
reverseOrderOnMobile?: boolean; // default true (Cancel debajo en móvil)
isLoading?: boolean;
disabled?: boolean;
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
cancel?: CancelFormButtonProps & { show?: boolean };
submit?: GroupSubmitButtonProps; // props directas a SubmitButton
onReset?: () => void;
onDelete?: () => void;
onPreview?: () => void;
onDuplicate?: () => void;
onBack?: () => void;
};
const alignToJustify: Record<Align, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
};
export const FormCommitButtonGroup = ({
className,
align = "end",
gap = "gap-2",
reverseOrderOnMobile = true,
isLoading,
disabled = false,
preventDoubleSubmit = true,
cancel,
submit,
onReset,
onDelete,
onPreview,
onDuplicate,
onBack,
}: FormCommitButtonGroupProps) => {
const { t } = useTranslation();
const ctx = useFormContext();
const rhfIsSubmitting = ctx.formState.isSubmitting;
const busy = isLoading ?? rhfIsSubmitting;
const showCancel = cancel?.show ?? true;
const computedDisabled = !!(disabled || (preventDoubleSubmit && busy));
const hasSecondaryActions = !!(onReset || onPreview || onDuplicate || onBack || onDelete);
return (
<div
className={cn(
"flex sm:items-center",
reverseOrderOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToJustify[align],
gap,
className
)}
>
{showCancel && (
<CancelFormButton {...cancel} disabled={cancel?.disabled ?? computedDisabled} />
)}
{submit && (
<SubmitFormButton
{...submit}
disabled={submit.disabled ?? computedDisabled}
isLoading={busy}
preventDoubleSubmit={preventDoubleSubmit}
/>
)}
{hasSecondaryActions && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="px-2"
disabled={computedDisabled}
size="sm"
type="button"
variant="ghost"
>
<MoreHorizontalIcon className="h-4 w-4" />
<span className="sr-only">{t("common.moreActions")}</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuGroup>
{onReset && (
<DropdownMenuItem disabled={computedDisabled} onClick={onReset}>
<RotateCcwIcon className="mr-2 h-4 w-4" />
{t("common.resetChanges")}
</DropdownMenuItem>
)}
{onPreview && (
<DropdownMenuItem disabled={computedDisabled} onClick={onPreview}>
<EyeIcon className="mr-2 h-4 w-4" />
{t("common.preview")}
</DropdownMenuItem>
)}
{onDuplicate && (
<DropdownMenuItem disabled={computedDisabled} onClick={onDuplicate}>
<CopyIcon className="mr-2 h-4 w-4" />
{t("common.duplicate")}
</DropdownMenuItem>
)}
{onBack && (
<DropdownMenuItem disabled={computedDisabled} onClick={onBack}>
<ArrowLeftIcon className="mr-2 h-4 w-4" />
{t("common.back")}
</DropdownMenuItem>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={computedDisabled}
onClick={onDelete}
>
<Trash2Icon className="mr-2 h-4 w-4" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@ -1,4 +1,2 @@
export * from "./cancel-form-button"; export * from "./unsaved-changes-cancel-action-button";
export * from "./form-commit-button-group";
export * from "./submit-form-button";
export * from "./unsaved-changes-dialog"; export * from "./unsaved-changes-dialog";

View File

@ -1,93 +0,0 @@
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { LoaderCircleIcon, SaveIcon } from "lucide-react";
import type * as React from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n.ts";
export type SubmitButtonProps = {
formId?: string;
isLoading?: boolean;
label?: string;
labelIsLoading?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
className?: string;
preventDoubleSubmit?: boolean;
hasChanges?: boolean;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
children?: React.ReactNode;
"data-testid"?: string;
};
export const SubmitFormButton = ({
formId,
isLoading,
label,
labelIsLoading,
variant = "default",
size = "default",
className,
preventDoubleSubmit = true,
hasChanges = false,
onClick,
disabled = false,
children,
"data-testid": dataTestId = "submit-button",
}: SubmitButtonProps) => {
const { t } = useTranslation();
const { formState } = useFormContext();
const busy = isLoading ?? formState.isSubmitting;
const computedDisabled = disabled || (preventDoubleSubmit && busy);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
if (preventDoubleSubmit && busy) {
event.preventDefault();
event.stopPropagation();
return;
}
onClick?.(event);
};
return (
<Button
aria-busy={busy}
className={cn(
"min-w-[100px] cursor-pointer font-medium",
hasChanges && "ring-2 ring-primary/20",
className
)}
data-state={busy ? "loading" : "idle"}
data-testid={dataTestId}
disabled={computedDisabled}
form={formId}
onClick={handleClick}
size={size}
type="submit"
variant={variant}
>
{children ?? (
<span className="inline-flex items-center gap-2">
{busy ? (
<>
<LoaderCircleIcon aria-hidden="true" className="h-3 w-3 animate-spin" />
<span>{labelIsLoading ?? t("common.saving")}</span>
</>
) : (
<>
<SaveIcon aria-hidden="true" className="h-3 w-3" />
<span>{label ?? t("common.save")}</span>
</>
)}
</span>
)}
</Button>
);
};

View File

@ -0,0 +1,37 @@
import { CancelActionButton, type CancelActionButtonProps } from "@repo/rdx-ui/components";
import { useState } from "react";
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
export type UnsavedChangesCancelActionButtonProps = Omit<CancelActionButtonProps, "isBusy">;
export const UnsavedChangesCancelActionButton = ({
onCancel,
disabled,
...props
}: UnsavedChangesCancelActionButtonProps) => {
const { requestConfirm } = useUnsavedChangesContext();
const [isRunning, setIsRunning] = useState(false);
const handleCancel = async () => {
setIsRunning(true);
try {
const ok = requestConfirm ? await requestConfirm() : true;
if (!ok) return;
await onCancel();
} finally {
setIsRunning(false);
}
};
return (
<CancelActionButton
{...props}
disabled={disabled || isRunning}
isBusy={isRunning}
onCancel={handleCancel}
/>
);
};

View File

@ -15,6 +15,7 @@ import type {
} from "../snapshot-builders"; } from "../snapshot-builders";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full";
import { import {
ChangeStatusProformaUseCase,
CreateProformaUseCase, CreateProformaUseCase,
GetProformaByIdUseCase, GetProformaByIdUseCase,
IssueProformaUseCase, IssueProformaUseCase,
@ -112,11 +113,18 @@ export function buildUpdateProformaUseCase(deps: {
/* /*
export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) { export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) {
return new DeleteProformaUseCase(deps.finder); return new DeleteProformaUseCase(deps.finder);
} }*/
export function buildChangeStatusProformaUseCase(deps: { export function buildChangeStatusProformaUseCase(deps: {
finder: IProformaFinder; finder: IProformaFinder;
updater: IProformaUpdater;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new ChangeStatusProformaUseCase(deps.finder, deps.transactionManager); return new ChangeStatusProformaUseCase({
}*/ finder: deps.finder,
updater: deps.updater,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager,
});
}

View File

@ -3,8 +3,8 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { ChangeStatusProformaByIdRequestDTO } from "../../../../common"; import type { ChangeStatusProformaByIdRequestDTO } from "../../../../common";
import { ProformaCustomerInvoiceDomainService } from "../../../domain"; import type { IProformaFinder, IProformaUpdater } from "../services";
import type { CustomerInvoiceApplicationService } from "../../services"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
type ChangeStatusProformaUseCaseInput = { type ChangeStatusProformaUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
@ -12,14 +12,24 @@ type ChangeStatusProformaUseCaseInput = {
dto: ChangeStatusProformaByIdRequestDTO; dto: ChangeStatusProformaByIdRequestDTO;
}; };
export class ChangeStatusProformaUseCase { type ChangeStatusProformaUseCaseDeps = {
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService; finder: IProformaFinder;
updater: IProformaUpdater;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager;
};
constructor( export class ChangeStatusProformaUseCase {
private readonly service: CustomerInvoiceApplicationService, private readonly finder: IProformaFinder;
private readonly transactionManager: ITransactionManager private readonly updater: IProformaUpdater;
) { private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder;
this.proformaDomainService = new ProformaCustomerInvoiceDomainService(); private readonly transactionManager: ITransactionManager;
constructor(deps: ChangeStatusProformaUseCaseDeps) {
this.finder = deps.finder;
this.updater = deps.updater;
this.fullSnapshotBuilder = deps.fullSnapshotBuilder;
this.transactionManager = deps.transactionManager;
} }
public execute(params: ChangeStatusProformaUseCaseInput) { public execute(params: ChangeStatusProformaUseCaseInput) {
@ -29,27 +39,32 @@ export class ChangeStatusProformaUseCase {
dto: { new_status }, dto: { new_status },
} = params; } = params;
const idOrError = UniqueID.create(proforma_id); const proformaIdOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) return Result.fail(idOrError.error); if (proformaIdOrError.isFailure) {
return Result.fail(proformaIdOrError.error);
}
const proformaId = idOrError.data; const proformaId = proformaIdOrError.data;
return this.transactionManager.complete(async (transaction) => { return this.transactionManager.complete(async (transaction) => {
try { try {
/** 1. Recuperamos la proforma */ /** 1. Recuperamos la proforma */
const proformaResult = await this.service.getProformaByIdInCompany( const proformaResult = await this.finder.findProformaById(
companyId, companyId,
proformaId, proformaId,
transaction transaction
); );
if (proformaResult.isFailure) return Result.fail(proformaResult.error); if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const proforma = proformaResult.data; const proforma = proformaResult.data;
/** 2. Hacer el cambio de estado */ /** 2. Hacer el cambio de estado */
const transitionResult = await this.proformaDomainService.transition(proforma, new_status!); const transitionResult = await this.proformaDomainService.transition(proforma, new_status!);
if (transitionResult.isFailure) return Result.fail(transitionResult.error); if (transitionResult.isFailure) return Result.fail(transitionResult.error);
const updateResult = await this.service.updateProformaStatusByIdInCompany( const updateResult = await this.updater.updateProformaStatusByIdInCompany(
companyId, companyId,
proformaId, proformaId,
transitionResult.data.status, transitionResult.data.status,

View File

@ -1,4 +1,4 @@
//export * from "./change-status-proforma.use-case"; export * from "./change-status-proforma.use-case";
export * from "./create-proforma"; export * from "./create-proforma";
//export * from "./delete-proforma.use-case"; //export * from "./delete-proforma.use-case";
export * from "./get-proforma-by-id.use-case"; export * from "./get-proforma-by-id.use-case";

View File

@ -1,6 +1,7 @@
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api";
import { import {
type ChangeStatusProformaUseCase,
type CreateProformaUseCase, type CreateProformaUseCase,
type GetProformaByIdUseCase, type GetProformaByIdUseCase,
type IIssuedInvoicePublicServices, type IIssuedInvoicePublicServices,
@ -8,6 +9,7 @@ import {
type ListProformasUseCase, type ListProformasUseCase,
type ReportProformaUseCase, type ReportProformaUseCase,
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
buildChangeStatusProformaUseCase,
buildCreateProformaUseCase, buildCreateProformaUseCase,
buildGetProformaByIdUseCase, buildGetProformaByIdUseCase,
buildIssueProformaUseCase, buildIssueProformaUseCase,
@ -38,11 +40,12 @@ export type ProformasInternalDeps = {
issuedInvoiceServices: IIssuedInvoicePublicServices; issuedInvoiceServices: IIssuedInvoicePublicServices;
}) => IssueProformaUseCase; }) => IssueProformaUseCase;
updateProforma: () => UpdateProformaByIdUseCase; updateProforma: () => UpdateProformaByIdUseCase;
changeStatusProforma: () => ChangeStatusProformaUseCase;
/* /*
deleteProforma: () => DeleteProformaUseCase; deleteProforma: () => DeleteProformaUseCase;
issueProforma: () => IssueProformaUseCase;
changeStatusProforma: () => ChangeStatusProformaUseCase;*/ */
}; };
}; };
@ -122,6 +125,14 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
issuer, issuer,
transactionManager, transactionManager,
}), }),
changeStatusProforma: () =>
buildChangeStatusProformaUseCase({
finder,
updater,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
}, },
}; };
} }

View File

@ -5,12 +5,14 @@ import {
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } from "@erp/core/api";
import type { ChangeStatusProformaByIdRequestDTO } from "../../../../../common/dto/index.ts"; import type { ChangeStatusProformaByIdRequestDTO } from "../../../../../common/dto";
import type { ChangeStatusProformaUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ChangeStatusProformaController extends ExpressController { export class ChangeStatusProformaController extends ExpressController {
public constructor(private readonly useCase: ChangeStatusProformaUseCase) { public constructor(private readonly useCase: ChangeStatusProformaUseCase) {
super(); super();
this.errorMapper = customerInvoicesApiErrorMapper; this.errorMapper = proformasApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards( this.registerGuards(
@ -21,7 +23,7 @@ export class ChangeStatusProformaController extends ExpressController {
} }
protected async executeImpl() { protected async executeImpl() {
const companyId = this.getTenantId(); // garantizado por tenantGuard const companyId = this.getTenantId();
if (!companyId) { if (!companyId) {
return this.forbiddenError("Tenant ID not found"); return this.forbiddenError("Tenant ID not found");
} }
@ -32,7 +34,8 @@ export class ChangeStatusProformaController extends ExpressController {
} }
const dto = this.req.body as ChangeStatusProformaByIdRequestDTO; const dto = this.req.body as ChangeStatusProformaByIdRequestDTO;
const result = await this.useCase.execute({ proforma_id, dto, companyId });
const result = await this.useCase.execute({ proforma_id, companyId, dto });
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),

View File

@ -5,4 +5,4 @@ export * from "./get-proforma.controller";
export * from "./issue-proforma.controller"; export * from "./issue-proforma.controller";
export * from "./list-proformas.controller"; export * from "./list-proformas.controller";
export * from "./report-proforma.controller"; export * from "./report-proforma.controller";
//export * from "./update-proforma.controller"; export * from "./update-proforma.controller";

View File

@ -5,8 +5,8 @@ import {
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } from "@erp/core/api";
import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto/index.ts"; import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto";
import type { UpdateProformaByIdUseCase } from "../../../../application/index.ts"; import type { UpdateProformaByIdUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class UpdateProformaController extends ExpressController { export class UpdateProformaController extends ExpressController {

View File

@ -11,6 +11,8 @@ import {
} from ".."; } from "..";
import { import {
ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema,
CreateProformaRequestSchema, CreateProformaRequestSchema,
GetProformaByIdRequestSchema, GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema, IssueProformaByIdParamsRequestSchema,
@ -22,6 +24,7 @@ import {
} from "../../../../common"; } from "../../../../common";
import type { IIssuedInvoicePublicServices } from "../../../application"; import type { IIssuedInvoicePublicServices } from "../../../application";
import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller";
import { CreateProformaController } from "./controllers/create-proforma.controller"; import { CreateProformaController } from "./controllers/create-proforma.controller";
import { UpdateProformaController } from "./controllers/update-proforma.controller"; import { UpdateProformaController } from "./controllers/update-proforma.controller";
@ -130,8 +133,6 @@ export const proformasRouter = (params: StartParams) => {
} }
);*/ );*/
/*
router.patch( router.patch(
"/:proforma_id/status", "/:proforma_id/status",
//checkTabContext, //checkTabContext,
@ -140,11 +141,11 @@ export const proformasRouter = (params: StartParams) => {
validateRequest(ChangeStatusProformaByIdRequestSchema, "body"), validateRequest(ChangeStatusProformaByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.changeStatus_proforma(); const useCase = deps.useCases.changeStatusProforma();
const controller = new ChangeStatusProformaController(useCase); const controller = new ChangeStatusProformaController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
);*/ );
router.put( router.put(
"/:proforma_id/issue", "/:proforma_id/issue",

View File

@ -351,9 +351,9 @@ export class ProformaRepository
searchableFields: ["invoice_number", "reference", "description"], searchableFields: ["invoice_number", "reference", "description"],
mappings: { mappings: {
invoice_date: "invoice_date", invoice_date: "invoice_date",
invoice_number: "invoice_number", invoice_number: "CustomerInvoiceModel.invoice_number",
reference: "reference", reference: "CustomerInvoiceModel.reference",
description: "description", description: "CustomerInvoiceModel.description",
recipient_name: "current_customer.name", recipient_name: "current_customer.name",
}, },
sortableFields: [ sortableFields: [

View File

@ -36,7 +36,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
</ProformaLayout> </ProformaLayout>
), ),
children: [ children: [
{ path: "", index: true, element: <ProformasListPage /> }, // index { index: true, element: <ProformasListPage /> }, // index
{ path: "list", element: <ProformasListPage /> }, { path: "list", element: <ProformasListPage /> },
//{ path: "create", element: <ProformaCreatePage /> }, //{ path: "create", element: <ProformaCreatePage /> },
{ path: ":id/edit", element: <ProformaUpdatePage /> }, { path: ":id/edit", element: <ProformaUpdatePage /> },

View File

@ -1,8 +1,10 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks"; import { useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { import {
Button, Button,
Card,
CardContent,
ResizableHandle, ResizableHandle,
ResizablePanel, ResizablePanel,
ResizablePanelGroup, ResizablePanelGroup,
@ -12,7 +14,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react"; import {
CheckCircle2Icon,
CircleDollarSignIcon,
ClockIcon,
FileTextIcon,
FilterIcon,
PlusIcon,
} from "lucide-react";
import { createSearchParams, useLocation, useNavigate } from "react-router-dom"; import { createSearchParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
@ -152,102 +161,171 @@ export const ListProformasPage = () => {
} }
return ( return (
<section> <div className="p-6 space-y-6">
<AppHeader> {/* Header */}
<PageHeader <PageHeader
description={t("pages.proformas.list.description")} description={t("pages.proformas.list.description")}
rightSlot={ rightSlot={
<Button <Button
aria-label={t("pages.proformas.create.title")} aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")} onClick={() => navigate("/proformas/create")}
size={"default"} size={"default"}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent className="min-h-screen">
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full mx-auto w-full space-y-4"
orientation="horizontal"
> >
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%"> <PlusIcon aria-hidden className="mr-2 size-4" />
{listContent} {t("pages.proformas.create.title")}
</ResizablePanel> </Button>
}
title={t("pages.proformas.list.title")}
/>
<ResizableHandle className="mx-4" withHandle /> {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 hidden">
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%"> <Card>
<div className="h-full"> <CardContent className="p-5">
<ProformaSummaryPanel <div className="flex items-start gap-4">
className="border bg-background" <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
mode={panelCtrl.panelState.mode} <FileTextIcon className="size-6 text-blue-600" />
onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
proforma={panelCtrl.proforma}
visibility={panelCtrl.panelState.visibility}
/>
</div> </div>
</ResizablePanel> <div className="flex-1">
</ResizablePanelGroup> <p className="text-sm text-muted-foreground">Total proformas</p>
) : ( <p className="text-2xl font-bold text-foreground">1.248</p>
<div className="mx-auto w-full space-y-4">{listContent}</div> <p className="text-xs text-muted-foreground mt-1">
)} <span className="text-green-600 font-medium"> 12.5%</span> vs. mes anterior
<> </p>
{/* Issue */} </div>
<IssueProformaDialog </div>
isSubmitting={issueDialogCtrl.isSubmitting} </CardContent>
onConfirm={issueDialogCtrl.confirmIssue} </Card>
onOpenChange={(open) => {
if (!open) {
issueDialogCtrl.closeDialog();
}
}}
open={issueDialogCtrl.open}
target={issueDialogCtrl.target}
/>
<ChangeProformaStatusDialog <Card>
isSubmitting={changeStatusDialogCtrl.isSubmitting} <CardContent className="p-5">
onConfirm={changeStatusDialogCtrl.confirmChangeStatus} <div className="flex items-start gap-4">
onOpenChange={(open) => { <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-100">
if (!open) { <CircleDollarSignIcon className="size-6 text-emerald-600" />
changeStatusDialogCtrl.closeDialog(); </div>
} <div className="flex-1">
}} <p className="text-sm text-muted-foreground">Importe total</p>
open={changeStatusDialogCtrl.open} <p className="text-2xl font-bold text-foreground">125.430,75 </p>
targets={changeStatusDialogCtrl.targets} <p className="text-xs text-muted-foreground mt-1">
/> <span className="text-green-600 font-medium"> 8.1%</span> vs. mes anterior
</p>
</div>
</div>
</CardContent>
</Card>
{/* Eliminar */} <Card>
<DeleteProformaDialog <CardContent className="p-5">
isSecondConfirmStep={deleteDialogCtrl.isSecondConfirmStep} <div className="flex items-start gap-4">
isSubmitting={deleteDialogCtrl.isSubmitting} <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-amber-100">
onConfirm={deleteDialogCtrl.confirmDelete} <ClockIcon className="size-6 text-amber-600" />
onOpenChange={(open) => { </div>
if (!open) { <div className="flex-1">
deleteDialogCtrl.closeDialog(); <p className="text-sm text-muted-foreground">Pendientes</p>
} <p className="text-2xl font-bold text-foreground">356</p>
}} <p className="text-xs text-muted-foreground mt-1">
open={deleteDialogCtrl.open} <span className="text-green-600 font-medium"> 5.2%</span> vs. mes anterior
targets={deleteDialogCtrl.targets} </p>
/> </div>
</> </div>
</AppContent> </CardContent>
</section> </Card>
<Card>
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
<CheckCircle2Icon className="size-6 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">Convertidas</p>
<p className="text-2xl font-bold text-foreground">892</p>
<p className="text-xs text-muted-foreground mt-1">
<span className="text-green-600 font-medium"> 15.7%</span> vs. mes anterior
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Table */}
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-proformas-page"
className="h-full mx-auto w-full space-y-4"
orientation="horizontal"
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
<ResizableHandle className="mx-4" withHandle />
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<ProformaSummaryPanel
className="border bg-background"
mode={panelCtrl.panelState.mode}
onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
proforma={panelCtrl.proforma}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="mx-auto w-full space-y-4">{listContent}</div>
)}
<>
{/* Issue */}
<IssueProformaDialog
isSubmitting={issueDialogCtrl.isSubmitting}
onConfirm={issueDialogCtrl.confirmIssue}
onOpenChange={(open) => {
if (!open) {
issueDialogCtrl.closeDialog();
}
}}
open={issueDialogCtrl.open}
target={issueDialogCtrl.target}
/>
<ChangeProformaStatusDialog
isSubmitting={changeStatusDialogCtrl.isSubmitting}
onConfirm={changeStatusDialogCtrl.confirmChangeStatus}
onOpenChange={(open) => {
if (!open) {
changeStatusDialogCtrl.closeDialog();
}
}}
open={changeStatusDialogCtrl.open}
targets={changeStatusDialogCtrl.targets}
/>
{/* Eliminar */}
<DeleteProformaDialog
isSecondConfirmStep={deleteDialogCtrl.isSecondConfirmStep}
isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => {
if (!open) {
deleteDialogCtrl.closeDialog();
}
}}
open={deleteDialogCtrl.open}
targets={deleteDialogCtrl.targets}
/>
</>
</div>
); );
}; };

View File

@ -1,5 +1,9 @@
import type { PropsWithChildren } from "react"; import type { ReactNode } from "react";
export const ProformaLayout = ({ children }: PropsWithChildren) => { interface ProformaLayoutProps {
return <div className="space-y-4">{children}</div>; children: ReactNode;
}; }
export function ProformaLayout({ children }: ProformaLayoutProps) {
return <div className="flex flex-col h-full w-full">{children}</div>;
}

View File

@ -61,12 +61,12 @@ export const ProformaCompactTotals = ({
}; };
return ( return (
<Card className={cn("rounded-none bg-muted-foreground p-0 gap-0", className)}> <Card className={"rounded-none bg-muted-foreground p-0 gap-0"}>
{/* Panel expandible con desglose */} {/* Panel expandible con desglose */}
<CardContent <CardContent
className={cn( className={cn(
"grid overflow-hidden transition-all duration-200 bg-background", "grid transition-all duration-200 bg-background",
isExpanded ? "grid-rows-[1fr] py-6" : "grid-rows-[0fr] py-0" isExpanded ? "grid-rows-[1fr] py-6" : "grid-rows-[0fr] py-0"
)} )}
> >
@ -176,15 +176,20 @@ export const ProformaCompactTotals = ({
</CardContent> </CardContent>
{/* Barra principal de totales - siempre visible */} {/* Barra principal de totales - siempre visible */}
<CardFooter className="flex items-center justify-between gap-4 bg-muted rounded-none py-3"> <CardFooter
className={cn(
"flex items-center justify-between gap-4 bg-muted rounded-none py-3",
className
)}
>
{/* Boton expandir */} {/* Boton expandir */}
<Button onClick={() => setIsExpanded(!isExpanded)} size="default" variant="outline"> <Button onClick={() => setIsExpanded(!isExpanded)} variant="outline">
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />} {isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
{isExpanded ? "Ocultar desglose" : "Ver desglose"} {isExpanded ? "Ocultar desglose" : "Ver desglose"}
</Button> </Button>
{/* Totales en linea */} {/* Totales en linea */}
<div className="flex items-center gap-8 text-sm"> <div className="flex items-center gap-8 text-base">
<div className="hidden items-center gap-2 xl:flex"> <div className="hidden items-center gap-2 xl:flex">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")} {t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}

View File

@ -1,97 +1,136 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-header.tsx
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import { import {
Button, CancelActionButton,
DropdownMenu, FormActionsBar,
DropdownMenuContent, type FormSecondaryAction,
DropdownMenuItem, FormSecondaryActionsMenu,
DropdownMenuSeparator, RhfSubmitActionButton,
DropdownMenuTrigger, } from "@repo/rdx-ui/components";
Tooltip, import type { ReactNode } from "react";
TooltipContent,
TooltipTrigger, export interface ProformaUpdateHeaderLabels {
} from "@repo/shadcn-ui/components"; title: string;
import { ArrowLeftIcon, KeyboardIcon, MoreHorizontalIcon, SaveIcon, XIcon } from "lucide-react"; back: string;
modified: string;
cancel: string;
save: string;
saving: string;
moreActions: string;
keyboardShortcuts: string;
duplicate: string;
exportPdf: string;
delete: string;
}
export interface ProformaUpdateHeaderProps { export interface ProformaUpdateHeaderProps {
onSave?: () => void; formId?: string;
labels: ProformaUpdateHeaderLabels;
onCancel?: () => void; onCancel?: () => void;
onDuplicate?: () => void;
onExportPdf?: () => void;
onDelete?: () => void;
disabled?: boolean; disabled?: boolean;
readOnly?: boolean; readOnly?: boolean;
isSaving?: boolean;
hasChanges?: boolean; hasChanges?: boolean;
className?: string;
children?: ReactNode;
} }
export const ProformaUpdateHeader = ({ export const ProformaUpdateHeader = ({
onSave, formId,
labels,
onCancel, onCancel,
onDuplicate,
onExportPdf,
onDelete,
disabled = false, disabled = false,
readOnly = false, readOnly = false,
isSaving = false,
hasChanges = false, hasChanges = false,
className,
children,
}: ProformaUpdateHeaderProps) => { }: ProformaUpdateHeaderProps) => {
const computedDisabled = disabled || isSaving;
const secondaryActions: FormSecondaryAction[] = [
/*{
id: "duplicate",
label: labels.duplicate,
icon: <CopyIcon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDuplicate,
disabled: computedDisabled,
onSelect: () => onDuplicate?.(),
},
{
id: "export-pdf",
label: labels.exportPdf,
icon: <FileDownIcon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onExportPdf,
disabled: computedDisabled,
onSelect: () => onExportPdf?.(),
},
{
id: "delete",
label: labels.delete,
icon: <Trash2Icon aria-hidden="true" className="mr-2 size-4" />,
hidden: !onDelete || readOnly,
disabled: computedDisabled,
destructive: true,
onSelect: () => onDelete?.(),
},*/
];
return ( return (
<header className="sticky top-0 z-20"> <PageFormHeader
<div className="flex h-14 items-center justify-between gap-4"> actions={
<div className="flex items-center gap-3"> <FormActionsBar align="end" reverseOnMobile={false}>
<Button onClick={onCancel} size="icon" variant="outline"> <PageKeyboardShortcutsButton
<ArrowLeftIcon className="size-5" /> className="hidden sm:flex"
</Button> label={labels.keyboardShortcuts}
<div className="flex items-center gap-2"> shortcuts={[
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl"> { keys: "Ctrl+S", label: labels.save },
Editar proforma { keys: "Esc", label: labels.cancel },
</h1> ]}
{hasChanges && <span className="size-2 rounded-full bg-amber-500" />} />
</div>
</div>
<div className="flex items-center gap-2"> <CancelActionButton
{/* Atajo de teclado info */} className="hidden sm:flex"
<Tooltip> disabled={computedDisabled}
<TooltipTrigger label={labels.cancel}
render={ onCancel={() => onCancel?.()}
<Button className="hidden sm:flex" size="icon" variant="ghost"> variant="outline"
<KeyboardIcon className="size-4" /> />
</Button>
}
/>
<TooltipContent className="max-w-xs" side="bottom">
<div className="space-y-1 text-xs">
<div>
<kbd className="rounded bg-muted px-1">Ctrl+S</kbd> Guardar
</div>
<div>
<kbd className="rounded bg-muted px-1">Esc</kbd> Cancelar
</div>
</div>
</TooltipContent>
</Tooltip>
<Button className="hidden sm:flex" onClick={onCancel} variant="ghost"> <RhfSubmitActionButton
<XIcon className="mr-2 size-4" /> busyLabel={labels.saving}
Cancelar disabled={computedDisabled || readOnly}
</Button> formId={formId}
isBusy={isSaving}
label={labels.save}
/>
<Button onClick={onSave}> <FormSecondaryActionsMenu
<SaveIcon className="mr-2 size-4" /> actions={secondaryActions}
Guardar disabled={computedDisabled}
</Button> label={labels.moreActions}
/>
<DropdownMenu> </FormActionsBar>
<DropdownMenuTrigger }
render={ backLabel={labels.back}
<Button size="icon" variant="ghost"> className={className}
<MoreHorizontalIcon className="size-5" /> onBack={onCancel}
</Button> showStatus={hasChanges}
} statusLabel={labels.modified}
/> title={labels.title}
>
<DropdownMenuContent align="end"> {children}
<DropdownMenuItem>Duplicar proforma</DropdownMenuItem> </PageFormHeader>
<DropdownMenuItem>Exportar PDF</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">Eliminar</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
); );
}; };

View File

@ -26,7 +26,7 @@ export const ProformaInfoAlert = ({
return ( return (
<Alert <Alert
className={cn( className={cn(
"flex items-center gap-2 border-t bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200", "rounded-none flex items-center gap-2 border-0 bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200",
className className
)} )}
> >

View File

@ -3,6 +3,7 @@
import type { CustomerSelectionOption } from "@erp/customers"; import type { CustomerSelectionOption } from "@erp/customers";
import { PercentageField } from "@repo/rdx-ui/components"; import { PercentageField } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers"; import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { import {
ProformaUpdatePaymentEditor, ProformaUpdatePaymentEditor,
@ -17,9 +18,8 @@ import type {
UseUpdateProformaTaxControllerResult, UseUpdateProformaTaxControllerResult,
UseUpdateProformaTotalsControllerResult, UseUpdateProformaTotalsControllerResult,
} from "../../controllers"; } from "../../controllers";
import { EditorSidebar, ProformaCompactTotals, ProformaTotalsSummary } from "../blocks"; import { EditorSidebar } from "../blocks";
import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary"; import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary";
import { ProformaInfoAlert } from "../components";
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor"; import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor"; import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
@ -42,6 +42,8 @@ type ProformaUpdateEditorProps = {
currencyCode?: string; currencyCode?: string;
languageCode?: string; languageCode?: string;
className?: string;
}; };
export const ProformaUpdateEditorForm = ({ export const ProformaUpdateEditorForm = ({
@ -58,23 +60,18 @@ export const ProformaUpdateEditorForm = ({
paymentCtrl, paymentCtrl,
currencyCode, currencyCode,
languageCode, languageCode,
className,
}: ProformaUpdateEditorProps) => { }: ProformaUpdateEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<form <form
className="space-y-6 2xl:space-y-12" className={cn("space-y-6 2xl:space-y-12", className)}
id={formId} id={formId}
noValidate noValidate
onKeyDown={preventEnterKeySubmitForm} onKeyDown={preventEnterKeySubmitForm}
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{/* Alerta informativa */}
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Esta proforma no tiene validez fiscal.
Puedes convertirla en factura cuando sea aceptada por el cliente.
</ProformaInfoAlert>
{/* Contenido principal */} {/* Contenido principal */}
<div className="flex flex-1 overflow-hidden hidden"> <div className="flex flex-1 overflow-hidden hidden">
{/* Área principal */} {/* Área principal */}
@ -93,7 +90,7 @@ export const ProformaUpdateEditorForm = ({
</main> </main>
{/* Sidebar - desktop */} {/* Sidebar - desktop */}
<aside className="hidden w-96 shrink-0 border-none bg-transparent lg:block"> <aside className="w-96 shrink-0 border-none bg-transparent lg:block">
<EditorSidebar <EditorSidebar
className="2xl:col-span-1" className="2xl:col-span-1"
disabled={isSubmitting} disabled={isSubmitting}
@ -115,25 +112,6 @@ export const ProformaUpdateEditorForm = ({
itemsCtrl={itemsCtrl} itemsCtrl={itemsCtrl}
taxCtrl={taxCtrl} taxCtrl={taxCtrl}
/> />
<div className="grid 2xl:grid-cols-1 gap-4 2xl:gap-6 space-y-4 2xl:space-y-6">
<ProformaTotalsSummary
className="hidden"
currency={currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={isSubmitting}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
showRec={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetentionPercentage}
totals={totalsCtrl.totals}
/>
</div>
</div> </div>
<div className="2xl:col-span-1 space-y-4 2xl:space-y-6"> <div className="2xl:col-span-1 space-y-4 2xl:space-y-6">
<ProformaUpdateRecipientEditor <ProformaUpdateRecipientEditor
@ -147,6 +125,7 @@ export const ProformaUpdateEditorForm = ({
<ProformaUpdateTaxEditor className="2xl:col-span-1" taxCtrl={taxCtrl} /> <ProformaUpdateTaxEditor className="2xl:col-span-1" taxCtrl={taxCtrl} />
<NewProformaTotalsSummary <NewProformaTotalsSummary
className="hidden"
currency={currencyCode} currency={currencyCode}
globalDiscountField={ globalDiscountField={
<PercentageField <PercentageField
@ -163,9 +142,9 @@ export const ProformaUpdateEditorForm = ({
totals={totalsCtrl.totals} totals={totalsCtrl.totals}
/> />
<ProformaUpdateSettingsEditor className="w-full bg-secondary ring-0" /> <ProformaUpdateSettingsEditor className="w-full" />
<ProformaUpdatePaymentEditor <ProformaUpdatePaymentEditor
className="w-full bg-secondary ring-0" className="w-full"
disabled={isSubmitting || paymentCtrl.isLoading} disabled={isSubmitting || paymentCtrl.isLoading}
paymentMethodOptions={paymentCtrl.paymentMethodOptions} paymentMethodOptions={paymentCtrl.paymentMethodOptions}
paymentTermOptions={paymentCtrl.paymentTermOptions} paymentTermOptions={paymentCtrl.paymentTermOptions}
@ -173,23 +152,6 @@ export const ProformaUpdateEditorForm = ({
</div> </div>
</div> </div>
{/* Footer fijo con totales */} {/* Footer fijo con totales */}
<footer className="sticky bottom-0 z-30 bg-transparent">
<ProformaCompactTotals
currency={currencyCode}
globalDiscountField={
<PercentageField
className="md:col-span-4 md:col-start-1"
disabled={isSubmitting}
inputClassName="bg-background"
label={t("proformas.update.totals.globalDiscountPercentage", "Descuento global")}
name="globalDiscountPercentage"
/>
}
showRec={taxCtrl.hasRecPercentage}
showRetention={taxCtrl.hasRetentionPercentage}
totals={totalsCtrl.totals}
/>
</footer>
</form> </form>
); );
}; };

View File

@ -1,17 +1,13 @@
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components"; import { ErrorAlert, NotFoundCard } from "@erp/core/components";
import { import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
FormCommitButtonGroup,
UnsavedChangesProvider,
useReturnToNavigation,
} from "@erp/core/hooks";
import { SelectCustomerDialog } from "@erp/customers"; import { SelectCustomerDialog } from "@erp/customers";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, BackHistoryButton, PercentageField } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller"; import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
import { ProformaUpdateHeader } from "../blocks"; import { ProformaCompactTotals, ProformaUpdateHeader } from "../blocks";
import { ProformaUpdateSkeleton } from "../components"; import { ProformaInfoAlert, ProformaUpdateSkeleton } from "../components";
import { ProformaUpdateEditorForm } from "../editors"; import { ProformaUpdateEditorForm } from "../editors";
export const ProformaUpdatePage = () => { export const ProformaUpdatePage = () => {
@ -23,6 +19,8 @@ export const ProformaUpdatePage = () => {
fallbackPath: returnTo, fallbackPath: returnTo,
}); });
const handleCancel = () => navigateBack();
if (updateCtrl.isLoading) { if (updateCtrl.isLoading) {
return <ProformaUpdateSkeleton />; return <ProformaUpdateSkeleton />;
} }
@ -59,39 +57,39 @@ export const ProformaUpdatePage = () => {
</AppContent> </AppContent>
); );
updateCtrl.form.d;
return ( return (
<FormProvider {...updateCtrl.form}> <div className="fixed inset-0 z-50 flex flex-col overflow-y-scroll bg-muted">
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}> <FormProvider {...updateCtrl.form}>
<AppHeader className="mx-auto max-w-[100rem]"> <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
<PageHeader
description={t("pages.proformas.update.description")}
onBackClick={() => navigateBack()}
rightSlot={
<FormCommitButtonGroup
cancel={{
onCancel: () => navigateBack(),
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onBack={() => navigateBack()}
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined}
submit={{
formId: updateCtrl.formId,
}}
/>
}
showBackButton
title={t("pages.proformas.update.title")}
/>
</AppHeader>
<AppContent className="mx-auto max-w-[100rem]">
<ProformaUpdateHeader <ProformaUpdateHeader
disabled={updateCtrl.isUpdating} formId={updateCtrl.formId}
hasChanges={updateCtrl.form.formState.isDirty} hasChanges={updateCtrl.form.formState.isDirty}
/> isSaving={updateCtrl.form.formState.isSubmitting}
labels={{
title: "Editar proforma",
back: "Volver",
modified: "Modificada",
cancel: "Cancelar",
save: "Guardar",
saving: "Guardando...",
moreActions: "Más acciones",
keyboardShortcuts: "Ver atajos de teclado",
duplicate: "Duplicar proforma",
exportPdf: "Exportar PDF",
delete: "Eliminar",
}}
onCancel={handleCancel}
//onDelete={openDeleteDialog}
//onDuplicate={handleDuplicate}
//onExportPdf={handleExportPdf}
>
{/* Alerta informativa */}
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Esta proforma no tiene validez
fiscal. Puedes convertirla en factura cuando sea aceptada por el cliente.
</ProformaInfoAlert>
</ProformaUpdateHeader>
{updateCtrl.isUpdateError && ( {updateCtrl.isUpdateError && (
<ErrorAlert <ErrorAlert
message={ message={
@ -101,26 +99,51 @@ export const ProformaUpdatePage = () => {
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")} title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
/> />
)} )}
<div className="flex-1 overflow-y-auto">
<ProformaUpdateEditorForm
className="p-6 space-y-6 "
currencyCode={updateCtrl.currencyCode}
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
itemsCtrl={updateCtrl.itemsCtrl}
languageCode={updateCtrl.languageCode}
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog}
onCreateCustomerClick={() => null}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
paymentCtrl={updateCtrl.paymentCtrl}
selectedCustomer={updateCtrl.selectedCustomer}
taxCtrl={updateCtrl.taxCtrl}
totalsCtrl={updateCtrl.totalsCtrl}
/>
<ProformaUpdateEditorForm {/* Sticky Footer */}
currencyCode={updateCtrl.currencyCode} <footer className="sticky bottom-0 z-10 shrink-0">
formId={updateCtrl.formId} <ProformaCompactTotals
isSubmitting={updateCtrl.isUpdating} className="bg-background"
itemsCtrl={updateCtrl.itemsCtrl} currency={updateCtrl.currencyCode}
languageCode={updateCtrl.languageCode} globalDiscountField={
onChangeCustomerClick={selectCustomerCtrl.selectCtrl.openDialog} <PercentageField
onCreateCustomerClick={() => null} className="md:col-span-4 md:col-start-1"
onReset={updateCtrl.resetForm} disabled={updateCtrl.isUpdating}
onSubmit={updateCtrl.onSubmit} inputClassName="bg-background"
paymentCtrl={updateCtrl.paymentCtrl} label={t(
selectedCustomer={updateCtrl.selectedCustomer} "proformas.update.totals.globalDiscountPercentage",
taxCtrl={updateCtrl.taxCtrl} "Descuento global"
totalsCtrl={updateCtrl.totalsCtrl} )}
/> name="globalDiscountPercentage"
/>
}
showRec={updateCtrl.taxCtrl.hasRecPercentage}
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
totals={updateCtrl.totalsCtrl.totals}
/>
</footer>
</div>
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} /> <SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
</AppContent> </UnsavedChangesProvider>
</UnsavedChangesProvider> </FormProvider>
</FormProvider> </div>
); );
}; };

View File

@ -7,7 +7,6 @@ import {
InputGroupInput, InputGroupInput,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
import { CalendarIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form"; import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
@ -54,7 +53,7 @@ export const DatePickerField = <TFormValues extends FieldValues>({
spellCheck: false, spellCheck: false,
}; };
const rightIcon = <CalendarIcon />; const rightIcon = null; //<CalendarX2Icon />;
// Obtener error del campo (tipado seguro) // Obtener error del campo (tipado seguro)
const fieldError = getFieldState(name, formState).error; const fieldError = getFieldState(name, formState).error;

View File

@ -0,0 +1,45 @@
import { XIcon } from "lucide-react";
import type * as React from "react";
import { FormActionButton } from "./form-action-button.tsx";
export interface CancelActionButtonProps {
label: string;
disabled?: boolean;
isBusy?: boolean;
variant?: React.ComponentProps<typeof FormActionButton>["variant"];
size?: React.ComponentProps<typeof FormActionButton>["size"];
className?: string;
onCancel: () => void | Promise<void>;
"data-testid"?: string;
}
export const CancelActionButton = ({
label,
disabled = false,
isBusy = false,
variant = "outline",
size = "default",
className,
onCancel,
"data-testid": dataTestId = "cancel-button",
}: CancelActionButtonProps) => {
return (
<FormActionButton
className={className}
data-testid={dataTestId}
disabled={disabled}
icon={<XIcon aria-hidden="true" className="h-3 w-3" />}
isBusy={isBusy}
label={label}
onClick={() => onCancel()}
size={size}
type="button"
variant={variant}
/>
);
};

View File

@ -0,0 +1,61 @@
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type * as React from "react";
export interface FormActionButtonProps {
label: string;
icon?: React.ReactNode;
type?: "button" | "submit" | "reset";
formId?: string;
variant?: React.ComponentProps<typeof Button>["variant"];
size?: React.ComponentProps<typeof Button>["size"];
disabled?: boolean;
isBusy?: boolean;
className?: string;
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
"data-testid"?: string;
}
export const FormActionButton = ({
label,
icon,
type = "button",
formId,
variant = "outline",
size = "default",
disabled = false,
isBusy = false,
className,
children,
onClick,
"data-testid": dataTestId,
}: FormActionButtonProps) => {
return (
<Button
aria-busy={isBusy}
className={cn("cursor-pointer font-medium", className)}
data-state={isBusy ? "busy" : "idle"}
data-testid={dataTestId}
disabled={disabled || isBusy}
form={formId}
onClick={onClick}
size={size}
type={type}
variant={variant}
>
{children ?? (
<span className="inline-flex items-center gap-2">
{icon}
<span>{label}</span>
</span>
)}
</Button>
);
};

View File

@ -0,0 +1,42 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type * as React from "react";
export type FormActionsAlign = "start" | "center" | "end" | "between";
export interface FormActionsBarProps {
children: React.ReactNode;
align?: FormActionsAlign;
gap?: string;
reverseOnMobile?: boolean;
className?: string;
}
const alignToClassName: Record<FormActionsAlign, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
};
export const FormActionsBar = ({
children,
align = "end",
gap = "gap-2",
reverseOnMobile = true,
className,
}: FormActionsBarProps) => {
return (
<div
className={cn(
"flex sm:items-center",
reverseOnMobile ? "flex-col-reverse sm:flex-row" : "flex-row",
alignToClassName[align],
gap,
className
)}
>
{children}
</div>
);
};

View File

@ -0,0 +1,91 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { MoreHorizontalIcon } from "lucide-react";
import { Fragment } from "react";
export interface FormSecondaryAction {
id: string;
label: string;
icon?: React.ReactNode;
hidden?: boolean;
destructive?: boolean;
disabled?: boolean;
onSelect: () => void | Promise<void>;
}
export interface FormSecondaryActionsMenuProps {
label: string;
actions: FormSecondaryAction[];
disabled?: boolean;
className?: string;
"data-testid"?: string;
}
export const FormSecondaryActionsMenu = ({
label,
actions,
disabled = false,
className,
"data-testid": dataTestId = "secondary-actions-menu",
}: FormSecondaryActionsMenuProps) => {
const visibleActions = actions;
if (visibleActions.length === 0) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className={className}
data-testid={dataTestId}
disabled={disabled}
size="sm"
type="button"
variant="ghost"
>
<MoreHorizontalIcon aria-hidden="true" className="h-4 w-4" />
<span className="sr-only">{label}</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuGroup>
{visibleActions.map(
(action, index) =>
!action.hidden && (
<>
(
<Fragment key={action.id}>
{action.destructive && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
className={
action.destructive ? "text-destructive focus:text-destructive" : undefined
}
disabled={disabled || action.disabled}
onClick={() => action.onSelect()}
>
{action.icon}
{action.label}
</DropdownMenuItem>
</Fragment>
)
</>
)
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@ -0,0 +1,6 @@
export * from "./cancel-action-button.tsx";
export * from "./form-action-button.tsx";
export * from "./form-actions-bar.tsx";
export * from "./form-secondary-actions-menu.tsx";
export * from "./rhf-submit-action-button.tsx";
export * from "./submit-action-button.tsx";

View File

@ -0,0 +1,13 @@
import { useFormContext } from "react-hook-form";
import { SubmitActionButton, type SubmitActionButtonProps } from "./submit-action-button.tsx";
export type RhfSubmitActionButtonProps = Omit<SubmitActionButtonProps, "isBusy"> & {
isBusy?: boolean;
};
export const RhfSubmitActionButton = ({ isBusy, ...props }: RhfSubmitActionButtonProps) => {
const { formState } = useFormContext();
return <SubmitActionButton {...props} isBusy={isBusy ?? formState.isSubmitting} />;
};

View File

@ -0,0 +1,75 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { LoaderCircleIcon, SaveIcon } from "lucide-react";
import type * as React from "react";
import { FormActionButton } from "./form-action-button.tsx";
export interface SubmitActionButtonProps {
label: string;
busyLabel: string;
icon?: React.ReactNode;
busyIcon?: React.ReactNode;
formId?: string;
isBusy?: boolean;
hasChanges?: boolean;
disabled?: boolean;
preventDoubleSubmit?: boolean;
variant?: React.ComponentProps<typeof FormActionButton>["variant"];
size?: React.ComponentProps<typeof FormActionButton>["size"];
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
"data-testid"?: string;
}
export const SubmitActionButton = ({
label,
busyLabel,
icon,
busyIcon,
formId,
isBusy = false,
hasChanges = false,
disabled = false,
preventDoubleSubmit = true,
variant = "default",
size = "default",
className,
onClick,
"data-testid": dataTestId = "submit-button",
}: SubmitActionButtonProps) => {
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (event) => {
if (preventDoubleSubmit && isBusy) {
event.preventDefault();
event.stopPropagation();
return;
}
onClick?.(event);
};
const resolvedIcon = isBusy
? (busyIcon ?? <LoaderCircleIcon aria-hidden="true" className="h-3 w-3 animate-spin" />)
: (icon ?? <SaveIcon aria-hidden="true" className="h-3 w-3" />);
return (
<FormActionButton
className={cn("min-w-[100px]", hasChanges && "ring-2 ring-primary/20", className)}
data-testid={dataTestId}
disabled={disabled || (preventDoubleSubmit && isBusy)}
formId={formId}
icon={resolvedIcon}
isBusy={isBusy}
label={isBusy ? busyLabel : label}
onClick={handleClick}
size={size}
type="submit"
variant={variant}
/>
);
};

View File

@ -35,12 +35,12 @@ export const FormSectionCard = ({
<Card className={className}> <Card className={className}>
{hasHeader && ( {hasHeader && (
<CardHeader className={headerClassName}> <CardHeader className={headerClassName}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-2">
{icon ? ( {icon ? (
<div <div
className={cn( className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-md", "flex size-6 shrink-0 items-center justify-center",
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary" disabled ? "text-muted-foreground" : "text-primary"
)} )}
> >
{" "} {" "}

View File

@ -9,7 +9,7 @@ interface FormSectionGridProps {
export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => { export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => {
return ( return (
<FieldGroup className={cn("grid grid-cols-1 gap-4 md:grid-cols-12", className)}> <FieldGroup className={cn("grid grid-cols-1 gap-4 md:grid-cols-12 2xl:gap-6", className)}>
{children} {children}
</FieldGroup> </FieldGroup>
); );

View File

@ -2,6 +2,7 @@ export * from "./checkbox-field.tsx";
export * from "./date-picker-field.tsx"; export * from "./date-picker-field.tsx";
export * from "./date-picker-input-field/index.ts"; export * from "./date-picker-input-field/index.ts";
export * from "./decimal-field/index.ts"; export * from "./decimal-field/index.ts";
export * from "./form-actions/index.ts";
export * from "./form-field-label.tsx"; export * from "./form-field-label.tsx";
export * from "./form-section-card.tsx"; export * from "./form-section-card.tsx";
export * from "./form-section-grid.tsx"; export * from "./form-section-grid.tsx";

View File

@ -7,7 +7,10 @@ export const AppContent = ({
...props ...props
}: PropsWithChildren<{ className?: string }>) => { }: PropsWithChildren<{ className?: string }>) => {
return ( return (
<div className={cn("app-content", className)} {...props}> <div
className={cn("app-content flex flex-1 flex-col px-4 py-4 sm:px-6 sm:py-6", className)}
{...props}
>
{children} {children}
</div> </div>
); );

View File

@ -18,9 +18,7 @@ export const AppLayout = () => {
{/* Aquí está el MAIN */} {/* Aquí está el MAIN */}
<SidebarInset className="app-main bg-muted "> <SidebarInset className="app-main bg-muted ">
<AppTopbar /> <AppTopbar />
<div className="flex flex-1 flex-col px-4 py-4 sm:px-6 sm:py-6"> <Outlet />
<Outlet />
</div>
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>
); );

View File

@ -7,6 +7,9 @@
"plugins": [{ "name": "typescript-plugin-css-modules" }] "plugins": [{ "name": "typescript-plugin-css-modules" }]
}, },
"include": ["src"], "include": [
"src",
"../../modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-cancel-action-button.tsx"
],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -1,19 +1,18 @@
import type * as React from "react" import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menu as MenuPrimitive } from "@base-ui/react/menu" import { cn } from "@repo/shadcn-ui/lib/utils";
import { CheckIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@repo/shadcn-ui/lib/utils" import type * as React from "react";
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
} }
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} /> return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -24,31 +23,31 @@ function DropdownMenuContent({
className, className,
...props ...props
}: MenuPrimitive.Popup.Props & }: MenuPrimitive.Popup.Props &
Pick< Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<MenuPrimitive.Portal> <MenuPrimitive.Portal>
<MenuPrimitive.Positioner <MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}
className="isolate z-50 outline-none"
side={side} side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
> >
<MenuPrimitive.Popup <MenuPrimitive.Popup
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props} {...props}
/> />
</MenuPrimitive.Positioner> </MenuPrimitive.Positioner>
</MenuPrimitive.Portal> </MenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -56,19 +55,19 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: MenuPrimitive.GroupLabel.Props & { }: MenuPrimitive.GroupLabel.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.GroupLabel <MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", "px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
className className
)} )}
data-inset={inset}
data-slot="dropdown-menu-label"
{...props} {...props}
/> />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -77,25 +76,25 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: MenuPrimitive.Item.Props & { }: MenuPrimitive.Item.Props & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<MenuPrimitive.Item <MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn( className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive", "group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className className
)} )}
data-inset={inset}
data-slot="dropdown-menu-item"
data-variant={variant}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} /> return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -104,22 +103,22 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: MenuPrimitive.SubmenuTrigger.Props & { }: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.SubmenuTrigger <MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
data-inset={inset}
data-slot="dropdown-menu-sub-trigger"
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger> </MenuPrimitive.SubmenuTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -132,15 +131,18 @@ function DropdownMenuSubContent({
}: React.ComponentProps<typeof DropdownMenuContent>) { }: React.ComponentProps<typeof DropdownMenuContent>) {
return ( return (
<DropdownMenuContent <DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}
className={cn(
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
data-slot="dropdown-menu-sub-content"
side={side} side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -150,17 +152,17 @@ function DropdownMenuCheckboxItem({
inset, inset,
...props ...props
}: MenuPrimitive.CheckboxItem.Props & { }: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.CheckboxItem <MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" checked={checked}
data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
checked={checked} data-inset={inset}
data-slot="dropdown-menu-checkbox-item"
{...props} {...props}
> >
<span <span
@ -168,22 +170,16 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
<MenuPrimitive.CheckboxItemIndicator> <MenuPrimitive.CheckboxItemIndicator>
<CheckIcon <CheckIcon />
/>
</MenuPrimitive.CheckboxItemIndicator> </MenuPrimitive.CheckboxItemIndicator>
</span> </span>
{children} {children}
</MenuPrimitive.CheckboxItem> </MenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return ( return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -192,16 +188,16 @@ function DropdownMenuRadioItem({
inset, inset,
...props ...props
}: MenuPrimitive.RadioItem.Props & { }: MenuPrimitive.RadioItem.Props & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<MenuPrimitive.RadioItem <MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
data-inset={inset}
data-slot="dropdown-menu-radio-item"
{...props} {...props}
> >
<span <span
@ -209,58 +205,51 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
<MenuPrimitive.RadioItemIndicator> <MenuPrimitive.RadioItemIndicator>
<CheckIcon <CheckIcon />
/>
</MenuPrimitive.RadioItemIndicator> </MenuPrimitive.RadioItemIndicator>
</span> </span>
{children} {children}
</MenuPrimitive.RadioItem> </MenuPrimitive.RadioItem>
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
className,
...props
}: MenuPrimitive.Separator.Props) {
return ( return (
<MenuPrimitive.Separator <MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="dropdown-menu-separator"
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground", "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className className
)} )}
data-slot="dropdown-menu-shortcut"
{...props} {...props}
/> />
) );
} }
export { export {
DropdownMenu, DropdownMenu,
DropdownMenuPortal, DropdownMenuCheckboxItem,
DropdownMenuTrigger,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuCheckboxItem, DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} DropdownMenuSubTrigger,
DropdownMenuTrigger,
};

View File

@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@ -49,14 +49,18 @@
--chart-3: #155dfc; --chart-3: #155dfc;
--chart-4: #1447e6; --chart-4: #1447e6;
--chart-5: #193cb8; --chart-5: #193cb8;
--sidebar: #0b7ad0;
--sidebar-foreground: #ffffff; --sidebar: #ffffff;
--sidebar-primary: #ffffff; --sidebar-foreground: #0f172a;
--sidebar-primary-foreground: #0b7ad0;
--sidebar-accent: #4a8fe0; --sidebar-primary: #2563eb;
--sidebar-accent-foreground: #ffffff; --sidebar-primary-foreground: #ffffff;
--sidebar-border: #4a8fe0;
--sidebar-ring: #ffffff; --sidebar-accent: #f1f5f9;
--sidebar-accent-foreground: #0f172a;
--sidebar-border: #e5e7eb;
--sidebar-ring: #2563eb;
--font-sans: "Geist Variable", sans-serif; --font-sans: "Geist Variable", sans-serif;
--font-serif: "Geist Variable", serif; --font-serif: "Geist Variable", serif;

View File

@ -282,6 +282,9 @@ importers:
react-i18next: react-i18next:
specifier: ^17.0.2 specifier: ^17.0.2
version: 17.0.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2) version: 17.0.8(i18next@26.0.4(typescript@6.0.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.2)
react-router:
specifier: ^7.14.0
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: react-router-dom:
specifier: ^7.14.0 specifier: ^7.14.0
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)