Panel lateral

This commit is contained in:
David Arranz 2026-03-31 17:56:41 +02:00
parent 8e547ff551
commit 233aadf259
39 changed files with 861 additions and 759 deletions

View File

@ -1,230 +0,0 @@
import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Avatar,
AvatarFallback,
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import {
Building2Icon,
EyeIcon,
MailIcon,
MoreHorizontalIcon,
PhoneIcon,
User2Icon,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "../../../i18n";
import { AddressCell } from "../../../list/ui/components/address-cell";
import type { CustomerSummaryFormData } from "../../schemas";
type CustomerActionHandlers = {
onEdit?: (customer: CustomerSummaryFormData) => void;
onView?: (customer: CustomerSummaryFormData) => void;
onDelete?: (customer: CustomerSummaryFormData) => void;
};
function shortId(id: string) {
return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-";
}
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge className="gap-1 tracking-wide text-xs text-foreground/70" variant="outline">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"}
</Badge>
);
export const Soft = ({ children }: { children: React.ReactNode }) => (
<span className="text-muted-foreground">{children}</span>
);
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
<div className="grid gap-1 text-foreground text-sm my-1.5">
{customer.email_primary && (
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
<a className="group" href={`mailto:${customer.email_primary}`}>
{customer.email_primary}
</a>
</div>
)}
{customer.email_secondary && (
<div className="flex items-center gap-2">
<MailIcon className="size-3.5" />
{customer.email_secondary}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<PhoneIcon className="size-3.5 group" />
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
{customer.phone_secondary && <Soft> {customer.phone_secondary}</Soft>}
{customer.mobile_secondary && <Soft> {customer.mobile_secondary}</Soft>}
{false}
</div>
{false}
</div>
);
function initials(name: string) {
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((p) => p[0]?.toUpperCase() ?? "").join("") || "?";
}
function safeHttp(url: string) {
if (!url) return "#";
if (/^https?:\/\//i.test(url)) return url;
return `https://${url}`;
}
export function useCustomersListColumns(
handlers: CustomerActionHandlers = {}
): ColumnDef<CustomerSummaryFormData>[] {
const { t } = useTranslation();
const { onEdit, onView, onDelete } = handlers;
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(
() => [
// Identidad + estado + metadatos (columna compuesta)
{
id: "customer",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.customer")}
/>
),
accessorFn: (row) => row.name, // para ordenar/buscar por nombre
enableHiding: false,
size: 140,
minSize: 120,
cell: ({ row }) => {
const c = row.original;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
<div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2">
<CustomerStatusBadge status={c.status} />{" "}
<span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>}
</div>
<div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-base truncate">{c.tin}</span>}
<KindBadge isCompany={isCompany} />
</div>
</div>
</div>
);
},
},
// Contacto (emails, teléfonos, web)
{
id: "contact",
header: ({ column }) => (
<DataTableColumnHeader
className="text-left"
column={column}
title={t("pages.list.grid_columns.contact")}
/>
),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />,
},
// Dirección (múltiples campos en bloque)
{
id: "address",
header: t("pages.list.grid_columns.address"),
accessorFn: (r) =>
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell customer={row.original} />,
},
// Acciones
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader
className="text-right"
column={column}
title={t("pages.list.grid_columns.actions")}
/>
),
size: 64,
minSize: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
const customer = row.original;
const { website, email_primary } = customer;
return (
<div className="flex justify-end">
<div className="flex items-center gap-1">
<Button
aria-label="Ver cliente"
onClick={() => onView?.(customer)}
size="icon"
variant="ghost"
>
<EyeIcon className="size-4" />
</Button>
{0 === false && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label="More actions" size="icon" variant="ghost">
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onView?.(customer)}>Open</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit?.(customer)}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.open(safeHttp(website), "_blank")}>
Visit website
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(email_primary)}
>
Copy email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete?.(customer)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
},
},
],
[t, onEdit, onView, onDelete]
);
}

View File

@ -1,48 +0,0 @@
import { useSheetState } from "@repo/rdx-ui/hooks";
import { useCallback, useState } from "react";
import { useCustomerGetQuery } from "../../shared/hooks";
export const useCustomerSheetController = (initialCustomerId = "") => {
const [customerId, setCustomerId] = useState(initialCustomerId);
const sheetState = useSheetState({
defaultOpen: false,
defaultPinned: false,
defaultMode: "view",
});
const query = useCustomerGetQuery(customerId, {
enabled: Boolean(customerId),
});
const openCustomerSheet = useCallback(
(nextCustomerId: string) => {
setCustomerId(nextCustomerId);
sheetState.openInMode("view");
},
[sheetState]
);
const closeCustomerSheet = useCallback(() => {
sheetState.closeSheet();
}, [sheetState]);
return {
customer: query.data,
customerId,
setCustomerId,
openCustomerSheet,
closeCustomerSheet,
isLoading: query.isLoading,
isFetching: query.isFetching,
isError: query.isError,
error: query.error,
refetch: query.refetch,
sheetState,
};
};

View File

@ -0,0 +1,53 @@
import { type RightPanelMode, useRightPanelState } from "@repo/rdx-ui/hooks";
import { useCallback, useState } from "react";
import { useCustomerGetQuery } from "../../shared";
interface Options {
initialCustomerId?: string;
initialMode?: RightPanelMode;
initialOpen?: boolean;
}
export const useCustomerSummaryPanelController = ({
initialCustomerId = "",
initialMode = "view",
initialOpen = false,
}: Options = {}) => {
const [customerId, setCustomerId] = useState(initialCustomerId);
const panelState = useRightPanelState({
defaultMode: initialMode,
defaultVisibility: initialOpen ? "temporary" : "hidden",
});
const query = useCustomerGetQuery(customerId, {
enabled: Boolean(customerId),
});
const openCustomerPanel = useCallback(
(nextCustomerId: string, mode: RightPanelMode = "view") => {
setCustomerId(nextCustomerId);
if (panelState.isPinned) {
panelState.openPersistent(mode);
} else {
panelState.openTemporary(mode);
}
},
[panelState.isPinned, panelState.openTemporary, panelState.openPersistent]
);
const closePanel = useCallback(() => {
panelState.close();
setCustomerId("");
}, [panelState.close]);
return {
customer: query.data,
customerId,
openCustomerPanel,
closePanel,
panelState,
};
};

View File

@ -1,13 +1,48 @@
import { useCustomerSheetController } from "./use-customer-sheet.controller";
import type { RightPanelMode } from "@repo/rdx-ui/hooks";
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useCustomerSummaryPanelController } from "./use-customer-summary-panel.controller";
import { useListCustomersController } from "./use-list-customers.controller";
export function useListCustomersPageController() {
export const useListCustomersPageController = () => {
const listCtrl = useListCustomersController();
const sheetCtrl = useCustomerSheetController();
const [searchParams] = useSearchParams();
// -----------------------------
// URL → estado inicial (sync)
// -----------------------------
const initialPanelState = useMemo(() => {
const customerId = searchParams.get("customerId");
const panelMode = searchParams.get("panel") as RightPanelMode | null;
if (!customerId) {
return {
customerId: "",
mode: "view" as RightPanelMode,
open: false,
};
}
return {
customerId,
mode: panelMode ?? "view",
open: true,
};
}, [searchParams]);
// -----------------------------
// Controller con estado inicial
// -----------------------------
const panelCtrl = useCustomerSummaryPanelController({
initialCustomerId: initialPanelState.customerId,
initialMode: initialPanelState.mode,
initialOpen: initialPanelState.open,
});
return {
listCtrl,
sheetCtrl,
panelCtrl,
};
}
};

View File

@ -1,400 +0,0 @@
import {
Avatar,
AvatarFallback,
Badge,
Button,
Separator,
Sheet,
SheetContent,
SheetTitle,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
CopyIcon,
ExternalLinkIcon,
FileTextIcon,
GlobeIcon,
MailIcon,
MapPinIcon,
PencilIcon,
PhoneIcon,
PinIcon,
PinOffIcon,
TrendingUpIcon,
UserIcon,
XIcon,
} from "lucide-react";
import type { Customer } from "../../../../shared";
interface CustomerSheetProps {
customer?: Customer;
open: boolean;
onOpenChange: (open: boolean) => void;
pinned: boolean;
onPinnedChange: (pinned: boolean) => void;
onEdit?: (customer: Customer) => void;
}
// Datos de ejemplo ERP para demostración
const mockERPData = {
totalPurchases: 45750.8,
purchasesThisYear: 12350.25,
lastInvoices: [
{
id: "1",
number: "FAC-2024-0156",
date: "2024-01-15",
amount: 1250.0,
status: "paid" as const,
},
{
id: "2",
number: "FAC-2024-0142",
date: "2024-01-08",
amount: 890.5,
status: "paid" as const,
},
{
id: "3",
number: "FAC-2024-0128",
date: "2023-12-22",
amount: 2100.0,
status: "pending" as const,
},
{
id: "4",
number: "FAC-2023-0098",
date: "2023-11-30",
amount: 750.25,
status: "overdue" as const,
},
],
};
export const CustomerSheet = ({
customer,
open,
onOpenChange,
pinned,
onPinnedChange,
onEdit,
}: CustomerSheetProps) => {
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const getGoogleMapsUrl = (customer: Customer) => {
const fullAddress = `${customer.street}, ${customer.postalCode} ${customer.city}, ${customer.province}, ${customer.country}`;
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const getInvoiceStatusBadge = (status: "paid" | "pending" | "overdue") => {
switch (status) {
case "paid":
return (
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200" variant="outline">
Pagada
</Badge>
);
case "pending":
return (
<Badge className="bg-amber-50 text-amber-700 border-amber-200" variant="outline">
Pendiente
</Badge>
);
case "overdue":
return (
<Badge className="bg-red-50 text-red-700 border-red-200" variant="outline">
Vencida
</Badge>
);
}
};
// Combinar datos del cliente con datos mock de ERP
const customerWithERP = { ...customer, ...mockERPData };
return (
<Sheet modal={!pinned} onOpenChange={onOpenChange} open={open}>
<SheetContent
className={cn("flex w-full flex-col p-0 sm:max-w-md", pinned && "shadow-none")}
showCloseButton={false}
side="right"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<SheetTitle className="sr-only">Ficha de cliente</SheetTitle>
<div className="flex items-center gap-2">
<Button
onClick={() => onPinnedChange(!pinned)}
size="icon-sm"
title={pinned ? "Desfijar panel" : "Fijar panel"}
variant="ghost"
>
{pinned ? <PinOffIcon className="size-4" /> : <PinIcon className="size-4" />}
</Button>
</div>
<div className="flex items-center gap-1">
<Button onClick={() => null /*onEdit?.(customer?)*/} size="icon-sm" variant="ghost">
<PencilIcon className="size-4" />
<span className="sr-only">Editar</span>
</Button>
{!pinned && (
<Button onClick={() => onOpenChange(false)} size="icon-sm" variant="ghost">
<XIcon className="size-4" />
<span className="sr-only">Cerrar</span>
</Button>
)}
</div>
</div>
{/* Contenido scrolleable */}
{customer ? (
<div className="flex-1 overflow-y-auto">
{/* Info principal del cliente */}
<div className="p-4">
<div className="flex items-start gap-4">
<Avatar className="size-14 border-2 border-background shadow-md">
<AvatarFallback
className={
customer.status === "active"
? "bg-emerald-100 text-emerald-700 text-lg"
: "bg-muted text-muted-foreground text-lg"
}
>
{getInitials(customer.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-foreground truncate">
{customer.name}
</h2>
<Badge
className="shrink-0"
variant={customer.status === "active" ? "default" : "secondary"}
>
{customer.status === "active" ? "Activo" : "Inactivo"}
</Badge>
</div>
{customer.tradeName && (
<p className="text-sm text-muted-foreground truncate">{customer.tradeName}</p>
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
Empresa
</>
) : (
<>
<UserIcon className="size-3" />
Particular
</>
)}
</Badge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground transition-colors"
onClick={() => copyToClipboard(customer.tin)}
title="Copiar NIF/CIF"
type="button"
>
{customer.tin}
<CopyIcon className="size-3" />
</button>
</div>
</div>
</div>
</div>
<Separator />
{/* Datos de contacto */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">Contacto</h3>
<div className="space-y-2.5">
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`mailto:${customer.primaryEmail}`}
>
<MailIcon className="size-4 shrink-0" />
<span className="truncate">{customer.primaryEmail}</span>
</a>
{customer.secondaryEmail && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`mailto:${customer.secondaryEmail}`}
>
<MailIcon className="size-4 shrink-0" />
<span className="truncate">{customer.secondaryEmail}</span>
</a>
)}
{customer.primaryPhone && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`tel:${customer.primaryPhone}`}
>
<PhoneIcon className="size-4 shrink-0" />
<span>{customer.primaryPhone}</span>
</a>
)}
{customerWithERP.primaryMobile && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={`tel:${customerWithERP.primaryMobile}`}
>
<PhoneIcon className="size-4 shrink-0" />
<span>{customerWithERP.primaryMobile}</span>
</a>
)}
{customerWithERP.website && (
<a
className="flex items-center gap-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
href={customerWithERP.website}
rel="noopener noreferrer"
target="_blank"
>
<GlobeIcon className="size-4 shrink-0" />
<span className="truncate">{customerWithERP.website}</span>
<ExternalLinkIcon className="size-3 shrink-0" />
</a>
)}
</div>
</div>
<Separator />
{/* Dirección */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3">Dirección</h3>
<a
className="group flex items-start gap-3 rounded-lg p-2 -m-2 transition-colors hover:bg-muted"
href={getGoogleMapsUrl(customer)}
rel="noopener noreferrer"
target="_blank"
>
<MapPinIcon className="size-4 shrink-0 mt-0.5 text-muted-foreground group-hover:text-primary" />
<div className="text-sm text-muted-foreground group-hover:text-foreground">
<p>{customer.street}</p>
<p>
{customer.postalCode} {customer.city}
</p>
<p>
{customer.province}, {customer.country}
</p>
</div>
<ExternalLinkIcon className="size-3 shrink-0 mt-0.5 text-muted-foreground opacity-0 group-hover:opacity-100" />
</a>
</div>
<Separator />
{/* Volumen de compras */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<TrendingUpIcon className="size-4" />
Volumen de compras
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Total histórico</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(customerWithERP.totalPurchases || 0)}
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Este año</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(customerWithERP.purchasesThisYear || 0)}
</p>
</div>
</div>
</div>
<Separator />
{/* Últimas facturas */}
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<FileTextIcon className="size-4" />
Últimas facturas
</h3>
<div className="space-y-2">
{customerWithERP.lastInvoices?.map((invoice) => (
<button
className="w-full flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-muted"
key={invoice.id}
onClick={() => console.log("Ver factura:", invoice.number)}
type="button"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{invoice.number}
</p>
<p className="text-xs text-muted-foreground">{formatDate(invoice.date)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-medium">{formatCurrency(invoice.amount)}</span>
{getInvoiceStatusBadge(invoice.status)}
</div>
</button>
))}
</div>
<Button className="w-full mt-3" size="sm" variant="outline">
Ver todas las facturas
</Button>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-sm text-muted-foreground">Selecciona un cliente</p>
</div>
)}
{/* Footer con acciones */}
<div className="border-t p-4">
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => console.log("Crear proforma para:", customer?.id)}
variant="outline"
>
<FileTextIcon className="size-4 mr-2" />
Nueva proforma
</Button>
<Button className="flex-1" onClick={() => null /*onEdit?.(customer?)*/}>
<PencilIcon className="size-4 mr-2" />
Editar cliente
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
};

View File

@ -1 +0,0 @@
export * from "./customer-sheet";

View File

@ -0,0 +1,26 @@
import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
export const CustomerAddressSection = ({ customer }: { customer: Customer }) => {
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
`${customer.street}, ${customer.city}`
)}`;
return (
<div className="p-4">
<h3 className="mb-3 text-sm font-medium">Dirección</h3>
<a className="flex gap-3 text-sm" href={url} rel="noopener noreferrer" target="_blank">
<MapPinIcon className="size-4 mt-0.5" />
<div>
<p>{customer.street}</p>
<p>
{customer.postalCode} {customer.city}
</p>
</div>
<ExternalLinkIcon className="size-3" />
</a>
</div>
);
};

View File

@ -0,0 +1,32 @@
import { MailIcon, PhoneIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
export const CustomerContactSection = ({ customer }: { customer: Customer }) => {
return (
<div className="p-4">
<h3 className="mb-3 text-sm font-medium">Contacto</h3>
<div className="space-y-2.5">
<a className="flex items-center gap-3 text-sm" href={`mailto:${customer.primaryEmail}`}>
<MailIcon className="size-4" />
<span className="truncate">{customer.primaryEmail}</span>
</a>
{customer.secondaryEmail && (
<a className="flex items-center gap-3 text-sm" href={`mailto:${customer.secondaryEmail}`}>
<MailIcon className="size-4" />
<span className="truncate">{customer.secondaryEmail}</span>
</a>
)}
{customer.primaryPhone && (
<a className="flex items-center gap-3 text-sm" href={`tel:${customer.primaryPhone}`}>
<PhoneIcon className="size-4" />
<span>{customer.primaryPhone}</span>
</a>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,27 @@
import { Button } from "@repo/shadcn-ui/components";
import type { Customer } from "../../../../shared";
export const CustomerFooterActions = ({
customer,
onCreateInvoice,
onEdit,
}: {
customer: Customer;
onCreateInvoice?: (customer: Customer) => void;
onEdit?: (customer: Customer) => void;
}) => {
return (
<div className="border-t p-4">
<div className="flex gap-2">
<Button className="flex-1" onClick={() => onCreateInvoice?.(customer)} variant="outline">
Nueva factura
</Button>
<Button className="flex-1" onClick={() => onEdit?.(customer)}>
Editar cliente
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,63 @@
import { Avatar, AvatarFallback, Badge } from "@repo/shadcn-ui/components";
import { Building2Icon, CopyIcon, UserIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
import { Initials } from "../../components";
export const CustomerHeader = ({ customer }: { customer: Customer }) => {
return (
<div className="p-4">
<div className="flex items-start gap-4">
<Avatar className="size-10 border-2 border-background shadow-sm">
<AvatarFallback
className={
customer.status === "active"
? "bg-blue-100 text-blue-700"
: "bg-muted text-muted-foreground"
}
>
<Initials name={customer.name} />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-lg font-semibold">{customer.name}</h2>
<Badge variant={customer.status === "active" ? "default" : "secondary"}>
{customer.status === "active" ? "Activo" : "Inactivo"}
</Badge>
</div>
{customer.tradeName && (
<p className="truncate text-sm text-muted-foreground">{customer.tradeName}</p>
)}
<div className="mt-2 flex items-center gap-2">
<Badge className="gap-1" variant="outline">
{customer.isCompany ? (
<>
<Building2Icon className="size-3" />
Empresa
</>
) : (
<>
<UserIcon className="size-3" />
Particular
</>
)}
</Badge>
<button
className="flex items-center gap-1 text-xs font-mono text-muted-foreground hover:text-foreground"
onClick={() => navigator.clipboard.writeText(customer.tin)}
type="button"
>
{customer.tin}
<CopyIcon className="size-3" />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,83 @@
import { Badge, Button } from "@repo/shadcn-ui/components";
import { FileTextIcon } from "lucide-react";
const getInvoiceStatusBadge = (status: "paid" | "pending" | "overdue") => {
switch (status) {
case "paid":
return (
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200" variant="outline">
Pagada
</Badge>
);
case "pending":
return (
<Badge className="bg-amber-50 text-amber-700 border-amber-200" variant="outline">
Pendiente
</Badge>
);
case "overdue":
return (
<Badge className="bg-red-50 text-red-700 border-red-200" variant="outline">
Vencida
</Badge>
);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
export const CustomerProformasSection = ({
proformas,
onProformaClick,
}: {
proformas: {
id: string;
number: string;
date: string;
amount: number;
status: "paid" | "pending" | "overdue";
}[];
onProformaClick?: (id: string) => void;
}) => {
return (
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<FileTextIcon className="size-4" />
Últimas proformas
</h3>
<div className="space-y-2">
{proformas.map((pro) => (
<Button
className="w-full flex items-center justify-between text-left transition-colors h-16 cursor-pointer"
key={pro.id}
onClick={() => onProformaClick?.(pro.id)}
variant={"outline"}
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">{pro.number}</p>
<p className="text-xs text-muted-foreground">{formatDate(pro.date)}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-medium">{formatCurrency(pro.amount)}</span>
{getInvoiceStatusBadge(pro.status)}
</div>
</Button>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,33 @@
import { TrendingUpIcon } from "lucide-react";
export const CustomerStatsSection = ({
stats,
}: {
stats: { currency: string; totalPurchases: number; purchasesThisYear: number };
}) => {
const formatCurrency = (value: number) =>
new Intl.NumberFormat("es-ES", { style: "currency", currency: stats.currency }).format(value);
return (
<div className="p-4">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<TrendingUpIcon className="size-4" />
Volumen de compras
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Total histórico</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(stats.totalPurchases || 0)}
</p>
</div>
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-xs text-muted-foreground">Este año</p>
<p className="text-lg font-semibold text-foreground">
{formatCurrency(stats.purchasesThisYear || 0)}
</p>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,68 @@
import { Separator } from "@repo/shadcn-ui/components";
import type { Customer } from "../../../../shared";
import { CustomerAddressSection } from "./customer-address-section";
import { CustomerContactSection } from "./customer-contact-section";
import { CustomerFooterActions } from "./customer-footer-actions";
import { CustomerHeader } from "./customer-header";
import { CustomerProformasSection } from "./customer-proformas-section";
import { CustomerStatsSection } from "./customer-stats-section";
interface CustomerSummaryContentProps {
customer: Customer;
stats: {
totalPurchases: number;
purchasesThisYear: number;
};
proformas: {
id: string;
number: string;
date: string;
amount: number;
status: "paid" | "pending" | "overdue";
}[];
onEdit?: (customer: Customer) => void;
onCreateInvoice?: (customer: Customer) => void;
onProformaClick?: (proformaId: string) => void;
}
export const CustomerSummaryContent = ({
customer,
stats,
proformas,
onEdit,
onCreateInvoice,
onProformaClick,
}: CustomerSummaryContentProps) => {
return (
<div className="flex flex-col">
<CustomerHeader customer={customer} />
<Separator />
<CustomerContactSection customer={customer} />
<Separator />
<CustomerAddressSection customer={customer} />
<Separator />
<CustomerStatsSection stats={{ ...stats, currency: customer.currencyCode }} />
<Separator />
<CustomerProformasSection onProformaClick={onProformaClick} proformas={proformas} />
<CustomerFooterActions
customer={customer}
onCreateInvoice={onCreateInvoice}
onEdit={onEdit}
/>
</div>
);
};

View File

@ -0,0 +1,123 @@
import { RightPanel } from "@repo/rdx-ui/components";
import type { RightPanelMode, RightPanelVisibility } from "@repo/rdx-ui/hooks";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { PencilIcon, PinIcon, PinOffIcon } from "lucide-react";
import type { Customer } from "../../../../shared";
import { CustomerSummaryContent } from "./customer-summary-content";
const mockERPData = {
totalPurchases: 45750.8,
purchasesThisYear: 12350.25,
lastInvoices: [
{
id: "1",
number: "FAC-2024-0156",
date: "2024-01-15",
amount: 1250.0,
status: "paid" as const,
},
{
id: "2",
number: "FAC-2024-0142",
date: "2024-01-08",
amount: 890.5,
status: "paid" as const,
},
{
id: "3",
number: "FAC-2024-0128",
date: "2023-12-22",
amount: 2100.0,
status: "pending" as const,
},
{
id: "4",
number: "FAC-2023-0098",
date: "2023-11-30",
amount: 750.25,
status: "overdue" as const,
},
],
};
interface CustomerSummaryPanelProps {
customer?: Customer;
open: boolean;
visibility: RightPanelVisibility;
mode: RightPanelMode;
onOpenChange: (open: boolean) => void;
onTogglePinned: () => void;
onEdit?: (customer: Customer) => void;
className?: string;
}
export const CustomerSummaryPanel = ({
customer,
open,
visibility,
mode,
onOpenChange,
onTogglePinned,
onEdit,
className,
}: CustomerSummaryPanelProps) => {
const isPinned = visibility === "persistent";
const titleMap: Record<RightPanelMode, string> = {
view: "Ficha de cliente",
edit: "Editar cliente",
create: "Nuevo cliente",
};
return (
<RightPanel
className={cn("bg-transparent", className)}
headerActions={
<>
<Button
aria-label={isPinned ? "Desfijar panel" : "Fijar panel"}
aria-pressed={isPinned}
onClick={onTogglePinned}
size="icon-sm"
variant="ghost"
>
{isPinned ? <PinOffIcon className="size-4" /> : <PinIcon className="size-4" />}
</Button>
{customer ? (
<Button
aria-label="Editar cliente"
onClick={() => onEdit?.(customer)}
size="icon-sm"
variant="ghost"
>
<PencilIcon className="size-4" />
</Button>
) : null}
</>
}
onOpenChange={onOpenChange}
open={open}
title={titleMap[mode]}
>
{customer ? (
<CustomerSummaryContent
customer={customer}
onEdit={onEdit}
proformas={mockERPData.lastInvoices}
stats={mockERPData}
/>
) : (
<div className="flex h-full items-center justify-center p-4">
<p className="text-sm text-muted-foreground">Selecciona un cliente</p>
</div>
)}
</RightPanel>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-summary-panel";

View File

@ -132,7 +132,7 @@ export function useCustomersGridColumns(
enableSorting: false,
size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell customer={row.original} />,
cell: ({ row }) => <AddressCell address={row.original} />,
},
{
id: "actions",

View File

@ -1,2 +1,2 @@
export * from "./customer-sheet";
export * from "./customer-summary-panel";
export * from "./customers-grid";

View File

@ -1,35 +1,44 @@
import { MapPinIcon } from "lucide-react";
import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
import { useTranslation } from "../../../i18n";
import type { CustomerListRow } from "../../../shared";
const getGoogleMapsUrl = (customer: CustomerListRow) => {
const fullAddress = `${customer.street}, ${customer.postalCode} ${customer.city}, ${customer.province}, ${customer.country}`;
export interface CustomerAddress {
street: string;
street2: string;
postalCode: string;
city: string;
province: string;
country: string;
}
const getGoogleMapsUrl = (adress: CustomerAddress) => {
const fullAddress = `${adress.street}, ${adress.postalCode} ${adress.city}, ${adress.province}, ${adress.country}`;
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
};
export const AddressCell = ({ customer }: { customer: CustomerListRow }) => {
export const AddressCell = ({ address }: { address: CustomerAddress }) => {
const { t } = useTranslation();
const line1 = [customer.street, customer.street2].filter(Boolean).join(", ");
const line2 = [customer.postalCode, customer.city].filter(Boolean).join(" ");
const line3 = [customer.province, customer.country].filter(Boolean).join(", ");
const line1 = [address.street, address.street2].filter(Boolean).join(", ");
const line2 = [address.postalCode, address.city].filter(Boolean).join(" ");
const line3 = [address.province, address.country].filter(Boolean).join(", ");
return (
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
<a
aria-label={t("components.address_cell.open_in_google_maps")}
className="group/address flex items-start gap-2 rounded-md p-1 -m-1 transition-colors hover:bg-muted"
href={getGoogleMapsUrl(customer)}
href={getGoogleMapsUrl(address)}
rel="noopener noreferrer"
target="_blank"
title={t("components.address_cell.open_in_google_maps")}
>
<MapPinIcon className="mt-0.5 size-3.5" />
<div className="text-sm">
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground group-hover:text-primary" />
<div className="text-sm text-muted-foreground group-hover:text-foreground">
<p>{line1}</p>
<p>
{line2} · {line3}
{line2} - {line3}
</p>
</div>
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
</a>
</address>
);

View File

@ -7,18 +7,18 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { ErrorAlert } from "../../../shared/ui";
import { useListCustomersPageController } from "../../controllers";
import { CustomerSheet, CustomersGrid, useCustomersGridColumns } from "../blocks";
import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks";
export const ListCustomersPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { listCtrl, sheetCtrl } = useListCustomersPageController();
const { listCtrl, panelCtrl } = useListCustomersPageController();
const columns = useCustomersGridColumns({
onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`),
onViewClick: (customer) => navigate(`/customers/${customer.id}`),
onSummaryClick: (customer) => sheetCtrl.openCustomerSheet(customer.id),
onSummaryClick: (customer) => panelCtrl.openCustomerPanel(customer.id, "view"),
//onDeleteClick: (customer) => null, //confirmDelete(inv.id),
});
@ -51,38 +51,43 @@ export const ListCustomersPage = () => {
title={t("pages.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isFetching}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
<AppContent className="min-h-0">
<div className="flex min-h-0 flex-1 overflow-hidden gap-4">
<div className="min-w-0 flex-1 overflow-auto">
<div className="flex items-center justify-between gap-16">
<SimpleSearchInput
loading={listCtrl.isFetching}
onSearchChange={listCtrl.setSearchValue}
value={listCtrl.search}
/>
</div>
<CustomersGrid
columns={columns}
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={() => null}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
</div>
<CustomerSummaryPanel
customer={panelCtrl.customer}
mode={panelCtrl.panelState.mode}
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
onOpenChange={(open) => {
if (open) panelCtrl.panelState.onOpenChange(true);
else panelCtrl.closePanel();
}}
onTogglePinned={panelCtrl.panelState.togglePinned}
open={panelCtrl.panelState.isOpen}
visibility={panelCtrl.panelState.visibility}
/>
</div>
<CustomersGrid
columns={columns}
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
// acciones rápidas del grid → page controller
onRowClick={(id) => null}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
{/* Customer Sheet */}
<CustomerSheet
customer={sheetCtrl.customer}
//mode={sheetCtrl.sheet.mode}
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
onOpenChange={sheetCtrl.sheetState.onOpenChange}
onPinnedChange={sheetCtrl.sheetState.setPinned}
open={sheetCtrl.sheetState.sheetIsOpen}
pinned={sheetCtrl.sheetState.sheetIsPinned}
/>
</AppContent>
</>
);

View File

@ -10,7 +10,7 @@
"./helpers": "./src/helpers/index.ts",
"./globals.css": "./src/styles/globals.css",
"./postcss.config": "./postcss.config.mjs",
"./components": "./src/components/index.tsx",
"./components": "./src/components/index.ts",
"./components/*": "./src/components/*.tsx",
"./locales/*": "./src/locales/*",
"./hooks": [

View File

@ -0,0 +1,69 @@
// entity-sheet-shell.tsx
import { Button, Sheet, SheetContent, SheetTitle } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { PinIcon, PinOffIcon, XIcon } from "lucide-react";
import type { ReactNode } from "react";
export interface EntitySheetShellProps {
open: boolean;
pinned: boolean;
title: string;
onOpenChange: (open: boolean) => void;
onTogglePinned: () => void;
headerActions?: ReactNode;
children: ReactNode;
}
export const EntitySheetShell = ({
open,
pinned,
title,
onOpenChange,
onTogglePinned,
headerActions,
children,
}: EntitySheetShellProps) => {
return (
<Sheet modal={!pinned} onOpenChange={onOpenChange} open={open}>
<SheetContent
className={cn("flex w-full flex-col p-0 sm:max-w-md", pinned && "shadow-none")}
showCloseButton={false}
side="right"
>
<div className="flex items-center justify-between border-b px-4 py-3">
<SheetTitle className="sr-only">{title}</SheetTitle>
<div className="flex items-center gap-2">
<Button
aria-label={pinned ? "Desfijar panel" : "Fijar panel"}
aria-pressed={pinned}
onClick={onTogglePinned}
size="icon-sm"
title={pinned ? "Desfijar panel" : "Fijar panel"}
variant="ghost"
>
{pinned ? <PinOffIcon className="size-4" /> : <PinIcon className="size-4" />}
</Button>
</div>
<div className="flex items-center gap-1">
{headerActions}
{pinned ? null : (
<Button
aria-label="Cerrar panel"
onClick={() => onOpenChange(false)}
size="icon-sm"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">{children}</div>
</SheetContent>
</Sheet>
);
};

View File

@ -0,0 +1 @@
export * from "./entity-sheet-shell.tsx";

View File

@ -1,9 +1,7 @@
export * from "./date-picker-input-field/index.ts";
export * from "./DatePickerField.tsx";
export * from "./date-picker-input-field/index.ts";
export * from "./multi-select-field.tsx";
export * from "./SelectField.tsx";
export * from "./TextAreaField.tsx";
export * from "./TextField.tsx";
export type * from "./types.d.ts";

View File

@ -1,16 +1,18 @@
export * from "./buttons/index.tsx";
export * from "./buttons/index.ts";
export * from "./custom-dialog.tsx";
export * from "./datatable/index.tsx";
export * from "./datatable/index.ts";
export * from "./dynamics-tabs.tsx";
export * from "./entity-sheet/index.ts";
export * from "./error-overlay.tsx";
export * from "./form/index.tsx";
export * from "./form/index.ts";
export * from "./full-screen-modal.tsx";
export * from "./grid/index.ts";
export * from "./layout/index.tsx";
export * from "./loading-overlay/index.tsx";
export * from "./layout/index.ts";
export * from "./loading-overlay/index.ts";
export * from "./logo-verifactu.tsx";
export * from "./lookup-dialog/index.tsx";
export * from "./lookup-dialog/index.ts";
export * from "./multi-select.tsx";
export * from "./multiple-selector.tsx";
export * from "./right-panel/index.ts";
export * from "./scroll-to-top.tsx";
export * from "./tailwind-indicator.tsx";

View File

@ -11,16 +11,16 @@ import {
export const AppBreadcrumb = () => {
return (
<header className='app-breadcrumb flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12'>
<div className='flex items-center gap-2 px-6'>
<SidebarTrigger className='-ml-1' />
<Separator orientation='vertical' className='mr-2 h-4' />
<header className="app-breadcrumb flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-6">
<SidebarTrigger className="-ml-1" />
<Separator className="mr-2 h-4" orientation="vertical" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className='hidden md:block'>
<BreadcrumbLink href='#'>Building Your Application</BreadcrumbLink>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className='hidden md:block' />
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>

View File

@ -0,0 +1 @@
export * from "./right-panel.tsx";

View File

@ -0,0 +1,10 @@
import type { ReactNode } from "react";
export interface RightPanelProps {
open: boolean;
title: string;
className?: string;
headerActions?: ReactNode;
children: ReactNode;
onOpenChange?: (open: boolean) => void;
}

View File

@ -0,0 +1,53 @@
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { XIcon } from "lucide-react";
import type { RightPanelProps } from "./right-panel-types.ts";
export const RightPanel = ({
open,
title,
className,
headerActions,
children,
onOpenChange,
}: RightPanelProps) => {
if (!open) return null;
return (
<aside
aria-labelledby="right-panel-title"
className={cn(
"ml-4", // separación real
"border bg-background",
"w-[420px] xl:w-[460px]",
className
)}
>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-3">
<h2 className="truncate text-sm font-semibold" id="right-panel-title">
{title}
</h2>
<div className="flex items-center gap-1">
{headerActions}
{onOpenChange ? (
<Button
aria-label="Cerrar panel"
onClick={() => onOpenChange(false)}
size="icon-sm"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
) : null}
</div>
</div>
<div className="flex-1 overflow-auto">{children}</div>
</div>
</aside>
);
};

View File

@ -1,3 +1,4 @@
export * from "./sheet/index.ts";
export * from "./side-panel/index.ts";
export * from "./use-device-info.ts";
export * from "./use-row-selection.ts";

View File

@ -0,0 +1 @@
export * from "./use-side-panel-state.ts";

View File

@ -0,0 +1,88 @@
import { useCallback, useState } from "react";
export type RightPanelMode = "view" | "edit" | "create";
export type RightPanelVisibility = "hidden" | "temporary" | "persistent";
export interface RightPanelStateOptions {
defaultMode?: RightPanelMode;
defaultVisibility?: RightPanelVisibility;
}
export interface RightPanelState {
mode: RightPanelMode;
visibility: RightPanelVisibility;
isOpen: boolean;
isPinned: boolean;
}
export interface RightPanelStateActions {
onOpenChange: (next: boolean) => void;
openTemporary: (mode?: RightPanelMode) => void;
openPersistent: (mode?: RightPanelMode) => void;
togglePinned: () => void;
close: () => void;
reset: () => void;
}
export type RightPanelStateController = RightPanelState & RightPanelStateActions;
const DEFAULT_MODE: RightPanelMode = "view";
const DEFAULT_VISIBILITY: RightPanelVisibility = "hidden";
export const useRightPanelState = (
options: RightPanelStateOptions = {}
): RightPanelStateController => {
const { defaultMode = DEFAULT_MODE, defaultVisibility = DEFAULT_VISIBILITY } = options;
const [mode, setMode] = useState<RightPanelMode>(defaultMode);
const [visibility, setVisibility] = useState<RightPanelVisibility>(defaultVisibility);
const isOpen = visibility !== "hidden";
const isPinned = visibility === "persistent";
const close = useCallback(() => {
setVisibility("hidden");
}, []);
const onOpenChange = useCallback((next: boolean) => {
setVisibility((prev) => {
if (!next) return "hidden";
return prev === "persistent" ? "persistent" : "temporary";
});
}, []);
const openTemporary = useCallback((nextMode: RightPanelMode = DEFAULT_MODE) => {
setMode(nextMode);
setVisibility("temporary");
}, []);
const openPersistent = useCallback((nextMode: RightPanelMode = DEFAULT_MODE) => {
setMode(nextMode);
setVisibility("persistent");
}, []);
const togglePinned = useCallback(() => {
setVisibility((prev) => {
if (prev === "hidden") return prev;
return prev === "persistent" ? "temporary" : "persistent";
});
}, []);
const reset = useCallback(() => {
setMode(defaultMode);
setVisibility(defaultVisibility);
}, [defaultMode, defaultVisibility]);
return {
mode,
visibility,
isOpen,
isPinned,
onOpenChange,
openTemporary,
openPersistent,
togglePinned,
close,
reset,
};
};

View File

@ -1,5 +1,5 @@
export const PACKAGE_NAME = "rdx-ui";
export * from "./components/index.tsx";
export * from "./components/index.ts";
export * from "./helpers/index.ts";
export * from "./hooks/index.ts";

View File

@ -1,7 +1,6 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@repo/shadcn-ui/lib/utils"
import { cn } from "@repo/shadcn-ui/lib/utils";
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
function Separator({
className,
@ -11,16 +10,16 @@ function Separator({
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch h-[1px]",
className
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };