.
This commit is contained in:
parent
0e193ee9ce
commit
823e5cebe1
@ -19,7 +19,7 @@ export function createApp(config: ConfigType): Application {
|
||||
// En desarrollo reflejamos el Origin entrante (permite credenciales)
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposedHeaders: [
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
@ -34,7 +34,7 @@ export function createApp(config: ConfigType): Application {
|
||||
const prodCors: CorsOptions = {
|
||||
origin: config.server.frontendUrl,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
exposedHeaders: [
|
||||
"Content-Disposition",
|
||||
"Content-Type",
|
||||
|
||||
@ -50,6 +50,7 @@
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-router": "^7.14.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-secure-storage": "^1.3.2",
|
||||
"sequelize": "^6.37.8",
|
||||
|
||||
108
apps/web/src/layout/app-company-switcher.tsx
Normal file
108
apps/web/src/layout/app-company-switcher.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
apps/web/src/layout/app-layout.tsx
Normal file
28
apps/web/src/layout/app-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
apps/web/src/layout/app-main.tsx
Normal file
11
apps/web/src/layout/app-main.tsx
Normal 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>;
|
||||
};
|
||||
162
apps/web/src/layout/app-sidebar-nav.tsx
Normal file
162
apps/web/src/layout/app-sidebar-nav.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
104
apps/web/src/layout/app-sidebar-settings-menu.tsx
Normal file
104
apps/web/src/layout/app-sidebar-settings-menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
97
apps/web/src/layout/app-sidebar.config.ts
Normal file
97
apps/web/src/layout/app-sidebar.config.ts
Normal 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 },
|
||||
];
|
||||
40
apps/web/src/layout/app-sidebar.tsx
Normal file
40
apps/web/src/layout/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
apps/web/src/layout/app-topbar.tsx
Normal file
109
apps/web/src/layout/app-topbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
4
apps/web/src/layout/index.ts
Normal file
4
apps/web/src/layout/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './app-layout';
|
||||
export * from './app-main';
|
||||
export * from './app-sidebar';
|
||||
export * from './app-topbar';
|
||||
@ -1,13 +1,14 @@
|
||||
// apps/web/src/routes/app-routes.tsx
|
||||
import type { IModuleClient } from "@erp/core/client";
|
||||
import { AppLayout } from "@repo/rdx-ui/components";
|
||||
import { Navigate, Route, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
|
||||
|
||||
import { ModuleRoutes } from "@/components/module-routes";
|
||||
import { AppLayout } from "@/layout";
|
||||
import ShadcnShowcasePage from "@/pages/shadcn-ui-page";
|
||||
import TailwindV4ShowcasePage from "@/pages/tailwindcss-page";
|
||||
|
||||
import { ErrorPage, LoginForm } from "../pages";
|
||||
import { modules } from "../register-modules"; // Aquí ca
|
||||
import { modules } from "../register-modules";
|
||||
|
||||
function groupModulesByLayout(modules: IModuleClient[]) {
|
||||
const groups: Record<string, IModuleClient[]> = {
|
||||
@ -15,15 +16,12 @@ function groupModulesByLayout(modules: IModuleClient[]) {
|
||||
app: [],
|
||||
};
|
||||
|
||||
if (modules) {
|
||||
for (const module of modules) {
|
||||
if (typeof module.layout !== "string") continue;
|
||||
const layout = typeof module.layout === "string" ? module.layout : "app";
|
||||
|
||||
const layout = module.layout || "app";
|
||||
groups[layout] = groups[layout] || [];
|
||||
groups[layout] = groups[layout] ?? [];
|
||||
groups[layout].push(module);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
@ -38,27 +36,29 @@ export const getAppRouter = () => {
|
||||
return createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route path="/">
|
||||
<Route element={<Navigate replace to="/proformas" />} index />
|
||||
|
||||
{/* Auth Layout */}
|
||||
<Route path="/auth">
|
||||
<Route element={<Navigate to="login" />} index />
|
||||
<Route path="auth">
|
||||
<Route element={<Navigate replace to="login" />} index />
|
||||
<Route element={<LoginForm />} path="login" />
|
||||
<Route element={<ModuleRoutes modules={grouped.auth} params={params} />} path="*" />
|
||||
</Route>
|
||||
|
||||
{/* App Layout */}
|
||||
<Route element={<AppLayout />}>
|
||||
{/* Dynamic Module Routes */}
|
||||
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
|
||||
|
||||
{/* Test */}
|
||||
<Route element={<ShadcnShowcasePage />} path="/shadcnui" />
|
||||
<Route element={<TailwindV4ShowcasePage />} path="/tailwindcss4" />
|
||||
<Route element={<ShadcnShowcasePage />} path="shadcnui" />
|
||||
<Route element={<TailwindV4ShowcasePage />} path="tailwindcss4" />
|
||||
|
||||
{/* Main Layout */}
|
||||
<Route element={<ErrorPage />} path="/dashboard" />
|
||||
<Route element={<ErrorPage />} path="/settings" />
|
||||
<Route element={<ErrorPage />} path="/catalog" />
|
||||
<Route element={<ErrorPage />} path="/quotes" />
|
||||
{/* Static / provisional routes */}
|
||||
<Route element={<ErrorPage />} path="dashboard" />
|
||||
<Route element={<ErrorPage />} path="settings" />
|
||||
<Route element={<ErrorPage />} path="catalog" />
|
||||
<Route element={<ErrorPage />} path="quotes" />
|
||||
|
||||
{/* Dynamic module routes. Keep this last. */}
|
||||
<Route element={<ModuleRoutes modules={grouped.app} params={params} />} path="*" />
|
||||
</Route>
|
||||
</Route>
|
||||
)
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * from './page-form-header';
|
||||
export * from './page-keyboard-shortcuts-button';
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./form-actions";
|
||||
export * from "./form-debug.tsx";
|
||||
export * from "./simple-search-input.tsx";
|
||||
|
||||
@ -51,14 +51,16 @@ export const PageHeader = ({
|
||||
|
||||
<div className="min-w-0 space-y-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}
|
||||
</h1>
|
||||
|
||||
{statusSlot}
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,2 @@
|
||||
export * from "./cancel-form-button";
|
||||
export * from "./form-commit-button-group";
|
||||
export * from "./submit-form-button";
|
||||
export * from "./unsaved-changes-cancel-action-button";
|
||||
export * from "./unsaved-changes-dialog";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
} from "../snapshot-builders";
|
||||
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders/full";
|
||||
import {
|
||||
ChangeStatusProformaUseCase,
|
||||
CreateProformaUseCase,
|
||||
GetProformaByIdUseCase,
|
||||
IssueProformaUseCase,
|
||||
@ -112,11 +113,18 @@ export function buildUpdateProformaUseCase(deps: {
|
||||
/*
|
||||
export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) {
|
||||
return new DeleteProformaUseCase(deps.finder);
|
||||
}
|
||||
}*/
|
||||
|
||||
export function buildChangeStatusProformaUseCase(deps: {
|
||||
finder: IProformaFinder;
|
||||
updater: IProformaUpdater;
|
||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}) {
|
||||
return new ChangeStatusProformaUseCase(deps.finder, deps.transactionManager);
|
||||
}*/
|
||||
return new ChangeStatusProformaUseCase({
|
||||
finder: deps.finder,
|
||||
updater: deps.updater,
|
||||
fullSnapshotBuilder: deps.fullSnapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@ import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { ChangeStatusProformaByIdRequestDTO } from "../../../../common";
|
||||
import { ProformaCustomerInvoiceDomainService } from "../../../domain";
|
||||
import type { CustomerInvoiceApplicationService } from "../../services";
|
||||
import type { IProformaFinder, IProformaUpdater } from "../services";
|
||||
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
type ChangeStatusProformaUseCaseInput = {
|
||||
companyId: UniqueID;
|
||||
@ -12,14 +12,24 @@ type ChangeStatusProformaUseCaseInput = {
|
||||
dto: ChangeStatusProformaByIdRequestDTO;
|
||||
};
|
||||
|
||||
export class ChangeStatusProformaUseCase {
|
||||
private readonly proformaDomainService: ProformaCustomerInvoiceDomainService;
|
||||
type ChangeStatusProformaUseCaseDeps = {
|
||||
finder: IProformaFinder;
|
||||
updater: IProformaUpdater;
|
||||
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly service: CustomerInvoiceApplicationService,
|
||||
private readonly transactionManager: ITransactionManager
|
||||
) {
|
||||
this.proformaDomainService = new ProformaCustomerInvoiceDomainService();
|
||||
export class ChangeStatusProformaUseCase {
|
||||
private readonly finder: IProformaFinder;
|
||||
private readonly updater: IProformaUpdater;
|
||||
private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder;
|
||||
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) {
|
||||
@ -29,27 +39,32 @@ export class ChangeStatusProformaUseCase {
|
||||
dto: { new_status },
|
||||
} = params;
|
||||
|
||||
const idOrError = UniqueID.create(proforma_id);
|
||||
if (idOrError.isFailure) return Result.fail(idOrError.error);
|
||||
const proformaIdOrError = UniqueID.create(proforma_id);
|
||||
if (proformaIdOrError.isFailure) {
|
||||
return Result.fail(proformaIdOrError.error);
|
||||
}
|
||||
|
||||
const proformaId = idOrError.data;
|
||||
const proformaId = proformaIdOrError.data;
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
/** 1. Recuperamos la proforma */
|
||||
const proformaResult = await this.service.getProformaByIdInCompany(
|
||||
const proformaResult = await this.finder.findProformaById(
|
||||
companyId,
|
||||
proformaId,
|
||||
transaction
|
||||
);
|
||||
if (proformaResult.isFailure) return Result.fail(proformaResult.error);
|
||||
if (proformaResult.isFailure) {
|
||||
return Result.fail(proformaResult.error);
|
||||
}
|
||||
|
||||
const proforma = proformaResult.data;
|
||||
|
||||
/** 2. Hacer el cambio de estado */
|
||||
const transitionResult = await this.proformaDomainService.transition(proforma, new_status!);
|
||||
if (transitionResult.isFailure) return Result.fail(transitionResult.error);
|
||||
|
||||
const updateResult = await this.service.updateProformaStatusByIdInCompany(
|
||||
const updateResult = await this.updater.updateProformaStatusByIdInCompany(
|
||||
companyId,
|
||||
proformaId,
|
||||
transitionResult.data.status,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
//export * from "./change-status-proforma.use-case";
|
||||
export * from "./change-status-proforma.use-case";
|
||||
export * from "./create-proforma";
|
||||
//export * from "./delete-proforma.use-case";
|
||||
export * from "./get-proforma-by-id.use-case";
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api";
|
||||
|
||||
import {
|
||||
type ChangeStatusProformaUseCase,
|
||||
type CreateProformaUseCase,
|
||||
type GetProformaByIdUseCase,
|
||||
type IIssuedInvoicePublicServices,
|
||||
@ -8,6 +9,7 @@ import {
|
||||
type ListProformasUseCase,
|
||||
type ReportProformaUseCase,
|
||||
type UpdateProformaByIdUseCase,
|
||||
buildChangeStatusProformaUseCase,
|
||||
buildCreateProformaUseCase,
|
||||
buildGetProformaByIdUseCase,
|
||||
buildIssueProformaUseCase,
|
||||
@ -38,11 +40,12 @@ export type ProformasInternalDeps = {
|
||||
issuedInvoiceServices: IIssuedInvoicePublicServices;
|
||||
}) => IssueProformaUseCase;
|
||||
updateProforma: () => UpdateProformaByIdUseCase;
|
||||
changeStatusProforma: () => ChangeStatusProformaUseCase;
|
||||
|
||||
/*
|
||||
deleteProforma: () => DeleteProformaUseCase;
|
||||
issueProforma: () => IssueProformaUseCase;
|
||||
changeStatusProforma: () => ChangeStatusProformaUseCase;*/
|
||||
|
||||
*/
|
||||
};
|
||||
};
|
||||
|
||||
@ -122,6 +125,14 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
issuer,
|
||||
transactionManager,
|
||||
}),
|
||||
|
||||
changeStatusProforma: () =>
|
||||
buildChangeStatusProformaUseCase({
|
||||
finder,
|
||||
updater,
|
||||
fullSnapshotBuilder: snapshotBuilders.full,
|
||||
transactionManager,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,12 +5,14 @@ import {
|
||||
requireCompanyContextGuard,
|
||||
} 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 {
|
||||
public constructor(private readonly useCase: ChangeStatusProformaUseCase) {
|
||||
super();
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
this.errorMapper = proformasApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(
|
||||
@ -21,7 +23,7 @@ export class ChangeStatusProformaController extends ExpressController {
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId(); // garantizado por tenantGuard
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Tenant ID not found");
|
||||
}
|
||||
@ -32,7 +34,8 @@ export class ChangeStatusProformaController extends ExpressController {
|
||||
}
|
||||
|
||||
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(
|
||||
(data) => this.ok(data),
|
||||
|
||||
@ -5,4 +5,4 @@ export * from "./get-proforma.controller";
|
||||
export * from "./issue-proforma.controller";
|
||||
export * from "./list-proformas.controller";
|
||||
export * from "./report-proforma.controller";
|
||||
//export * from "./update-proforma.controller";
|
||||
export * from "./update-proforma.controller";
|
||||
|
||||
@ -5,8 +5,8 @@ import {
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto/index.ts";
|
||||
import type { UpdateProformaByIdUseCase } from "../../../../application/index.ts";
|
||||
import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto";
|
||||
import type { UpdateProformaByIdUseCase } from "../../../../application";
|
||||
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
|
||||
|
||||
export class UpdateProformaController extends ExpressController {
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
} from "..";
|
||||
|
||||
import {
|
||||
ChangeStatusProformaByIdParamsRequestSchema,
|
||||
ChangeStatusProformaByIdRequestSchema,
|
||||
CreateProformaRequestSchema,
|
||||
GetProformaByIdRequestSchema,
|
||||
IssueProformaByIdParamsRequestSchema,
|
||||
@ -22,6 +24,7 @@ import {
|
||||
} from "../../../../common";
|
||||
import type { IIssuedInvoicePublicServices } from "../../../application";
|
||||
|
||||
import { ChangeStatusProformaController } from "./controllers/change-status-proforma.controller";
|
||||
import { CreateProformaController } from "./controllers/create-proforma.controller";
|
||||
import { UpdateProformaController } from "./controllers/update-proforma.controller";
|
||||
|
||||
@ -130,8 +133,6 @@ export const proformasRouter = (params: StartParams) => {
|
||||
}
|
||||
);*/
|
||||
|
||||
/*
|
||||
|
||||
router.patch(
|
||||
"/:proforma_id/status",
|
||||
//checkTabContext,
|
||||
@ -140,11 +141,11 @@ export const proformasRouter = (params: StartParams) => {
|
||||
validateRequest(ChangeStatusProformaByIdRequestSchema, "body"),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const useCase = deps.useCases.changeStatus_proforma();
|
||||
const useCase = deps.useCases.changeStatusProforma();
|
||||
const controller = new ChangeStatusProformaController(useCase);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);*/
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/:proforma_id/issue",
|
||||
|
||||
@ -351,9 +351,9 @@ export class ProformaRepository
|
||||
searchableFields: ["invoice_number", "reference", "description"],
|
||||
mappings: {
|
||||
invoice_date: "invoice_date",
|
||||
invoice_number: "invoice_number",
|
||||
reference: "reference",
|
||||
description: "description",
|
||||
invoice_number: "CustomerInvoiceModel.invoice_number",
|
||||
reference: "CustomerInvoiceModel.reference",
|
||||
description: "CustomerInvoiceModel.description",
|
||||
recipient_name: "current_customer.name",
|
||||
},
|
||||
sortableFields: [
|
||||
|
||||
@ -36,7 +36,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
|
||||
</ProformaLayout>
|
||||
),
|
||||
children: [
|
||||
{ path: "", index: true, element: <ProformasListPage /> }, // index
|
||||
{ index: true, element: <ProformasListPage /> }, // index
|
||||
{ path: "list", element: <ProformasListPage /> },
|
||||
//{ path: "create", element: <ProformaCreatePage /> },
|
||||
{ path: ":id/edit", element: <ProformaUpdatePage /> },
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
|
||||
import { useReturnToNavigation } from "@erp/core/hooks";
|
||||
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
@ -12,7 +14,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { useTranslation } from "../../../../i18n";
|
||||
@ -152,8 +161,8 @@ export const ListProformasPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<AppHeader>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
description={t("pages.proformas.list.description")}
|
||||
rightSlot={
|
||||
@ -168,9 +177,79 @@ export const ListProformasPage = () => {
|
||||
}
|
||||
title={t("pages.proformas.list.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
|
||||
<AppContent className="min-h-screen">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 hidden">
|
||||
<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-blue-100">
|
||||
<FileTextIcon className="size-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground">Total proformas</p>
|
||||
<p className="text-2xl font-bold text-foreground">1.248</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<span className="text-green-600 font-medium">↑ 12.5%</span> vs. mes anterior
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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-emerald-100">
|
||||
<CircleDollarSignIcon className="size-6 text-emerald-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground">Importe total</p>
|
||||
<p className="text-2xl font-bold text-foreground">125.430,75 €</p>
|
||||
<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>
|
||||
|
||||
<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-amber-100">
|
||||
<ClockIcon className="size-6 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
<span className="text-green-600 font-medium">↑ 5.2%</span> vs. mes anterior
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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"
|
||||
@ -247,7 +326,6 @@ export const ListProformasPage = () => {
|
||||
targets={deleteDialogCtrl.targets}
|
||||
/>
|
||||
</>
|
||||
</AppContent>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const ProformaLayout = ({ children }: PropsWithChildren) => {
|
||||
return <div className="space-y-4">{children}</div>;
|
||||
};
|
||||
interface ProformaLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProformaLayout({ children }: ProformaLayoutProps) {
|
||||
return <div className="flex flex-col h-full w-full">{children}</div>;
|
||||
}
|
||||
|
||||
@ -61,12 +61,12 @@ export const ProformaCompactTotals = ({
|
||||
};
|
||||
|
||||
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 */}
|
||||
|
||||
<CardContent
|
||||
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"
|
||||
)}
|
||||
>
|
||||
@ -176,15 +176,20 @@ export const ProformaCompactTotals = ({
|
||||
</CardContent>
|
||||
|
||||
{/* 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 */}
|
||||
<Button onClick={() => setIsExpanded(!isExpanded)} size="default" variant="outline">
|
||||
<Button onClick={() => setIsExpanded(!isExpanded)} variant="outline">
|
||||
{isExpanded ? <ChevronDown className="size-5" /> : <ChevronUp className="size-5" />}
|
||||
{isExpanded ? "Ocultar desglose" : "Ver desglose"}
|
||||
</Button>
|
||||
|
||||
{/* 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">
|
||||
<span className="text-muted-foreground">
|
||||
{t("proformas.update.totals.subtotalBeforeDiscounts", "Subtotal")}
|
||||
|
||||
@ -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 {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { ArrowLeftIcon, KeyboardIcon, MoreHorizontalIcon, SaveIcon, XIcon } from "lucide-react";
|
||||
CancelActionButton,
|
||||
FormActionsBar,
|
||||
type FormSecondaryAction,
|
||||
FormSecondaryActionsMenu,
|
||||
RhfSubmitActionButton,
|
||||
} from "@repo/rdx-ui/components";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ProformaUpdateHeaderLabels {
|
||||
title: string;
|
||||
back: string;
|
||||
modified: string;
|
||||
cancel: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
moreActions: string;
|
||||
keyboardShortcuts: string;
|
||||
duplicate: string;
|
||||
exportPdf: string;
|
||||
delete: string;
|
||||
}
|
||||
|
||||
export interface ProformaUpdateHeaderProps {
|
||||
onSave?: () => void;
|
||||
formId?: string;
|
||||
|
||||
labels: ProformaUpdateHeaderLabels;
|
||||
|
||||
onCancel?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onExportPdf?: () => void;
|
||||
onDelete?: () => void;
|
||||
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
isSaving?: boolean;
|
||||
hasChanges?: boolean;
|
||||
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ProformaUpdateHeader = ({
|
||||
onSave,
|
||||
formId,
|
||||
labels,
|
||||
onCancel,
|
||||
onDuplicate,
|
||||
onExportPdf,
|
||||
onDelete,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
isSaving = false,
|
||||
hasChanges = false,
|
||||
className,
|
||||
children,
|
||||
}: 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 (
|
||||
<header className="sticky top-0 z-20">
|
||||
<div className="flex h-14 items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={onCancel} size="icon" variant="outline">
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-xl font-semibold tracking-tight text-foreground lg:text-2xl">
|
||||
Editar proforma
|
||||
</h1>
|
||||
{hasChanges && <span className="size-2 rounded-full bg-amber-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Atajo de teclado info */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button className="hidden sm:flex" size="icon" variant="ghost">
|
||||
<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">
|
||||
<XIcon className="mr-2 size-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
|
||||
<Button onClick={onSave}>
|
||||
<SaveIcon className="mr-2 size-4" />
|
||||
Guardar
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
<PageFormHeader
|
||||
actions={
|
||||
<FormActionsBar align="end" reverseOnMobile={false}>
|
||||
<PageKeyboardShortcutsButton
|
||||
className="hidden sm:flex"
|
||||
label={labels.keyboardShortcuts}
|
||||
shortcuts={[
|
||||
{ keys: "Ctrl+S", label: labels.save },
|
||||
{ keys: "Esc", label: labels.cancel },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>Duplicar proforma</DropdownMenuItem>
|
||||
<DropdownMenuItem>Exportar PDF</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">Eliminar</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<CancelActionButton
|
||||
className="hidden sm:flex"
|
||||
disabled={computedDisabled}
|
||||
label={labels.cancel}
|
||||
onCancel={() => onCancel?.()}
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
<RhfSubmitActionButton
|
||||
busyLabel={labels.saving}
|
||||
disabled={computedDisabled || readOnly}
|
||||
formId={formId}
|
||||
isBusy={isSaving}
|
||||
label={labels.save}
|
||||
/>
|
||||
|
||||
<FormSecondaryActionsMenu
|
||||
actions={secondaryActions}
|
||||
disabled={computedDisabled}
|
||||
label={labels.moreActions}
|
||||
/>
|
||||
</FormActionsBar>
|
||||
}
|
||||
backLabel={labels.back}
|
||||
className={className}
|
||||
onBack={onCancel}
|
||||
showStatus={hasChanges}
|
||||
statusLabel={labels.modified}
|
||||
title={labels.title}
|
||||
>
|
||||
{children}
|
||||
</PageFormHeader>
|
||||
);
|
||||
};
|
||||
|
||||
@ -26,7 +26,7 @@ export const ProformaInfoAlert = ({
|
||||
return (
|
||||
<Alert
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { CustomerSelectionOption } from "@erp/customers";
|
||||
import { PercentageField } from "@repo/rdx-ui/components";
|
||||
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
|
||||
import {
|
||||
ProformaUpdatePaymentEditor,
|
||||
@ -17,9 +18,8 @@ import type {
|
||||
UseUpdateProformaTaxControllerResult,
|
||||
UseUpdateProformaTotalsControllerResult,
|
||||
} from "../../controllers";
|
||||
import { EditorSidebar, ProformaCompactTotals, ProformaTotalsSummary } from "../blocks";
|
||||
import { EditorSidebar } from "../blocks";
|
||||
import { NewProformaTotalsSummary } from "../blocks/new-proforma-totals-summary";
|
||||
import { ProformaInfoAlert } from "../components";
|
||||
|
||||
import { ProformaUpdateHeaderEditor } from "./proforma-update-header-editor";
|
||||
import { ProformaUpdateItemsEditor } from "./proforma-update-items-editor";
|
||||
@ -42,6 +42,8 @@ type ProformaUpdateEditorProps = {
|
||||
|
||||
currencyCode?: string;
|
||||
languageCode?: string;
|
||||
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProformaUpdateEditorForm = ({
|
||||
@ -58,23 +60,18 @@ export const ProformaUpdateEditorForm = ({
|
||||
paymentCtrl,
|
||||
currencyCode,
|
||||
languageCode,
|
||||
className,
|
||||
}: ProformaUpdateEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-6 2xl:space-y-12"
|
||||
className={cn("space-y-6 2xl:space-y-12", className)}
|
||||
id={formId}
|
||||
noValidate
|
||||
onKeyDown={preventEnterKeySubmitForm}
|
||||
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 */}
|
||||
<div className="flex flex-1 overflow-hidden hidden">
|
||||
{/* Área principal */}
|
||||
@ -93,7 +90,7 @@ export const ProformaUpdateEditorForm = ({
|
||||
</main>
|
||||
|
||||
{/* 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
|
||||
className="2xl:col-span-1"
|
||||
disabled={isSubmitting}
|
||||
@ -115,25 +112,6 @@ export const ProformaUpdateEditorForm = ({
|
||||
itemsCtrl={itemsCtrl}
|
||||
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 className="2xl:col-span-1 space-y-4 2xl:space-y-6">
|
||||
<ProformaUpdateRecipientEditor
|
||||
@ -147,6 +125,7 @@ export const ProformaUpdateEditorForm = ({
|
||||
<ProformaUpdateTaxEditor className="2xl:col-span-1" taxCtrl={taxCtrl} />
|
||||
|
||||
<NewProformaTotalsSummary
|
||||
className="hidden"
|
||||
currency={currencyCode}
|
||||
globalDiscountField={
|
||||
<PercentageField
|
||||
@ -163,9 +142,9 @@ export const ProformaUpdateEditorForm = ({
|
||||
totals={totalsCtrl.totals}
|
||||
/>
|
||||
|
||||
<ProformaUpdateSettingsEditor className="w-full bg-secondary ring-0" />
|
||||
<ProformaUpdateSettingsEditor className="w-full" />
|
||||
<ProformaUpdatePaymentEditor
|
||||
className="w-full bg-secondary ring-0"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || paymentCtrl.isLoading}
|
||||
paymentMethodOptions={paymentCtrl.paymentMethodOptions}
|
||||
paymentTermOptions={paymentCtrl.paymentTermOptions}
|
||||
@ -173,23 +152,6 @@ export const ProformaUpdateEditorForm = ({
|
||||
</div>
|
||||
</div>
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
|
||||
import {
|
||||
FormCommitButtonGroup,
|
||||
UnsavedChangesProvider,
|
||||
useReturnToNavigation,
|
||||
} from "@erp/core/hooks";
|
||||
import { ErrorAlert, NotFoundCard } from "@erp/core/components";
|
||||
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
|
||||
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 { useTranslation } from "../../../../i18n";
|
||||
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
|
||||
import { ProformaUpdateHeader } from "../blocks";
|
||||
import { ProformaUpdateSkeleton } from "../components";
|
||||
import { ProformaCompactTotals, ProformaUpdateHeader } from "../blocks";
|
||||
import { ProformaInfoAlert, ProformaUpdateSkeleton } from "../components";
|
||||
import { ProformaUpdateEditorForm } from "../editors";
|
||||
|
||||
export const ProformaUpdatePage = () => {
|
||||
@ -23,6 +19,8 @@ export const ProformaUpdatePage = () => {
|
||||
fallbackPath: returnTo,
|
||||
});
|
||||
|
||||
const handleCancel = () => navigateBack();
|
||||
|
||||
if (updateCtrl.isLoading) {
|
||||
return <ProformaUpdateSkeleton />;
|
||||
}
|
||||
@ -59,39 +57,39 @@ export const ProformaUpdatePage = () => {
|
||||
</AppContent>
|
||||
);
|
||||
|
||||
updateCtrl.form.d;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col overflow-y-scroll bg-muted">
|
||||
<FormProvider {...updateCtrl.form}>
|
||||
<UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
|
||||
<AppHeader className="mx-auto max-w-[100rem]">
|
||||
<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
|
||||
disabled={updateCtrl.isUpdating}
|
||||
formId={updateCtrl.formId}
|
||||
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 && (
|
||||
<ErrorAlert
|
||||
message={
|
||||
@ -101,8 +99,9 @@ export const ProformaUpdatePage = () => {
|
||||
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}
|
||||
@ -118,9 +117,33 @@ export const ProformaUpdatePage = () => {
|
||||
totalsCtrl={updateCtrl.totalsCtrl}
|
||||
/>
|
||||
|
||||
{/* Sticky Footer */}
|
||||
<footer className="sticky bottom-0 z-10 shrink-0">
|
||||
<ProformaCompactTotals
|
||||
className="bg-background"
|
||||
currency={updateCtrl.currencyCode}
|
||||
globalDiscountField={
|
||||
<PercentageField
|
||||
className="md:col-span-4 md:col-start-1"
|
||||
disabled={updateCtrl.isUpdating}
|
||||
inputClassName="bg-background"
|
||||
label={t(
|
||||
"proformas.update.totals.globalDiscountPercentage",
|
||||
"Descuento global"
|
||||
)}
|
||||
name="globalDiscountPercentage"
|
||||
/>
|
||||
}
|
||||
showRec={updateCtrl.taxCtrl.hasRecPercentage}
|
||||
showRetention={updateCtrl.taxCtrl.hasRetentionPercentage}
|
||||
totals={updateCtrl.totalsCtrl.totals}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
|
||||
</AppContent>
|
||||
</UnsavedChangesProvider>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
InputGroupInput,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
|
||||
|
||||
@ -54,7 +53,7 @@ export const DatePickerField = <TFormValues extends FieldValues>({
|
||||
spellCheck: false,
|
||||
};
|
||||
|
||||
const rightIcon = <CalendarIcon />;
|
||||
const rightIcon = null; //<CalendarX2Icon />;
|
||||
|
||||
// Obtener error del campo (tipado seguro)
|
||||
const fieldError = getFieldState(name, formState).error;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -35,12 +35,12 @@ export const FormSectionCard = ({
|
||||
<Card className={className}>
|
||||
{hasHeader && (
|
||||
<CardHeader className={headerClassName}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{icon ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-md",
|
||||
disabled ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
|
||||
"flex size-6 shrink-0 items-center justify-center",
|
||||
disabled ? "text-muted-foreground" : "text-primary"
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
|
||||
@ -9,7 +9,7 @@ interface FormSectionGridProps {
|
||||
|
||||
export const FormSectionGrid = ({ children, className }: FormSectionGridProps) => {
|
||||
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}
|
||||
</FieldGroup>
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ export * from "./checkbox-field.tsx";
|
||||
export * from "./date-picker-field.tsx";
|
||||
export * from "./date-picker-input-field/index.ts";
|
||||
export * from "./decimal-field/index.ts";
|
||||
export * from "./form-actions/index.ts";
|
||||
export * from "./form-field-label.tsx";
|
||||
export * from "./form-section-card.tsx";
|
||||
export * from "./form-section-grid.tsx";
|
||||
|
||||
@ -7,7 +7,10 @@ export const AppContent = ({
|
||||
...props
|
||||
}: PropsWithChildren<{ className?: string }>) => {
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -18,9 +18,7 @@ export const AppLayout = () => {
|
||||
{/* Aquí está el MAIN */}
|
||||
<SidebarInset className="app-main bg-muted ">
|
||||
<AppTopbar />
|
||||
<div className="flex flex-1 flex-col px-4 py-4 sm:px-6 sm:py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
|
||||
"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"]
|
||||
}
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import type * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
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) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...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({
|
||||
@ -24,31 +23,31 @@ function DropdownMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
className="isolate z-50 outline-none"
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
@ -56,19 +55,19 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@ -77,25 +76,25 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-variant={variant}
|
||||
{...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({
|
||||
@ -104,22 +103,22 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@ -132,15 +131,18 @@ function DropdownMenuSubContent({
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<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}
|
||||
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}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@ -150,17 +152,17 @@ function DropdownMenuCheckboxItem({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
checked={checked}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
@ -168,22 +170,16 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@ -192,16 +188,16 @@ function DropdownMenuRadioItem({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
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",
|
||||
className
|
||||
)}
|
||||
data-inset={inset}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
@ -209,58 +205,51 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@ -49,14 +49,18 @@
|
||||
--chart-3: #155dfc;
|
||||
--chart-4: #1447e6;
|
||||
--chart-5: #193cb8;
|
||||
--sidebar: #0b7ad0;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #ffffff;
|
||||
--sidebar-primary-foreground: #0b7ad0;
|
||||
--sidebar-accent: #4a8fe0;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #4a8fe0;
|
||||
--sidebar-ring: #ffffff;
|
||||
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #0f172a;
|
||||
|
||||
--sidebar-primary: #2563eb;
|
||||
--sidebar-primary-foreground: #ffffff;
|
||||
|
||||
--sidebar-accent: #f1f5f9;
|
||||
--sidebar-accent-foreground: #0f172a;
|
||||
|
||||
--sidebar-border: #e5e7eb;
|
||||
--sidebar-ring: #2563eb;
|
||||
|
||||
--font-sans: "Geist Variable", sans-serif;
|
||||
--font-serif: "Geist Variable", serif;
|
||||
|
||||
@ -282,6 +282,9 @@ importers:
|
||||
react-i18next:
|
||||
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)
|
||||
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:
|
||||
specifier: ^7.14.0
|
||||
version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user