Panel lateral
This commit is contained in:
parent
8e547ff551
commit
233aadf259
@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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";
|
import { useListCustomersController } from "./use-list-customers.controller";
|
||||||
|
|
||||||
export function useListCustomersPageController() {
|
export const useListCustomersPageController = () => {
|
||||||
const listCtrl = useListCustomersController();
|
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 {
|
return {
|
||||||
listCtrl,
|
listCtrl,
|
||||||
sheetCtrl,
|
panelCtrl,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export * from "./customer-sheet";
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from "./customer-summary-panel";
|
||||||
@ -132,7 +132,7 @@ export function useCustomersGridColumns(
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
size: 140,
|
size: 140,
|
||||||
minSize: 120,
|
minSize: 120,
|
||||||
cell: ({ row }) => <AddressCell customer={row.original} />,
|
cell: ({ row }) => <AddressCell address={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
export * from "./customer-sheet";
|
export * from "./customer-summary-panel";
|
||||||
export * from "./customers-grid";
|
export * from "./customers-grid";
|
||||||
|
|||||||
@ -1,35 +1,44 @@
|
|||||||
import { MapPinIcon } from "lucide-react";
|
import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
|
||||||
|
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import type { CustomerListRow } from "../../../shared";
|
|
||||||
|
|
||||||
const getGoogleMapsUrl = (customer: CustomerListRow) => {
|
export interface CustomerAddress {
|
||||||
const fullAddress = `${customer.street}, ${customer.postalCode} ${customer.city}, ${customer.province}, ${customer.country}`;
|
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)}`;
|
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 { t } = useTranslation();
|
||||||
const line1 = [customer.street, customer.street2].filter(Boolean).join(", ");
|
const line1 = [address.street, address.street2].filter(Boolean).join(", ");
|
||||||
const line2 = [customer.postalCode, customer.city].filter(Boolean).join(" ");
|
const line2 = [address.postalCode, address.city].filter(Boolean).join(" ");
|
||||||
const line3 = [customer.province, customer.country].filter(Boolean).join(", ");
|
const line3 = [address.province, address.country].filter(Boolean).join(", ");
|
||||||
return (
|
return (
|
||||||
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
|
<address className="not-italic flex items-start gap-2 text-muted-foreground hover:text-primary transition-colors">
|
||||||
<a
|
<a
|
||||||
aria-label={t("components.address_cell.open_in_google_maps")}
|
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"
|
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"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
title={t("components.address_cell.open_in_google_maps")}
|
title={t("components.address_cell.open_in_google_maps")}
|
||||||
>
|
>
|
||||||
<MapPinIcon className="mt-0.5 size-3.5" />
|
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground group-hover:text-primary" />
|
||||||
<div className="text-sm">
|
<div className="text-sm text-muted-foreground group-hover:text-foreground">
|
||||||
<p>{line1}</p>
|
<p>{line1}</p>
|
||||||
<p>
|
<p>
|
||||||
{line2} · {line3}
|
{line2} - {line3}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ExternalLinkIcon className="mt-0.5 size-3 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100" />
|
||||||
</a>
|
</a>
|
||||||
</address>
|
</address>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,18 +7,18 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { ErrorAlert } from "../../../shared/ui";
|
import { ErrorAlert } from "../../../shared/ui";
|
||||||
import { useListCustomersPageController } from "../../controllers";
|
import { useListCustomersPageController } from "../../controllers";
|
||||||
import { CustomerSheet, CustomersGrid, useCustomersGridColumns } from "../blocks";
|
import { CustomerSummaryPanel, CustomersGrid, useCustomersGridColumns } from "../blocks";
|
||||||
|
|
||||||
export const ListCustomersPage = () => {
|
export const ListCustomersPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { listCtrl, sheetCtrl } = useListCustomersPageController();
|
const { listCtrl, panelCtrl } = useListCustomersPageController();
|
||||||
|
|
||||||
const columns = useCustomersGridColumns({
|
const columns = useCustomersGridColumns({
|
||||||
onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`),
|
onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`),
|
||||||
onViewClick: (customer) => navigate(`/customers/${customer.id}`),
|
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),
|
//onDeleteClick: (customer) => null, //confirmDelete(inv.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -51,38 +51,43 @@ export const ListCustomersPage = () => {
|
|||||||
title={t("pages.list.title")}
|
title={t("pages.list.title")}
|
||||||
/>
|
/>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<AppContent>
|
<AppContent className="min-h-0">
|
||||||
<div className="flex items-center justify-between gap-16">
|
<div className="flex min-h-0 flex-1 overflow-hidden gap-4">
|
||||||
<SimpleSearchInput
|
<div className="min-w-0 flex-1 overflow-auto">
|
||||||
loading={listCtrl.isFetching}
|
<div className="flex items-center justify-between gap-16">
|
||||||
onSearchChange={listCtrl.setSearchValue}
|
<SimpleSearchInput
|
||||||
value={listCtrl.search}
|
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>
|
</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>
|
</AppContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
"./helpers": "./src/helpers/index.ts",
|
"./helpers": "./src/helpers/index.ts",
|
||||||
"./globals.css": "./src/styles/globals.css",
|
"./globals.css": "./src/styles/globals.css",
|
||||||
"./postcss.config": "./postcss.config.mjs",
|
"./postcss.config": "./postcss.config.mjs",
|
||||||
"./components": "./src/components/index.tsx",
|
"./components": "./src/components/index.ts",
|
||||||
"./components/*": "./src/components/*.tsx",
|
"./components/*": "./src/components/*.tsx",
|
||||||
"./locales/*": "./src/locales/*",
|
"./locales/*": "./src/locales/*",
|
||||||
"./hooks": [
|
"./hooks": [
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
packages/rdx-ui/src/components/entity-sheet/index.ts
Normal file
1
packages/rdx-ui/src/components/entity-sheet/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./entity-sheet-shell.tsx";
|
||||||
@ -1,9 +1,7 @@
|
|||||||
export * from "./date-picker-input-field/index.ts";
|
|
||||||
export * from "./DatePickerField.tsx";
|
export * from "./DatePickerField.tsx";
|
||||||
|
export * from "./date-picker-input-field/index.ts";
|
||||||
export * from "./multi-select-field.tsx";
|
export * from "./multi-select-field.tsx";
|
||||||
export * from "./SelectField.tsx";
|
export * from "./SelectField.tsx";
|
||||||
export * from "./TextAreaField.tsx";
|
export * from "./TextAreaField.tsx";
|
||||||
export * from "./TextField.tsx";
|
export * from "./TextField.tsx";
|
||||||
export type * from "./types.d.ts";
|
export type * from "./types.d.ts";
|
||||||
|
|
||||||
@ -1,16 +1,18 @@
|
|||||||
export * from "./buttons/index.tsx";
|
export * from "./buttons/index.ts";
|
||||||
export * from "./custom-dialog.tsx";
|
export * from "./custom-dialog.tsx";
|
||||||
export * from "./datatable/index.tsx";
|
export * from "./datatable/index.ts";
|
||||||
export * from "./dynamics-tabs.tsx";
|
export * from "./dynamics-tabs.tsx";
|
||||||
|
export * from "./entity-sheet/index.ts";
|
||||||
export * from "./error-overlay.tsx";
|
export * from "./error-overlay.tsx";
|
||||||
export * from "./form/index.tsx";
|
export * from "./form/index.ts";
|
||||||
export * from "./full-screen-modal.tsx";
|
export * from "./full-screen-modal.tsx";
|
||||||
export * from "./grid/index.ts";
|
export * from "./grid/index.ts";
|
||||||
export * from "./layout/index.tsx";
|
export * from "./layout/index.ts";
|
||||||
export * from "./loading-overlay/index.tsx";
|
export * from "./loading-overlay/index.ts";
|
||||||
export * from "./logo-verifactu.tsx";
|
export * from "./logo-verifactu.tsx";
|
||||||
export * from "./lookup-dialog/index.tsx";
|
export * from "./lookup-dialog/index.ts";
|
||||||
export * from "./multi-select.tsx";
|
export * from "./multi-select.tsx";
|
||||||
export * from "./multiple-selector.tsx";
|
export * from "./multiple-selector.tsx";
|
||||||
|
export * from "./right-panel/index.ts";
|
||||||
export * from "./scroll-to-top.tsx";
|
export * from "./scroll-to-top.tsx";
|
||||||
export * from "./tailwind-indicator.tsx";
|
export * from "./tailwind-indicator.tsx";
|
||||||
@ -11,16 +11,16 @@ import {
|
|||||||
|
|
||||||
export const AppBreadcrumb = () => {
|
export const AppBreadcrumb = () => {
|
||||||
return (
|
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'>
|
<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'>
|
<div className="flex items-center gap-2 px-6">
|
||||||
<SidebarTrigger className='-ml-1' />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
<Separator className="mr-2 h-4" orientation="vertical" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem className='hidden md:block'>
|
<BreadcrumbItem className="hidden md:block">
|
||||||
<BreadcrumbLink href='#'>Building Your Application</BreadcrumbLink>
|
<BreadcrumbLink href="#">Building Your Application</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator className='hidden md:block' />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
|
|||||||
1
packages/rdx-ui/src/components/right-panel/index.ts
Normal file
1
packages/rdx-ui/src/components/right-panel/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./right-panel.tsx";
|
||||||
@ -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;
|
||||||
|
}
|
||||||
53
packages/rdx-ui/src/components/right-panel/right-panel.tsx
Normal file
53
packages/rdx-ui/src/components/right-panel/right-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,3 +1,4 @@
|
|||||||
export * from "./sheet/index.ts";
|
export * from "./sheet/index.ts";
|
||||||
|
export * from "./side-panel/index.ts";
|
||||||
export * from "./use-device-info.ts";
|
export * from "./use-device-info.ts";
|
||||||
export * from "./use-row-selection.ts";
|
export * from "./use-row-selection.ts";
|
||||||
|
|||||||
1
packages/rdx-ui/src/hooks/side-panel/index.ts
Normal file
1
packages/rdx-ui/src/hooks/side-panel/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./use-side-panel-state.ts";
|
||||||
88
packages/rdx-ui/src/hooks/side-panel/use-side-panel-state.ts
Normal file
88
packages/rdx-ui/src/hooks/side-panel/use-side-panel-state.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
export const PACKAGE_NAME = "rdx-ui";
|
export const PACKAGE_NAME = "rdx-ui";
|
||||||
|
|
||||||
export * from "./components/index.tsx";
|
export * from "./components/index.ts";
|
||||||
export * from "./helpers/index.ts";
|
export * from "./helpers/index.ts";
|
||||||
export * from "./hooks/index.ts";
|
export * from "./hooks/index.ts";
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import * as React from "react"
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||||
|
import type * as React from "react";
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
@ -11,16 +10,16 @@ function Separator({
|
|||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<SeparatorPrimitive.Root
|
<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"
|
data-slot="separator"
|
||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Separator }
|
export { Separator };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user