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";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => <AddressCell customer={row.original} />,
|
||||
cell: ({ row }) => <AddressCell address={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./customer-sheet";
|
||||
export * from "./customer-summary-panel";
|
||||
export * from "./customers-grid";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 "./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";
|
||||
|
||||
@ -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";
|
||||
@ -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>
|
||||
|
||||
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 "./side-panel/index.ts";
|
||||
export * from "./use-device-info.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 * from "./components/index.tsx";
|
||||
export * from "./components/index.ts";
|
||||
export * from "./helpers/index.ts";
|
||||
export * from "./hooks/index.ts";
|
||||
|
||||
@ -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 };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user