Facturas de cliente

This commit is contained in:
David Arranz 2025-07-07 20:25:13 +02:00
parent 81fffc4a0e
commit 4a47e6f249
61 changed files with 3055 additions and 371 deletions

View File

@ -9,6 +9,7 @@
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
<title>FactuGES 2025</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link href="/src/global.css" rel="stylesheet">
</head>
<body>

View File

@ -17,7 +17,6 @@
"@hookform/devtools": "^4.4.0",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.5",
"@tailwindcss/vite": "^4.1.6",
"@tanstack/react-query-devtools": "^5.74.11",
"@types/dinero.js": "^1.9.4",
"@types/node": "^22.15.12",
@ -33,9 +32,11 @@
"@erp/auth": "workspace:*",
"@erp/core": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.74.11",
"axios": "^1.9.0",
"dinero.js": "^1.9.1",
@ -50,7 +51,7 @@
"react-secure-storage": "^1.3.2",
"sequelize": "^6.37.5",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.6",
"tailwindcss": "^4.1.10",
"tw-animate-css": "^1.2.9",
"vite-plugin-html": "^3.2.2"
}

View File

@ -1 +0,0 @@
export { default } from "@repo/shadcn-ui/postcss.config.mjs";

View File

@ -1,3 +1,2 @@
@import "tailwindcss";
@import "tw-animate-css";

View File

@ -1,20 +1,23 @@
@import 'tailwindcss';
@import "tailwindcss";
@import "tw-animate-css";
@import '@repo/shadcn-ui/globals.css';
@import '@repo/rdx-ui/globals.css';
@import "@repo/shadcn-ui/globals.css";
@import "@repo/rdx-ui/globals.css";
@import "@erp/customers/globals.css";
@import "@erp/customer-invoices/globals.css";
@theme {
--font-sans: "Calibri", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
@theme {
--font-sans: "Calibri", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
/**
* Tailwind CSS official document:
* https://tailwindcss.com/docs/detecting-classes-in-source-files
*
* if you ever need to explicitly add a source that's excluded by default,
* if you ever need to explicitly add a source that's excluded by default,
* you can always add it with the @source directive.
*/
@source '../node_modules/@repo/shadcn-ui';
@source '../node_modules/@repo/rdx-ui';
@source '../node_modules/@repo/rdx-ui';

View File

@ -1 +0,0 @@
export * from "@repo/shadcn-ui/tailwind.config.mjs";

View File

@ -25,7 +25,7 @@
"complexity": {
"noForEach": "off",
"noBannedTypes": "info",
"useOptionalChain": "info"
"useOptionalChain": "off"
},
"suspicious": {
"noImplicitAnyLet": "info",

View File

@ -1,2 +1,3 @@
export * from "./dto";
export * from "./schemas";
export * from "./types";

View File

@ -0,0 +1,29 @@
import { z } from "zod";
/***
* Cantidad
* admite decimales hasta 2 cifras
* el campo `amount` puede ser null
*/
export const makeAmountSchema = ({ maxScale = 2, nullable = false } = {}) => {
const amount = z.number().refine((v) => Number.isFinite(v), "Amount must be a finite number");
return z.object({
amount: nullable ? amount.nullable() : amount,
scale: z.number().int().nonnegative().max(maxScale, `Scale cannot exceed ${maxScale}`),
});
};
/**
* Porcentaje 0 100
* admite decimales hasta 2 cifras
* el campo `amount` puede ser null
*/
export const makePercentageSchema = ({ maxScale = 2, min = 0, max = 100, nullable = true } = {}) =>
makeAmountSchema({ maxScale, nullable }).extend({
amount: z
.number()
.min(min, `El porcentaje debe ser ≥ ${min}`)
.max(max, `El porcentaje no puede superar ${max}`)
.nullable(),
});

View File

@ -0,0 +1 @@
export * from "./core.schemas";

View File

@ -6,7 +6,8 @@
"exports": {
".": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./client": "./src/web/manifest.ts"
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css"
},
"peerDependencies": {
"dinero.js": "^1.9.1"
@ -22,7 +23,11 @@
},
"dependencies": {
"@ag-grid-community/locale": "34.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@erp/core": "workspace:*",
"@erp/customers": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
@ -30,6 +35,7 @@
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"@tanstack/react-table": "^8.21.3",
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
"date-fns": "^4.1.0",
@ -43,6 +49,9 @@
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"slugify": "^1.6.6",
"sonner": "^2.0.5",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.4",
"zod": "^3.25.67"
}
}

View File

@ -1,14 +1,14 @@
import * as z from "zod/v4";
export const CreateCustomerInvoiceCommandSchema = z.object({
id: z.string().uuid(),
status: z.string(),
id: z.uuid(),
invoice_status: z.string(),
invoice_number: z.string().min(1, "Customer invoice number is required"),
invoice_series: z.string().min(1, "Customer invoice series is required"),
issue_date: z.string().datetime({ offset: true, message: "Invalid issue date format" }),
operation_date: z.string().datetime({ offset: true, message: "Invalid operation date format" }),
language_code: z.string().min(2, "Language code must be at least 2 characters long"),
currency: z.string().min(3, "Currency code must be at least 3 characters long"),
currency_code: z.string().min(3, "Currency code must be at least 3 characters long"),
items: z.array(
z.object({
description: z.string().min(1, "Item description is required"),
@ -16,10 +16,12 @@ export const CreateCustomerInvoiceCommandSchema = z.object({
amount: z.number().positive("Quantity amount must be positive"),
scale: z.number().int().nonnegative("Quantity scale must be a non-negative integer"),
}),
unitPrice: z.object({
unit_price: z.object({
amount: z.number().positive("Unit price amount must be positive"),
scale: z.number().int().nonnegative("Unit price scale must be a non-negative integer"),
currency: z.string().min(3, "Unit price currency code must be at least 3 characters long"),
currency_code: z
.string()
.min(3, "Unit price currency code must be at least 3 characters long"),
}),
discount: z.object({
amount: z.number().nonnegative("Discount amount cannot be negative"),

View File

@ -1,14 +1,23 @@
{
"customerInvoices": {
"common": {},
"pages": {
"title": "Customer invoices",
"description": "Manage your customer invoices",
"list": {
"title": "Customer invoice list",
"description": "List all customer invoices"
"description": "List all customer invoices",
"grid_columns": {
"invoice_number": "Inv. number",
"invoice_series": "Serie",
"invoice_status": "Status",
"issue_date": "Date",
"total_price": "Total price"
}
},
"create": {
"title": "Create customer invoice",
"description": "Create a new customer invoice"
"title": "New customer invoice",
"description": "Create a new customer invoice",
"back_to_list": "Back to the list"
},
"edit": {
"title": "Edit customer invoice",
@ -22,5 +31,51 @@
"title": "View customer invoice",
"description": "View the details of the selected customer invoice"
}
},
"status": {
"draft": "Draft",
"emitted": "Emitted",
"sent": "Sent",
"received": "Received",
"rejected": "Rejected"
},
"form_fields": {
"invoice_number": {
"label": "Invoice number",
"placeholder": "",
"description": ""
},
"items": {
"quantity": {
"label": "Quantity",
"placeholder": "",
"description": ""
},
"description": {
"label": "Description",
"placeholder": "",
"description": ""
},
"unit_price": {
"label": "Unit price",
"placeholder": "",
"description": "Item unit price"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Percentage discount"
},
"total_price": {
"label": "Total price",
"placeholder": "",
"description": "Total price with percentage discount"
}
}
}
}

View File

@ -1,14 +1,23 @@
{
"customerInvoices": {
"common": {},
"pages": {
"title": "Facturas",
"description": "Gestiona tus facturas",
"list": {
"title": "Lista de facturas",
"description": "Lista todas las facturas"
"description": "Lista todas las facturas",
"grid_columns": {
"invoice_number": "Num. factura",
"invoice_series": "Serie",
"invoice_status": "Estado",
"issue_date": "Fecha",
"total_price": "Imp. total"
}
},
"create": {
"title": "Crear factura",
"description": "Crear una nueva factura"
"description": "Crear una nueva factura",
"back_to_list": "Volver a la lista"
},
"edit": {
"title": "Editar factura",
@ -22,5 +31,51 @@
"title": "Ver factura",
"description": "Ver los detalles de la factura seleccionada"
}
},
"status": {
"draft": "Borrador",
"emitted": "Emitida",
"sent": "Enviada",
"received": "Recibida",
"rejected": "Rechazada"
},
"form_fields": {
"invoice_number": {
"label": "Num. factura",
"placeholder": "",
"description": ""
},
"items": {
"quantity": {
"label": "Cantidad",
"placeholder": "",
"description": ""
},
"description": {
"label": "Descripción",
"placeholder": "",
"description": ""
},
"unit_price": {
"label": "Imp. unitario",
"placeholder": "",
"description": "Importe unitario del artículo"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Porcentaje de descuento"
},
"total_price": {
"label": "Imp. total",
"placeholder": "",
"description": "Importe total con el descuento ya aplicado"
}
}
}
}

View File

@ -0,0 +1,23 @@
import { Button } from "@repo/shadcn-ui/components";
import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react";
import { JSX, forwardRef } from "react";
export interface AppendBlockRowButtonProps extends React.ComponentProps<typeof Button> {
label?: string;
}
export const AppendBlockRowButton = forwardRef<HTMLButtonElement, AppendBlockRowButtonProps>(
(
{ label = t("common.append_block"), className, ...props }: AppendBlockRowButtonProps,
ref
): JSX.Element => (
<Button type='button' variant='outline' ref={ref} {...props}>
{" "}
<PackagePlusIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{label && <>{label}</>}
</Button>
)
);
AppendBlockRowButton.displayName = "AppendBlockRowButton";

View File

@ -0,0 +1,26 @@
import { Button } from "@repo/shadcn-ui/components";
import { t } from "i18next";
import { PackagePlusIcon } from "lucide-react";
import { JSX, forwardRef } from "react";
export interface AppendCatalogArticleRowButtonProps extends React.ComponentProps<typeof Button> {
label?: string;
}
export const AppendCatalogArticleRowButton = forwardRef<
HTMLButtonElement,
AppendCatalogArticleRowButtonProps
>(
(
{ label = t("common.append_article"), className, ...props }: AppendCatalogArticleRowButtonProps,
ref
): JSX.Element => (
<Button type='button' variant='outline' ref={ref} {...props}>
{" "}
<PackagePlusIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{label && <>{label}</>}
</Button>
)
);
AppendCatalogArticleRowButton.displayName = "AppendCatalogArticleRowButton";

View File

@ -0,0 +1,23 @@
import { Button } from "@repo/shadcn-ui/components";
import { t } from "i18next";
import { PlusCircleIcon } from "lucide-react";
import { JSX, forwardRef } from "react";
export interface AppendEmptyRowButtonProps extends React.ComponentProps<typeof Button> {
label?: string;
className?: string;
}
export const AppendEmptyRowButton = forwardRef<HTMLButtonElement, AppendEmptyRowButtonProps>(
(
{ label = t("common.append_empty_row"), className, ...props }: AppendEmptyRowButtonProps,
ref
): JSX.Element => (
<Button type='button' variant='outline' ref={ref} {...props}>
<PlusCircleIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
{label && <>{label}</>}
</Button>
)
);
AppendEmptyRowButton.displayName = "AppendEmptyRowButton";

View File

@ -0,0 +1,3 @@
export * from "./append-block-row-button";
export * from "./append-catalog-article-row-button";
export * from "./append-empty-row-button";

View File

@ -0,0 +1,66 @@
import { Badge } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import { MODULE_NAME } from "../manifest";
export type CustomerInvoiceStatus = "draft" | "emitted" | "sent" | "received" | "rejected";
export type CustomerInvoiceStatusBadgeProps = {
status: string; // permitir cualquier valor
className?: string;
};
const statusColorConfig: Record<CustomerInvoiceStatus, { badge: string; dot: string }> = {
draft: {
badge:
"bg-gray-600/10 dark:bg-gray-600/20 hover:bg-gray-600/10 text-gray-500 border-gray-600/60",
dot: "bg-gray-500",
},
emitted: {
badge:
"bg-amber-600/10 dark:bg-amber-600/20 hover:bg-amber-600/10 text-amber-500 border-amber-600/60",
dot: "bg-amber-500",
},
sent: {
badge:
"bg-cyan-600/10 dark:bg-cyan-600/20 hover:bg-cyan-600/10 text-cyan-500 border-cyan-600/60 shadow-none rounded-full",
dot: "bg-cyan-500",
},
received: {
badge:
"bg-emerald-600/10 dark:bg-emerald-600/20 hover:bg-emerald-600/10 text-emerald-500 border-emerald-600/60",
dot: "bg-emerald-500",
},
rejected: {
badge: "bg-red-600/10 dark:bg-red-600/20 hover:bg-red-600/10 text-red-500 border-red-600/60",
dot: "bg-red-500",
},
};
export const CustomerInvoiceStatusBadge = forwardRef<
HTMLDivElement,
CustomerInvoiceStatusBadgeProps
>(({ status, className, ...props }, ref) => {
const { t } = useTranslation(MODULE_NAME);
const normalizedStatus = status.toLowerCase() as CustomerInvoiceStatus;
const config = statusColorConfig[normalizedStatus];
const commonClassName = "transition-colors duration-200 cursor-pointer shadow-none rounded-full";
if (!config) {
return (
<Badge ref={ref} className={cn(commonClassName, className)} {...props}>
{status}
</Badge>
);
}
return (
<Badge className={cn(commonClassName, config.badge, className)} {...props}>
<div className={cn("h-1.5 w-1.5 rounded-full mr-2", config.dot)} />
{t(`status.${status}`)}
</Badge>
);
});
CustomerInvoiceStatusBadge.displayName = "CustomerInvoiceStatusBadge";

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
// Grid
@ -11,75 +11,40 @@ import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { AgGridReact } from "ag-grid-react";
import { useTranslation } from "react-i18next";
import { useCustomerInvoicesQuery } from "../hooks";
/**
* Fetch example Json data
* Not recommended for production use!
*/
export const useFetchJson = <T,>(url: string, limit?: number) => {
const [data, setData] = useState<T[]>();
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
// Note error handling is omitted here for brevity
const response = await fetch(url);
const json = await response.json();
const data = limit ? json.slice(0, limit) : json;
setData(data);
setLoading(false);
};
fetchData();
}, [url, limit]);
return { data, loading };
};
// Row Data Interface
interface IRow {
mission: string;
company: string;
location: string;
date: string;
time: string;
rocket: string;
price: number;
successful: boolean;
}
import { MODULE_NAME } from "../manifest";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
// Create new GridExample component
export const CustomerInvoicesGrid = () => {
//const { useList } = useCustomerInvoices();
export const CustomerInvoicesListGrid = () => {
const { t } = useTranslation(MODULE_NAME);
const { data, isLoading, isPending, isError, error } = useCustomerInvoicesQuery({});
// Column Definitions: Defines & controls grid columns.
const [colDefs] = useState<ColDef[]>([
{ field: "invoice_number", headerName: "Num. factura" },
{ field: "invoice_series", headerName: "Serie" },
{
field: "invoice_status",
filter: true,
headerName: "Estado",
headerName: t("pages.list.grid_columns.invoice_status"),
cellRenderer: (params: ValueFormatterParams) => {
return <CustomerInvoiceStatusBadge status={params.value} />;
},
},
{ field: "invoice_number", headerName: t("pages.list.grid_columns.invoice_number") },
{ field: "invoice_series", headerName: t("pages.list.grid_columns.invoice_series") },
{
field: "issue_date",
headerName: "Fecha fact.",
headerName: t("pages.list.grid_columns.issue_date"),
valueFormatter: (params: ValueFormatterParams) => {
return formatDate(params.value);
},
},
{
field: "subtotal_price",
valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value;
return formatMoney(rawValue);
},
},
{
field: "total_price",
headerName: t("pages.list.grid_columns.total_price"),
valueFormatter: (params: ValueFormatterParams) => {
const rawValue: MoneyDTO = params.value;
return formatMoney(rawValue);
@ -97,18 +62,9 @@ export const CustomerInvoicesGrid = () => {
sortable: false,
resizable: true,
},
sideBar: true,
statusBar: {
statusPanels: [
{ statusPanel: "agTotalAndFilteredRowCountComponent", align: "left" },
{ statusPanel: "agAggregationComponent" },
],
},
rowGroupPanelShow: "always",
pagination: true,
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50],
enableCharts: true,
localeText: AG_GRID_LOCALE_ES,
rowSelection: { mode: "multiRow" },
};

View File

@ -1,2 +1,3 @@
export * from "./customer-invoices-grid";
export * from "./customer-invoice-status-badge";
export * from "./customer-invoices-layout";
export * from "./customer-invoices-list-grid";

View File

@ -0,0 +1,343 @@
import {
FormControl,
FormField,
FormItem,
FormMessage,
Input,
Textarea,
} from "@repo/shadcn-ui/components";
import { ColumnDef } from "@tanstack/react-table";
import { ChevronDownIcon, ChevronUpIcon, CopyIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useDetailColumns } from "../../hooks";
import { MODULE_NAME } from "../../manifest";
import { formatCurrency } from "../../pages/create/utils";
import {
CustomerInvoiceItemsSortableDataTable,
RowIdData,
} from "./customer-invoice-items-sortable-datatable";
export const CustomerInvoiceItemsCardEditor = ({
//currency,
//language,
defaultValues,
}: {
//currency: CurrencyData;
//language: Language;
defaultValues: Readonly<{ [x: string]: any }> | undefined;
}) => {
const { t } = useTranslation(MODULE_NAME);
const { control, watch, getValues } = useFormContext();
const watchedItems = watch("items");
//const [pickerMode] = useState<"dialog" | "panel">("dialog");
//const [articlePickerDialogOpen, setArticlePickerDialogOpen] = useState<boolean>(false);
//const [blockPickerDialogOpen, setBlockPickerDialogOpen] = useState<boolean>(false);
const { fields, ...fieldActions } = useFieldArray({
control,
name: "items",
});
const columns: ColumnDef<RowIdData, unknown>[] = useDetailColumns(
[
/*{
id: "row_id" as const,
header: () => (
<HashIcon aria-label='Orden de fila' className='items-center justify-center w-4 h-4' />
),
accessorFn: (_: unknown, index: number) => index + 1,
size: 5,
enableHiding: false,
enableSorting: false,
enableResizing: false,
},*/
/*{
id: "id_article" as const,
accessorKey: "id_article",
header: "artículo",
cell: ({ row: { index, original } }) => {
return (
<FormTextAreaField
readOnly={original?.id_article}
autoSize
{...register(`items.${index}.id_article`)}
/>
);
},
size: 500,
},*/
{
id: "description" as const,
accessorKey: "description",
header: t("customer_invoices.form_fields.description.label"),
cell: ({ row: { index, original } }) => (
<FormField
control={control}
name={`items.${index}.description`}
render={({ field }) => (
<FormItem className='md:col-span-2'>
<FormControl>
<Textarea
placeholder={t("customer_invoices.form_fields.description.placeholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
minSize: 200,
size: 400,
},
{
id: "quantity" as const,
accessorKey: "quantity",
header: () => (
<div className='text-right'>{t("customer_invoices.form_fields.quantity.label")}</div>
),
cell: ({ row: { index } }) => (
<FormField
control={control}
name={`items.${index}.quantity.amount`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
size: 75,
},
{
id: "unit_price" as const,
accessorKey: "unit_price",
header: () => (
<div className='text-right'>
{t("customer_invoices.form_fields.items.unit_price.label")}
</div>
),
cell: ({ row: { index } }) => (
<FormField
control={control}
name={`items.${index}.unit_price.amount`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
size: 125,
},
{
id: "subtotal_price" as const,
accessorKey: "subtotal_price",
header: () => (
<div className='text-right'>
{t("customer_invoices.form_fields.items.subtotal_price.label")}
</div>
),
cell: ({ row: { index } }) => {
/*return (
<FormCurrencyField
variant='ghost'
currency={currency}
language={language}
scale={2}
readOnly
className='text-right'
{...register(`items.${index}.subtotal_price`)}
/>
);*/
return null;
},
size: 150,
},
{
id: "discount" as const,
accessorKey: "discount",
header: () => (
<div className='text-right'>
{t("customer_invoices.form_fields.items.discount.label")}
</div>
),
cell: ({ row: { index } }) => (
<FormField
control={control}
name={`items.${index}.discount.amount`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type='number'
step='0.01'
min='0'
max='100'
{...field}
onChange={(e) => field.onChange(Number(e.target.value) * 100)}
value={field.value / 100}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
),
size: 100,
},
{
id: "total_price" as const,
accessorKey: "total_price",
header: () => (
<div className='text-right'>
{t("customer_invoices.form_fields.items.total_price.label")}
</div>
),
cell: ({ row: { index } }) => (
<>
{formatCurrency(
watchedItems[index]?.total_price?.amount || 0,
2,
getValues("currency")
)}
</>
),
size: 150,
},
],
{
enableDragHandleColumn: true,
enableSelectionColumn: true,
enableActionsColumn: true,
rowActionFn: (props) => {
const { table, row } = props;
return [
{
label: t("common.duplicate_row"),
icon: <CopyIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.duplicateItems(row.index),
},
{
label: t("common.insert_row_above"),
icon: <ChevronUpIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.insertItem(row.index),
},
{
label: t("common.insert_row_below"),
icon: <ChevronDownIcon className='w-4 h-4 mr-2' />,
onClick: () => table.options.meta?.insertItem(row.index + 1),
},
{
label: "-",
},
{
label: t("common.remove_row"),
//shortcut: "⌘⌫",
icon: <Trash2Icon className='w-4 h-4 mr-2' />,
onClick: () => {
table.options.meta?.deleteItems(row.index);
},
},
];
},
}
);
/*const handleAppendCatalogArticle = useCallback(
(article: any, quantity = 1) => {
fieldActions.append({
...article,
quantity: {
amount: 100 * quantity,
scale: Quantity.DEFAULT_SCALE,
},
unit_price: article.retail_price,
discount: {
amount: null,
scale: 2,
},
});
toast({
title: t("quotes.catalog_picker_dialog.toast_article_added"),
description: article.description,
});
},
[fieldActions, toast]
);
const handleAppendBlock = useCallback(
(block: any) => {
fieldActions.append({
description: `${block.title}\n${block.body}`,
quantity: {
amount: null,
scale: Quantity.DEFAULT_SCALE,
},
unit_price: {
amount: null,
scale: UnitPrice.DEFAULT_SCALE,
},
discount: {
amount: null,
scale: 2,
},
});
toast({
title: t("quotes.blocks_picker_dialog.toast_article_added"),
description: block.title,
});
},
[fieldActions]
);*/
const [isCollapsed, setIsCollapsed] = useState(false);
const defaultLayout = [265, 440, 655];
const navCollapsedSize = 4;
console.log(columns);
return (
<div className='relative'>
<CustomerInvoiceItemsSortableDataTable
actions={{
...fieldActions,
//pickCatalogArticle: () => setArticlePickerDialogOpen(true),
//pickBlock: () => setBlockPickerDialogOpen(true),
}}
columns={columns}
data={fields}
defaultValues={defaultValues}
/>
</div>
);
};

View File

@ -0,0 +1,117 @@
import {
Button,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@repo/shadcn-ui/components";
import { Table } from "@tanstack/react-table";
import { t } from "i18next";
import { CopyPlusIcon, ScanIcon, Trash2Icon } from "lucide-react";
import {
AppendBlockRowButton,
AppendCatalogArticleRowButton,
AppendEmptyRowButton,
} from "../buttons";
export const CustomerInvoiceItemsSortableDataTableToolbar = ({ table }: { table: Table<any> }) => {
const selectedRowsCount = table.getSelectedRowModel().rows.length;
if (selectedRowsCount) {
return (
<nav className='flex items-center h-12 p-1 rounded-md text-muted-foreground bg-muted '>
<div className='flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.options.meta?.duplicateItems()}
>
<CopyPlusIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>
{t("common.duplicate_selected_rows")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.options.meta?.deleteItems()}
>
<Trash2Icon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.remove_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.remove_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type='button'
variant='link'
disabled={!table.getSelectedRowModel().rows.length}
onClick={() => table.resetRowSelection()}
>
<ScanIcon className='w-4 h-4 sm:mr-2' />
<span className='sr-only sm:not-sr-only'>{t("common.reset_selected_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.reset_selected_rows_tooltip")}</TooltipContent>
</Tooltip>
<Separator orientation='vertical' className='h-6 ml-1 mr-4' />
<p className='text-sm font-normal'>
{t("common.rows_selected", { count: selectedRowsCount })}
</p>
</div>
</nav>
);
}
return (
<nav className='flex items-center h-12 p-1 rounded-md bg-accent/75 text-muted-foreground'>
<div className='flex space-x-2'>
<Tooltip>
<TooltipTrigger asChild>
<AppendEmptyRowButton variant='link' onClick={() => table.options.meta?.appendItem()} />
</TooltipTrigger>
<TooltipContent>{t("common.append_empty_row_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendCatalogArticleRowButton
variant='link'
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_article_tooltip")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<AppendBlockRowButton
variant='link'
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
</TooltipTrigger>
<TooltipContent>{t("common.append_block_tooltip")}</TooltipContent>
</Tooltip>
</div>
<div className='flex items-center gap-2 ml-auto' />
</nav>
);
};

View File

@ -0,0 +1,531 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
DropAnimation,
KeyboardSensor,
MeasuringStrategy,
MouseSensor,
PointerSensor,
TouchSensor,
UniqueIdentifier,
closestCenter,
defaultDropAnimation,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ButtonGroup, DataTableColumnHeader } from "@repo/rdx-ui/components";
import {
Badge,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components/table";
import {
ColumnDef,
InitialTableState,
Row,
RowData,
RowSelectionState,
VisibilityState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
import {
AppendBlockRowButton,
AppendCatalogArticleRowButton,
AppendEmptyRowButton,
} from "../buttons";
import { CustomerInvoiceItemsSortableDataTableToolbar } from "./customer-invoice-items-sortable-datatable-toolbar";
import { CustomerInvoiceItemsSortableTableRow } from "./customer-invoice-items-sortable-table-row";
export type RowIdData = { [x: string]: any };
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
insertItem: (rowIndex: number, data?: unknown) => void;
appendItem: (data?: unknown) => void;
pickCatalogArticle?: () => void;
pickBlock?: () => void;
duplicateItems: (rowIndex?: number) => void;
deleteItems: (rowIndex?: number | number[]) => void;
updateItem: (
rowIndex: number,
rowData: TData & RowIdData,
fieldName: string,
value: unknown
) => void;
}
}
export interface CustomerInvoiceItemsSortableProps {
id: UniqueIdentifier;
}
export type CustomerInvoiceItemsSortableDataTableProps<
TData extends RowData & RowIdData,
TValue = unknown,
> = {
columns: ColumnDef<TData, TValue>[];
data: TData[];
defaultValues: Readonly<{ [x: string]: any }> | undefined;
initialState?: InitialTableState;
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields"> & {
pickCatalogArticle?: () => void;
pickBlock?: () => void;
};
};
const measuringConfig = {
droppable: {
strategy: MeasuringStrategy.Always,
},
};
const dropAnimationConfig: DropAnimation = {
keyframes({ transform }) {
return [
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
{
opacity: 0,
transform: CSS.Transform.toString({
...transform.final,
x: transform.final.x + 5,
y: transform.final.y + 5,
}),
},
];
},
easing: "ease-out",
sideEffects({ active }) {
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: defaultDropAnimation.duration,
easing: defaultDropAnimation.easing,
});
},
};
export function CustomerInvoiceItemsSortableDataTable<
TData extends RowData & RowIdData,
TValue = unknown,
>({
columns,
data,
defaultValues,
initialState,
actions,
}: CustomerInvoiceItemsSortableDataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
const [columnVisibility, _setColumnVisibility] = useState<VisibilityState>(
initialState?.columnVisibility || {}
);
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
const table = useReactTable<TData>({
data,
columns,
enableColumnResizing: false,
columnResizeMode: "onChange",
//defaultColumn,
autoResetAll: false, // <-- añadido nuevo
initialState,
state: {
rowSelection,
columnVisibility,
},
enableRowSelection: true,
enableMultiRowSelection: true,
enableSorting: false,
enableHiding: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (originalRow) => originalRow?.id,
debugTable: false,
debugHeaders: false,
debugColumns: false,
defaultColumn: {
minSize: 0, //starting column size
size: Number.MAX_SAFE_INTEGER, //enforced during column resizing
maxSize: Number.MAX_SAFE_INTEGER, //enforced during column resizing
},
meta: {
insertItem: (rowIndex: number, data?: unknown) => {
actions.insert(rowIndex, data || defaultValues?.items[0], { shouldFocus: true });
},
appendItem: (data?: unknown) => {
actions.append(data || defaultValues?.items[0], { shouldFocus: true });
},
pickCatalogArticle: () => {
if (actions.pickCatalogArticle) {
actions?.pickCatalogArticle();
}
},
pickBlock: () => {
if (actions.pickBlock) {
actions?.pickBlock();
}
},
duplicateItems: (rowIndex?: number) => {
if (rowIndex !== undefined) {
const originalData = table.getRowModel().rows[rowIndex].original;
actions.insert(rowIndex + 1, originalData, { shouldFocus: true });
} else if (table.getSelectedRowModel().rows.length) {
const lastIndex =
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;
const data = table
.getSelectedRowModel()
.rows.map((row: Row<any>) => ({ ...row.original, id: undefined }));
if (table.getRowModel().rows.length < lastIndex + 1) {
actions.append(data);
} else {
actions.insert(lastIndex + 1, data, { shouldFocus: true });
}
table.resetRowSelection();
}
},
deleteItems: (rowIndex?: number | number[]) => {
if (rowIndex !== undefined) {
actions.remove(rowIndex);
} else if (table.getSelectedRowModel().rows.length > 0) {
let start = table.getSelectedRowModel().rows.length - 1;
for (; start >= 0; start--) {
const oldIndex = sorteableRowIds.indexOf(
String(table.getSelectedRowModel().rows[start].id)
);
actions.remove(oldIndex);
sorteableRowIds.splice(oldIndex, 1);
}
/*table.getSelectedRowModel().rows.forEach((row) => {
});*/
table.resetRowSelection();
} else {
actions.remove();
}
},
updateItem: (rowIndex: number, rowData: any, fieldName: string, value: unknown) => {
// Skip page index reset until after next rerender
// skipAutoResetPageIndex();
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
},
},
});
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}),
useSensor(PointerSensor, {})
);
function handleDragEnd(event: DragEndEvent) {
let activeId = event.active.id;
let overId = event.over?.id;
if (overId !== undefined && activeId !== overId) {
let newIndex = sorteableRowIds.indexOf(String(overId));
if (table.getSelectedRowModel().rows.length > 1) {
table.getSelectedRowModel().rows.forEach((row, index) => {
const oldIndex = sorteableRowIds.indexOf(String(row.id));
if (index > 0) {
activeId = row.id;
newIndex = sorteableRowIds.indexOf(String(overId));
if (newIndex < oldIndex) {
newIndex = newIndex + 1;
}
}
actions.move(oldIndex, newIndex);
sorteableRowIds.splice(newIndex, 0, sorteableRowIds.splice(oldIndex, 1)[0]);
overId = row.id;
});
} else {
const oldIndex = sorteableRowIds.indexOf(String(activeId));
actions.move(oldIndex, newIndex);
}
}
setActiveId(null);
}
function handleDragStart({ active }: DragStartEvent) {
if (!table.getSelectedRowModel().rowsById[active.id]) {
table.resetRowSelection();
}
setActiveId(active.id);
}
function handleDragCancel() {
setActiveId(null);
}
function filterItems(items: string[] | Row<unknown>[]) {
if (!activeId) {
return items;
}
return items.filter((idOrRow) => {
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
return id === activeId || !table.getSelectedRowModel().rowsById[id];
});
}
return (
<DndContext
measuring={measuringConfig}
sensors={sensors}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragCancel={handleDragCancel}
collisionDetection={closestCenter}
>
<Card>
<CardHeader className='sticky z-10 top-16 bg-card/90'>
<CardTitle>
<CustomerInvoiceItemsSortableDataTableToolbar table={table} />
</CardTitle>
</CardHeader>
<CardContent>
<Table className='table-fixed'>
<TableHeader className='sticky top-0 z-10 bg-background'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
className='px-2 py-1'
style={{
width:
header.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: header.getSize(),
}}
>
{header.isPlaceholder ? null : (
<DataTableColumnHeader table={table} header={header} />
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
<SortableContext
items={filterItems(sorteableRowIds)}
strategy={verticalListSortingStrategy}
>
{filterItems(table.getRowModel().rows).map((row) => (
<CustomerInvoiceItemsSortableTableRow
key={(row as Row<any>).id}
id={(row as Row<any>).id}
>
{(row as Row<any>).getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className='px-2 py-2 align-top'
style={{
width:
cell.column.getSize() === Number.MAX_SAFE_INTEGER
? "auto"
: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</CustomerInvoiceItemsSortableTableRow>
))}
</SortableContext>
</TableBody>
</Table>
{createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
{activeId && (
<div className='relative flex flex-wrap'>
{table.getSelectedRowModel().rows.length ? (
<Badge
variant='destructive'
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
>
{table.getSelectedRowModel().rows.length}
</Badge>
) : null}
</div>
)}
</DragOverlay>,
document.body
)}
{false &&
createPortal(
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
{activeId && (
<div className='relative flex flex-wrap'>
{table.getSelectedRowModel().rows.length ? (
<Badge
variant='destructive'
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
>
{table.getSelectedRowModel().rows.length}
</Badge>
) : null}
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
{table.getSelectedRowModel().rows.length > 1 && (
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
{table.getSelectedRowModel().rows.length > 2 && (
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
{table.getSelectedRowModel().rows.length > 3 && (
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
<Table>
<TableBody>
{table.getRowModel().rows.map(
(row) =>
row.id === activeId && (
<TableRow key={row.id} id={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
className='p-1 align-top'
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
)}
</TableBody>
</Table>
</div>
)}
</div>
)}
</DragOverlay>,
document.body
)}
</CardContent>
<CardFooter>
<ButtonGroup>
<AppendEmptyRowButton onClick={() => table.options.meta?.appendItem()} />
<AppendCatalogArticleRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickCatalogArticle) {
table.options.meta?.pickCatalogArticle();
}
}}
/>
<AppendBlockRowButton
onClick={() => {
if (table.options.meta && table.options.meta.pickBlock) {
table.options.meta?.pickBlock();
}
}}
/>
</ButtonGroup>
</CardFooter>
</Card>
</DndContext>
);
}

View File

@ -0,0 +1,75 @@
import { DraggableSyntheticListeners } from "@dnd-kit/core";
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { TableRow } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { CSSProperties, PropsWithChildren, createContext, useMemo } from "react";
import { CustomerInvoiceItemsSortableProps } from "./customer-invoice-items-sortable-datatable";
interface Context {
attributes: Record<string, any>;
listeners: DraggableSyntheticListeners;
ref(node: HTMLElement | null): void;
}
export const CustomerInvoiceItemsSortableTableRowContext = createContext<Context>({
attributes: {},
listeners: undefined,
ref() {},
});
function animateLayoutChanges(args: any) {
if (args.isSorting || args.wasDragging) {
return defaultAnimateLayoutChanges(args);
}
return true;
}
export function CustomerInvoiceItemsSortableTableRow({
id,
children,
}: PropsWithChildren<CustomerInvoiceItemsSortableProps>) {
const {
attributes,
isDragging,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({
animateLayoutChanges,
id,
});
const style: CSSProperties = {
transform: CSS.Translate.toString(transform),
transition,
};
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
return (
<CustomerInvoiceItemsSortableTableRowContext.Provider value={context}>
<TableRow
key={id}
id={String(id)}
className={cn(
isDragging ? "opacity-60" : "opacity-100",
"m-0 hover:bg-muted hover:focus-within:bg-accent focus-within:bg-accent"
)}
ref={setNodeRef}
style={style}
>
{children}
</TableRow>
</CustomerInvoiceItemsSortableTableRowContext.Provider>
);
}

View File

@ -0,0 +1,2 @@
export * from "./customer-invoice-items-card-editor";
export * from "./customer-invoice-items-sortable-table-row";

View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "./components";
@source "./pages";

View File

@ -1,3 +1,4 @@
export * from "./use-create-customer-invoice-mutation";
export * from "./use-customer-invoices-context";
export * from "./use-customer-invoices-query";
export * from "./use-detail-columns";

View File

@ -0,0 +1,97 @@
import {
DataTablaRowActionFunction,
DataTableRowActions,
DataTableRowDragHandleCell,
} from "@repo/rdx-ui/components";
import { Checkbox } from "@repo/shadcn-ui/components";
import { ColumnDef } from "@tanstack/react-table";
import { useId, useMemo } from "react";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useDetailColumns<TData>(
columns: ReadonlyArray<ColumnDef<TData>>,
{
enableDragHandleColumn = false,
enableSelectionColumn = false,
enableActionsColumn = false,
rowActionFn,
}: {
enableDragHandleColumn?: boolean;
enableSelectionColumn?: boolean;
enableActionsColumn?: boolean;
rowActionFn?: DataTablaRowActionFunction<TData>;
} = {}
): ColumnDef<TData>[] {
const idPrefix = useId();
return useMemo(() => {
const baseColumns: ColumnDef<TData>[] = [...columns];
if (enableDragHandleColumn) {
baseColumns.unshift({
id: "row_drag_handle",
header: () => null,
cell: (info) => <DataTableRowDragHandleCell rowId={info.row.id} />,
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableSelectionColumn) {
baseColumns.unshift({
id: "select",
header: ({ table }) => (
<Checkbox
id={`${idPrefix}-select-all`}
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Seleccionar todo'
className='translate-y-[0px]'
/>
),
cell: ({ row }) => (
<Checkbox
id={`${idPrefix}-select-row-${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label='Seleccionar fila'
className='mt-2'
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
});
}
if (enableActionsColumn) {
const RowActionsCell = (props: any) => (
<DataTableRowActions rowContext={props} actions={rowActionFn} />
);
baseColumns.push({
id: "row_actions",
header: () => null,
cell: RowActionsCell,
enableSorting: false,
enableHiding: false,
size: 48,
});
}
return baseColumns;
}, [
columns,
rowActionFn,
idPrefix,
enableDragHandleColumn,
enableSelectionColumn,
enableActionsColumn,
]);
}

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerInvoiceMutation } from "../../hooks";
import { MODULE_NAME } from "../../manifest";
import { InvoiceEditForm } from "./invoice-edit-form";
import { CustomerInvoiceEditForm } from "./customer-invoice-edit-form";
export const CustomerInvoiceCreate = () => {
const { t } = useTranslation(MODULE_NAME);
@ -61,13 +61,13 @@ export const CustomerInvoiceCreate = () => {
<p className='text-muted-foreground'>{t("customerInvoices.create.description")}</p>
</div>
<div className='flex items-center justify-end mb-4'>
<Button className='btn btn-primary' onClick={() => navigate("/customer-invoices/list")}>
{t("customerInvoices.create.back_to_list")}
<Button className='cursor-pointer' onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className='flex flex-col w-full h-full py-4 @container'>
<InvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
<CustomerInvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
</div>
</AppContent>
</>

View File

@ -2,6 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useFieldArray, useForm } from "react-hook-form";
import * as z from "zod";
import { ClientSelector } from "@erp/customers/components";
import { formatDate } from "@erp/core/client";
import {
Button,
Calendar,
@ -32,7 +35,10 @@ import {
import { format } from "date-fns";
import { es } from "date-fns/locale";
import { CalendarIcon, PlusIcon, Save, Trash2Icon, X } from "lucide-react";
import { InvoiceData } from "./types";
import { useTranslation } from "react-i18next";
import { CustomerInvoiceItemsCardEditor } from "../../components/items";
import { MODULE_NAME } from "../../manifest";
import { CustomerInvoiceData } from "./customer-invoice.schema";
import { formatCurrency } from "./utils";
const invoiceSchema = z.object({
@ -119,8 +125,8 @@ const invoiceSchema = z.object({
}),
});
const defaultInvoiceData: InvoiceData = {
id: "893b2c74-e80f-4015-b0ed-6111b9c36ad2",
const defaultInvoiceData: CustomerInvoiceData = {
id: "",
invoice_status: "draft",
invoice_number: "1",
invoice_series: "A",
@ -128,15 +134,15 @@ const defaultInvoiceData: InvoiceData = {
operation_date: "2025-04-30T00:00:00.000Z",
language_code: "ES",
currency: "EUR",
customer_id: "c1d2e3f4-5678-90ab-cdef-1234567890ab",
customer_id: "",
items: [
{
id_article: "",
description: "Item 1",
description: "",
quantity: {
amount: 100,
scale: 2,
},
unit_price: {
amount: 100,
scale: 2,
@ -196,27 +202,26 @@ const defaultInvoiceData: InvoiceData = {
scale: 2,
currency_code: "EUR",
},
metadata: {
entity: "customer-invoice",
},
};
interface InvoiceFormProps {
initialData?: InvoiceData;
initialData?: CustomerInvoiceData;
isPending?: boolean;
/**
* Callback function to handle form submission.
* @param data - The invoice data submitted by the form.
*/
onSubmit?: (data: InvoiceData) => void;
onSubmit?: (data: CustomerInvoiceData) => void;
}
export const InvoiceEditForm = ({
export const CustomerInvoiceEditForm = ({
initialData = defaultInvoiceData,
onSubmit,
isPending,
}: InvoiceFormProps) => {
const form = useForm<InvoiceData>({
const { t } = useTranslation(MODULE_NAME);
const form = useForm<CustomerInvoiceData>({
resolver: zodResolver(invoiceSchema),
defaultValues: initialData,
});
@ -242,7 +247,7 @@ export const InvoiceEditForm = ({
});
};
const handleSubmit = (data: InvoiceData) => {
const handleSubmit = (data: CustomerInvoiceData) => {
console.log("Datos del formulario:", data);
onSubmit?.(data);
};
@ -258,147 +263,212 @@ export const InvoiceEditForm = ({
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)} className='space-y-6'>
<form onSubmit={form.handleSubmit(handleSubmit, handleError)} className='grid gap-6'>
{/* Información básica */}
<Card className='@container/card'>
<Card>
<CardHeader>
<CardTitle>Información Básica</CardTitle>
<CardDescription>Detalles generales de la factura</CardDescription>
</CardHeader>
<CardContent className=' gap-4'>
<FormField
control={form.control}
name='invoice_status'
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
<div className='grid grid-cols-1'>
<ClientSelector />
<FormField
control={form.control}
name='customer_id'
render={({ field }) => (
<FormItem>
<FormLabel>ID Cliente</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Seleccionar estado' />
</SelectTrigger>
<Input placeholder='ID del cliente' {...field} />
</FormControl>
<SelectContent>
<SelectItem value='draft'>Borrador</SelectItem>
<SelectItem value='sent'>Enviada</SelectItem>
<SelectItem value='paid'>Pagada</SelectItem>
<SelectItem value='cancelled'>Cancelada</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-4'>
<FormField
control={form.control}
name='invoice_number'
render={({ field }) => (
<FormItem>
<FormLabel>{t("form_fields.invoice_number.label")}</FormLabel>
<FormControl>
<Input placeholder='1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='invoice_series'
render={({ field }) => (
<FormItem>
<FormLabel>Serie</FormLabel>
<FormControl>
<Input placeholder='A' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='issue_date'
render={({ field }) => (
<FormItem>
<FormLabel>Fecha de Emisión</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value ? formatDate(field.value) : "Seleccionar fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='invoice_number'
render={({ field }) => (
<FormItem>
<FormLabel>Número</FormLabel>
<FormControl>
<Input placeholder='1' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='invoice_series'
render={({ field }) => (
<FormItem>
<FormLabel>Serie</FormLabel>
<FormControl>
<Input placeholder='A' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='issue_date'
render={({ field }) => (
<FormItem>
<FormLabel>Fecha de Emisión</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormField
control={form.control}
name='invoice_status'
render={({ field }) => (
<FormItem>
<FormLabel>Estado</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value
? format(new Date(field.value), "dd/MM/yyyy")
: "Seleccionar fecha"}
</Button>
<SelectTrigger>
<SelectValue placeholder='Seleccionar estado' />
</SelectTrigger>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<SelectContent>
<SelectItem value='draft'>Borrador</SelectItem>
<SelectItem value='sent'>Enviada</SelectItem>
<SelectItem value='paid'>Pagada</SelectItem>
<SelectItem value='cancelled'>Cancelada</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='operation_date'
render={({ field }) => (
<FormItem>
<FormLabel>Fecha de Operación</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value
? format(new Date(field.value), "dd/MM/yyyy")
: "Seleccionar fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='operation_date'
render={({ field }) => (
<FormItem>
<FormLabel>Fecha de Operación</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value ? formatDate(field.value) : "Seleccionar fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='customer_id'
render={({ field }) => (
<FormItem>
<FormLabel>ID Cliente</FormLabel>
<FormControl>
<Input placeholder='ID del cliente' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='operation_date'
render={({ field }) => (
<FormItem>
<FormLabel>Inicio periodo de facturación</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value ? formatDate(field.value) : "Seleccionar fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='operation_date'
render={({ field }) => (
<FormItem>
<FormLabel>Fin periodo de facturación</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
className='w-full justify-start text-left font-normal'
>
<CalendarIcon className='mr-2 h-4 w-4' />
{field.value ? formatDate(field.value) : "Seleccionar fecha"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={field.value ? new Date(field.value) : undefined}
onSelect={(date) => field.onChange(date?.toISOString())}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
@ -449,6 +519,9 @@ export const InvoiceEditForm = ({
</CardContent>
</Card>
{/*Items */}
<CustomerInvoiceItemsCardEditor defaultValues={defaultInvoiceData} />
{/* Items */}
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
@ -583,7 +656,6 @@ export const InvoiceEditForm = ({
))}
</CardContent>
</Card>
{/* Configuración de Impuestos */}
<Card>
<CardHeader>
@ -653,7 +725,6 @@ export const InvoiceEditForm = ({
</div>
</CardContent>
</Card>
<div className='flex justify-end space-x-4'>
<Button type='button' variant='outline' disabled={isPending} onClick={handleCancel}>
Cancelar

View File

@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { AG_GRID_LOCALE_ES } from "@ag-grid-community/locale";
// Grid
import type { ColDef, GridOptions, ValueFormatterParams } from "ag-grid-community";
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
ModuleRegistry.registerModules([AllCommunityModule]);
import { MoneyDTO } from "@erp/core";
import { formatDate, formatMoney } from "@erp/core/client";
// Core CSS
import { AgGridReact } from "ag-grid-react";
// Create new GridExample component
export const CustomerInvoiceItemsEditorGrid = ({ items: any[] }) => {
// Column Definitions: Defines & controls grid columns.
const [colDefs] = useState<ColDef[]>([
]);
const gridOptions: GridOptions = {
columnDefs: colDefs,
defaultColDef: {
editable: true,
flex: 1,
minWidth: 100,
filter: false,
sortable: false,
resizable: true,
},
sideBar: true,
statusBar: {
statusPanels: [
{ statusPanel: "agTotalAndFilteredRowCountComponent", align: "left" },
{ statusPanel: "agAggregationComponent" },
],
},
rowGroupPanelShow: "always",
pagination: true,
paginationPageSize: 10,
paginationPageSizeSelector: [10, 20, 30, 50],
enableCharts: true,
localeText: AG_GRID_LOCALE_ES,
rowSelection: { mode: "multiRow" },
};
// Container: Defines the grid's theme & dimensions.
return (
<div
className='ag-theme-alpine'
style={{
height: "100%",
width: "100%",
}}
>
<AgGridReact rowData={data?.items ?? []} loading={isLoading || isPending} {...gridOptions} />
</div>
);
};

View File

@ -0,0 +1,38 @@
import { CreateCustomerInvoiceCommandSchema } from "@erp/customer-invoices/common/dto";
import * as z from "zod/v4";
export const CustomerInvoiceItemDataFormSchema = CreateCustomerInvoiceCommandSchema.extend({
subtotal_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
discount: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
discount_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
before_tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
tax: z.object({
amount: z.number().nullable(),
scale: z.number(),
}),
tax_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
total_price: z.object({
amount: z.number().nullable(),
scale: z.number(),
currency_code: z.string(),
}),
});

View File

@ -1,35 +0,0 @@
import { IMoneyDTO, IPercentageDTO, IQuantityDTO } from "@erp/core";
export interface InvoiceItem {
id_article: string;
description: string;
quantity: IQuantityDTO;
unit_price: IMoneyDTO;
subtotal_price: IMoneyDTO;
discount: IPercentageDTO;
discount_price: IMoneyDTO;
total_price: IMoneyDTO;
}
export interface InvoiceData {
id: string;
invoice_status: string;
invoice_number: string;
invoice_series: string;
issue_date: string;
operation_date: string;
language_code: string;
currency: string;
customer_id: string;
items: InvoiceItem[];
subtotal_price: IMoneyDTO;
discount: IPercentageDTO;
discount_price: IMoneyDTO;
before_tax_price: IMoneyDTO;
tax: IPercentageDTO;
tax_price: IMoneyDTO;
total_price: IMoneyDTO;
metadata: {
entity: string;
};
}

View File

@ -4,7 +4,7 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { CustomerInvoicesGrid } from "../components";
import { CustomerInvoicesListGrid } from "../components";
import { MODULE_NAME } from "../manifest";
export const CustomerInvoicesList = () => {
@ -28,20 +28,21 @@ export const CustomerInvoicesList = () => {
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>
{t("customerInvoices.list.title")}
</h2>
<p className='text-muted-foreground'>{t("customerInvoices.list.description")}</p>
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.list.title")}</h2>
<p className='text-muted-foreground'>{t("pages.list.description")}</p>
</div>
<div className='flex items-center space-x-2'>
<Button onClick={() => navigate("/customer-invoices/create")}>
<Button
onClick={() => navigate("/customer-invoices/create")}
className='cursor-pointer'
>
<PlusIcon className='w-4 h-4 mr-2' />
{t("customerInvoices.create.title")}
{t("pages.create.title")}
</Button>
</div>
</div>
<div className='flex flex-col w-full h-full py-4'>
<CustomerInvoicesGrid />
<CustomerInvoicesListGrid />
</div>
</AppContent>
</>

View File

@ -0,0 +1,48 @@
{
"name": "@erp/customers",
"version": "0.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/common/index.ts",
"./api": "./src/api/index.ts",
"./client": "./src/web/manifest.ts",
"./globals.css": "./src/web/globals.css",
"./components": "./src/web/components/index.ts"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@types/express": "^4.17.21",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"@types/react-i18next": "^8.1.0",
"typescript": "^5.8.3"
},
"dependencies": {
"@ag-grid-community/locale": "34.0.0",
"@erp/core": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-ui": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"@tanstack/react-query": "^5.74.11",
"ag-grid-community": "^33.3.0",
"ag-grid-react": "^33.3.0",
"date-fns": "^4.1.0",
"express": "^4.18.2",
"i18next": "^25.1.1",
"lucide-react": "^0.503.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-i18next": "^15.5.1",
"react-router-dom": "^6.26.0",
"sequelize": "^6.37.5",
"slugify": "^1.6.6",
"tailwindcss": "^4.1.11",
"tw-animate-css": "^1.3.5",
"zod": "^3.25.67"
}
}

View File

@ -0,0 +1,29 @@
import { IModuleServer, ModuleParams } from "@erp/core/api";
//import { customerInvoicesRouter, models } from "./infrastructure";
export const customersAPIModule: IModuleServer = {
name: "customers",
version: "1.0.0",
dependencies: [],
init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
//customerInvoicesRouter(params);
logger.info("🚀 Customers module initialized", { label: "customers" });
},
registerDependencies(params) {
const { database, logger } = params;
logger.info("🚀 Customers module dependencies registered", {
label: "customers",
});
return {
//models,
services: {
/*...*/
},
};
},
};
export default customersAPIModule;

View File

@ -0,0 +1,435 @@
"use client";
import { generateUUIDv4 } from "@repo/rdx-utils";
import {
Badge,
Button,
Card,
CardContent,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Separator,
} from "@repo/shadcn-ui/components";
import {
Building,
Calendar,
Edit,
Mail,
MapPin,
Phone,
Plus,
Search,
Trash2,
User,
} from "lucide-react";
import { useState } from "react";
const mockCustomers = [
{
id: "a1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Juan Pérez",
email: "juan@email.com",
phone: "+34 600 123 456",
company: "Tech Corp",
address: "Calle Mayor 123, Madrid",
createdAt: "2024-01-15",
status: "Activo",
},
{
id: "b1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "María García",
email: "maria@email.com",
phone: "+34 600 789 012",
company: "Design Studio",
address: "Av. Diagonal 456, Barcelona",
createdAt: "2024-02-20",
status: "Activo",
},
{
id: "c1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Carlos López",
email: "carlos@email.com",
phone: "+34 600 345 678",
company: "Marketing Plus",
address: "Gran Vía 789, Valencia",
createdAt: "2024-01-30",
status: "Inactivo",
},
{
id: "d1d2e3f4-5678-90ab-cdef-1234567890ab",
name: "Ana Martínez",
email: "ana@email.com",
phone: "+34 600 901 234",
company: "Consulting Group",
address: "Calle Sierpes 321, Sevilla",
createdAt: "2024-03-10",
status: "Activo",
},
];
export const ClientSelector = () => {
const [open, setOpen] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [newCustomer, setNewCustomer] = useState({
name: "",
email: "",
phone: "",
company: "",
address: "",
});
const handleCreateCustomer = (e) => {
e.preventDefault();
const createdCustomer = {
id: generateUUIDv4(),
...newCustomer,
createdAt: new Date().toISOString().split("T")[0],
status: "Activo",
};
console.log("Cliente creado:", createdCustomer);
setSelectedCustomer(createdCustomer);
setIsCreateModalOpen(false);
setNewCustomer({ name: "", email: "", phone: "", company: "", address: "" });
};
const handleEditCustomer = () => {
console.log("Editar cliente:", selectedCustomer);
setIsDetailsModalOpen(false);
};
const handleDeleteCustomer = () => {
console.log("Eliminar cliente:", selectedCustomer);
setSelectedCustomer(null);
setIsDetailsModalOpen(false);
};
const handleSelectCustomer = (customer) => {
console.log("Seleccionar cliente:", customer);
setSelectedCustomer(customer);
setOpen(false);
};
return (
<div className='w-full max-w-md space-y-4'>
<div className='space-y-2'>
<Label>Cliente</Label>
<Button
variant='outline'
className='w-full justify-start bg-transparent'
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
>
<Search className='mr-2 h-4 w-4' />
{selectedCustomer ? selectedCustomer.name : "Buscar cliente..."}
</Button>
</div>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder='Buscar cliente por nombre, email o empresa...'
value={searchValue}
onValueChange={setSearchValue}
/>
<CommandList>
<CommandEmpty>
<div className='p-6 text-center'>
<User className='h-12 w-12 mx-auto text-muted-foreground mb-4' />
<p className='text-sm text-muted-foreground mb-4'>
No se encontró ningún cliente con "{searchValue}"
</p>
<Button
onClick={(e) => {
e.preventDefault();
setNewCustomer({ ...newCustomer, name: searchValue });
setIsCreateModalOpen(true);
setOpen(false);
}}
>
<Plus className='h-4 w-4 mr-2' />
Crear Cliente
</Button>
</div>
</CommandEmpty>
<CommandGroup heading='Clientes'>
{mockCustomers.map((customer) => (
<CommandItem
key={customer.id}
onSelect={() => handleSelectCustomer(customer)}
className='flex items-center space-x-3 p-3'
>
<User className='h-4 w-4 flex-shrink-0' />
<div className='flex-1 min-w-0'>
<div className='flex items-center space-x-2'>
<p className='font-medium truncate'>{customer.name}</p>
<Badge
variant={customer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
{customer.status}
</Badge>
</div>
<div className='flex items-center space-x-4 text-xs text-muted-foreground'>
<span className='flex items-center'>
<Building className='h-3 w-3 mr-1' />
{customer.company}
</span>
<span className='flex items-center'>
<Mail className='h-3 w-3 mr-1' />
{customer.email}
</span>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
<Separator />
<CommandGroup>
<CommandItem
onSelect={(e) => {
setIsCreateModalOpen(true);
setOpen(false);
}}
className='flex items-center space-x-3 p-3 text-primary'
>
<Plus className='h-4 w-4' />
<span>Crear nuevo cliente</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
{selectedCustomer && (
<Card className='border-primary'>
<CardContent className='p-4'>
<div className='flex items-center justify-between'>
<div className='flex items-center space-x-3'>
<User className='h-8 w-8 text-primary' />
<div>
<div className='flex items-center space-x-2'>
<h3 className='font-semibold'>{selectedCustomer.name}</h3>
<Badge
variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}
className='text-xs'
>
{selectedCustomer.status}
</Badge>
</div>
<p className='text-sm text-muted-foreground'>{selectedCustomer.company}</p>
</div>
</div>
<div className='flex space-x-2'>
<Button
variant='outline'
size='sm'
onClick={(e) => {
e.preventDefault();
setIsDetailsModalOpen(true);
}}
>
Ver Detalles
</Button>
<Button
variant='outline'
size='sm'
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
>
Cambiar
</Button>
</div>
</div>
</CardContent>
</Card>
)}
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center space-x-2'>
<Plus className='h-5 w-5' />
<span>Crear Nuevo Cliente</span>
</DialogTitle>
<DialogDescription>Completa la información del nuevo cliente</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-2'>
<Label htmlFor='name'>Nombre *</Label>
<Input
id='name'
value={newCustomer.name}
onChange={(e) => setNewCustomer({ ...newCustomer, name: e.target.value })}
placeholder='Nombre completo'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='company'>Empresa</Label>
<Input
id='company'
value={newCustomer.company}
onChange={(e) => setNewCustomer({ ...newCustomer, company: e.target.value })}
placeholder='Nombre de la empresa'
/>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='email'>Email *</Label>
<Input
id='email'
type='email'
value={newCustomer.email}
onChange={(e) => setNewCustomer({ ...newCustomer, email: e.target.value })}
placeholder='correo@ejemplo.com'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='phone'>Teléfono</Label>
<Input
id='phone'
value={newCustomer.phone}
onChange={(e) => setNewCustomer({ ...newCustomer, phone: e.target.value })}
placeholder='+34 600 000 000'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='address'>Dirección</Label>
<Input
id='address'
value={newCustomer.address}
onChange={(e) => setNewCustomer({ ...newCustomer, address: e.target.value })}
placeholder='Dirección completa'
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={(e) => {
e.preventDefault();
setIsCreateModalOpen(false);
}}
>
Cancelar
</Button>
<Button
onClick={handleCreateCustomer}
disabled={!newCustomer.name || !newCustomer.email}
>
<Plus className='h-4 w-4 mr-2' />
Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDetailsModalOpen} onOpenChange={setIsDetailsModalOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center space-x-2'>
<User className='h-5 w-5' />
<span>Detalles del Cliente</span>
</DialogTitle>
</DialogHeader>
{selectedCustomer && (
<div className='space-y-6'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h3 className='text-lg font-semibold'>{selectedCustomer.name}</h3>
<p className='text-sm text-muted-foreground'>{selectedCustomer.company}</p>
</div>
<Badge variant={selectedCustomer.status === "Activo" ? "default" : "secondary"}>
{selectedCustomer.status}
</Badge>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex items-center space-x-3'>
<Mail className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>EMAIL</Label>
<p className='font-medium'>{selectedCustomer.email}</p>
</div>
</div>
<div className='flex items-center space-x-3'>
<Phone className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>TELÉFONO</Label>
<p className='font-medium'>{selectedCustomer.phone}</p>
</div>
</div>
<div className='flex items-start space-x-3'>
<MapPin className='h-4 w-4 text-muted-foreground mt-1' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>DIRECCIÓN</Label>
<p className='font-medium'>{selectedCustomer.address}</p>
</div>
</div>
<div className='flex items-center space-x-3'>
<Calendar className='h-4 w-4 text-muted-foreground' />
<div>
<Label className='text-xs font-medium text-muted-foreground'>
FECHA DE REGISTRO
</Label>
<p className='font-medium'>
{new Date(selectedCustomer.createdAt).toLocaleDateString("es-ES")}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div className='flex space-x-2'>
<Button className='flex-1'>
<Mail className='h-4 w-4 mr-2' />
Enviar Email
</Button>
<Button variant='outline' onClick={handleEditCustomer}>
<Edit className='h-4 w-4 mr-2' />
Editar
</Button>
<Button variant='outline' onClick={handleDeleteCustomer}>
<Trash2 className='h-4 w-4 mr-2' />
Eliminar
</Button>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={() => setIsDetailsModalOpen(false)}>
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./client-selector";

View File

@ -0,0 +1,4 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "./components";
@source "./pages";

View File

@ -0,0 +1,23 @@
import { IModuleClient, ModuleClientParams } from "@erp/core/client";
//import enResources from "../common/locales/en.json";
//import esResources from "../common/locales/es.json";
export const MODULE_NAME = "Customers";
const MODULE_VERSION = "1.0.0";
export const CustomersModuleManifiest: IModuleClient = {
name: MODULE_NAME,
version: MODULE_VERSION,
dependencies: ["auth"],
protected: true,
layout: "app",
routes: (params: ModuleClientParams) => {
//i18next.addResourceBundle("en", MODULE_NAME, enResources, true, true);
//i18next.addResourceBundle("es", MODULE_NAME, esResources, true, true);
//return CustomerInvoiceRoutes(params);
return [];
},
};
export default CustomersModuleManifiest;

View File

@ -0,0 +1,33 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@erp/customer-invoices/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -20,7 +20,6 @@
"dinero.js": "^1.9.1",
"libphonenumber-js": "^1.11.20",
"shallow-equal-object": "^1.1.1",
"uuid": "^11.0.5",
"zod": "^3.24.4"
}
}

View File

@ -1,9 +1,13 @@
import { Result } from "@repo/rdx-utils";
import { v4 as uuidv4 } from "uuid";
import { Result, generateUUIDv4 } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { ValueObject } from "./value-object";
export class UniqueID extends ValueObject<string> {
static validate(value: string) {
const schema = z.uuid({ message: "Invalid UUID format" });
return schema.safeParse(value);
}
static create(id?: string, generateOnEmpty = false): Result<UniqueID, Error> {
if (!id || id?.trim() === "") {
if (!generateOnEmpty) {
@ -17,20 +21,15 @@ export class UniqueID extends ValueObject<string> {
return result.success
? Result.ok(new UniqueID(result.data))
: Result.fail(new Error(result.error.errors[0].message));
: Result.fail(new Error(result.error.message));
}
static generate(): Result<UniqueID, never> {
return Result.ok(new UniqueID(uuidv4()));
}
static validate(id: string) {
const schema = z.string().trim().uuid({ message: "Invalid UUID format" });
return schema.safeParse(id);
return UniqueID.generateNewID();
}
static generateNewID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(uuidv4()));
return Result.ok(new UniqueID(generateUUIDv4()));
}
getValue(): string {

View File

@ -0,0 +1,9 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import React from "react";
export const ButtonGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center gap-2", className)} {...props} />
)
);
ButtonGroup.displayName = "ButtonGroup";

View File

@ -1,2 +1,3 @@
export * from "./back-history-button.tsx";
export * from "./button-group.tsx";
export * from "./help-button.tsx";

View File

@ -0,0 +1,137 @@
import { Header, Table, flexRender } from "@tanstack/react-table";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Separator,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { t } from "i18next";
import { ArrowDownIcon, ArrowDownUpIcon, ArrowUpIcon, EyeOffIcon } from "lucide-react";
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
table: Table<TData>;
header: Header<TData, TValue>;
}
export function DataTableColumnHeader<TData, TValue>({
table,
header,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!header.column.getCanSort()) {
return (
<>
<div className={cn("data-[state=open]:bg-accent tracking-wide text-ellipsis", className)}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{false && header.column.getCanResize() && (
<Separator
orientation='vertical'
className={cn(
"absolute top-0 h-full w-[5px] bg-black/10 cursor-col-resize",
table.options.columnResizeDirection,
header.column.getIsResizing() ? "bg-primary opacity-100" : ""
)}
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
style: {
transform:
table.options.columnResizeMode === "onEnd" && header.column.getIsResizing()
? `translateX(${
(table.options.columnResizeDirection === "rtl" ? -1 : 1) *
(table.getState().columnSizingInfo.deltaOffset ?? 0)
}px)`
: "",
},
}}
/>
)}
</>
);
}
return (
<div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={
header.column.getIsSorted() === "desc"
? t("common.sort_desc_description")
: header.column.getIsSorted() === "asc"
? t("common.sort_asc_description")
: t("sort_none_description")
}
size='sm'
variant='ghost'
className='-ml-3 h-8 data-[state=open]:bg-accent font-bold text-muted-foreground'
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "desc" ? (
<ArrowDownIcon className='w-4 h-4 ml-2' aria-hidden='true' />
) : header.column.getIsSorted() === "asc" ? (
<ArrowUpIcon className='w-4 h-4 ml-2' aria-hidden='true' />
) : (
<ArrowDownUpIcon
className='w-4 h-4 ml-2 text-muted-foreground/30'
aria-hidden='true'
/>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{header.column.getCanSort() && (
<>
<DropdownMenuItem
onClick={() => header.column.toggleSorting(false)}
aria-label={t("common.sort_asc")}
>
<ArrowUpIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("common.sort_asc")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => header.column.toggleSorting(true)}
aria-label={t("common.sort_desc")}
>
<ArrowDownIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("common.sort_desc")}
</DropdownMenuItem>
</>
)}
{header.column.getCanSort() && header.column.getCanHide() && <DropdownMenuSeparator />}
{header.column.getCanHide() && (
<DropdownMenuItem
onClick={() => header.column.toggleVisibility(false)}
aria-label={t("Hide")}
>
<EyeOffIcon
className='mr-2 h-3.5 w-3.5 text-muted-foreground/70'
aria-hidden='true'
/>
{t("Hide")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -0,0 +1,71 @@
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@repo/shadcn-ui/components";
import { CellContext } from "@tanstack/react-table";
import { t } from "i18next";
import { MoreVerticalIcon } from "lucide-react";
import { ReactElement } from "react";
export type DataTablaRowActionFunction<TData> = (
props: CellContext<TData, unknown>
) => DataTableRowActionDefinition<TData>[];
export type DataTableRowActionDefinition<TData> = {
label: string | "-";
icon?: ReactElement<any, any>;
shortcut?: string;
onClick?: (props: CellContext<TData, unknown>, e: React.BaseSyntheticEvent) => void;
};
export type DataTableRowActionsProps<TData, _TValue = unknown> = {
className?: string;
actions?: DataTablaRowActionFunction<TData>;
rowContext: CellContext<TData, unknown>;
};
export function DataTableRowActions<TData = any, TValue = unknown>({
actions,
rowContext,
}: DataTableRowActionsProps<TData, TValue>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size='icon' variant='outline' className='w-8 h-8'>
<MoreVerticalIcon className='h-3.5 w-3.5' />
<span className='sr-only'>{t("common.open_menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>{t("common.actions")} </DropdownMenuLabel>
{actions &&
actions(rowContext).map((action, index) => {
// Use a more stable key: for separators, combine 'separator' and index; for items, use label and index
if (action.label === "-") {
// Use a more stable key by combining a static string and the previous/next action label if possible
const prevLabel = actions(rowContext)[index - 1]?.label ?? "start";
const nextLabel = actions(rowContext)[index + 1]?.label ?? "end";
return <DropdownMenuSeparator key={`separator-${prevLabel}-${nextLabel}`} />;
}
return (
<DropdownMenuItem
key={`action-${typeof action.label === "string" ? action.label : "item"}-${index}`}
onClick={(event) => (action.onClick ? action.onClick(rowContext, event) : null)}
>
{action.icon && <>{action.icon}</>}
{action.label}
{action.shortcut && <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,40 @@
import { useSortable } from "@dnd-kit/sortable";
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { t } from "i18next";
import { GripVerticalIcon } from "lucide-react";
export interface DataTableRowDragHandleCellProps {
rowId: string;
className?: string;
}
export const DataTableRowDragHandleCell = ({
rowId,
className,
}: DataTableRowDragHandleCellProps) => {
const { attributes, listeners, isDragging } = useSortable({
id: rowId,
});
return (
<Button
onClick={(event) => {
event.preventDefault();
return;
}}
size='icon'
variant='link'
className={cn(
isDragging ? "cursor-grabbing" : "cursor-grab",
"w-4 h-4 mt-2 text-ring hover:text-muted-foreground",
className
)}
{...attributes}
{...listeners}
>
<GripVerticalIcon className='w-4 h-4' />
<span className='sr-only'>{t("common.move_row")}</span>
</Button>
);
};

View File

@ -0,0 +1,3 @@
export * from "./datatable-column-header.tsx";
export * from "./datatable-row-actions.tsx";
export * from "./datatable-row-drag-handle-cell.tsx";

View File

@ -1,5 +1,6 @@
export * from "./buttons/index.tsx";
export * from "./custom-dialog.tsx";
export * from "./datatable/index.tsx";
export * from "./error-overlay.tsx";
export * from "./layout/index.tsx";
export * from "./loading-overlay/index.tsx";

View File

@ -1 +1,3 @@
@import '@repo/shadcn-ui/globals.css';
@import "tailwindcss";
@import "tw-animate-css";
@source "../components";

View File

@ -15,6 +15,7 @@
"typescript": "^5.8.3"
},
"dependencies": {
"joi": "^17.13.3"
"joi": "^17.13.3",
"uuid": "^11.0.5"
}
}

View File

@ -0,0 +1,3 @@
import { v4 as uuidv4 } from "uuid";
export const generateUUIDv4 = (): string => uuidv4();

View File

@ -1,4 +1,5 @@
export * from "./collection";
export * from "./id-utils";
export * from "./maybe";
export * from "./result";
export * from "./result-collection";

View File

@ -1,11 +1,11 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"css": "@repo/shadcn-ui/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""

View File

@ -10,10 +10,7 @@
"./components": "./src/components/index.tsx",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": [
"./src/hooks/*.ts",
"./src/hooks/*/index.ts"
]
"./hooks/*": ["./src/hooks/*.ts", "./src/hooks/*/index.ts"]
},
"scripts": {
"lint": "biome lint --fix",
@ -22,7 +19,8 @@
"peerDependencies": {
"lucide-react": "^0.503.0",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
"react-dom": "^18 || ^19",
"typescript": "^5.8.3"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
@ -32,8 +30,7 @@
"@types/node": "^22.15.12",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.3",
"postcss": "^8.5.3",
"typescript": "^5.8.3"
"postcss": "^8.5.3"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",

View File

@ -1,48 +1,9 @@
@import 'tailwindcss';
@import "tailwindcss";
@import "tw-animate-css";
@source "../components";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
:root {
--radius: 0.3rem;
--background: oklch(1 0 0);
@ -112,6 +73,45 @@
--sidebar-ring: oklch(0.488 0.243 264.376);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@layer base {
* {
@ -121,5 +121,4 @@
body {
@apply bg-background text-foreground;
}
}
}

View File

@ -1,9 +1,12 @@
import type { Config } from "tailwindcss";
const config = {
// this file is located in packages/shadcn-ui, but it is being used by apps/web (and any future web apps).
// hence, the following paths:
content: ["app/**/*.{ts,tsx}", "../../packages/shadcn-ui/src/**/*.{ts,tsx}"],
} satisfies Config;
content: [
"**/*.{ts,tsx}",
"apps/**/*.{ts,tsx}",
"../../packages/shadcn-ui/src/**/*.{ts,tsx}",
"../../modules/**/*.{ts,tsx}",
],
};
export default config;

View File

@ -1,6 +1,9 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"module": "node16",
"target": "esnext",
"moduleResolution": "node16",
"baseUrl": ".",
"paths": {
"@repo/shadcn-ui/*": ["./src/*"]

View File

@ -173,7 +173,7 @@ importers:
version: 29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3))
ts-jest:
specifier: ^29.2.5
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
version: 29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
@ -198,6 +198,9 @@ importers:
'@erp/customer-invoices':
specifier: workspace:*
version: link:../../modules/customer-invoices
'@erp/customers':
specifier: workspace:*
version: link:../../modules/customers
'@repo/rdx-criteria':
specifier: workspace:*
version: link:../../packages/rdx-criteria
@ -207,6 +210,9 @@ importers:
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
'@tailwindcss/vite':
specifier: ^4.1.11
version: 4.1.11(vite@6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4))
'@tanstack/react-query':
specifier: ^5.74.11
version: 5.81.2(react@19.1.0)
@ -250,8 +256,8 @@ importers:
specifier: ^3.2.0
version: 3.3.1
tailwindcss:
specifier: ^4.1.6
version: 4.1.10
specifier: ^4.1.10
version: 4.1.11
tw-animate-css:
specifier: ^1.2.9
version: 1.3.4
@ -271,9 +277,6 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.1.5
version: 4.1.10
'@tailwindcss/vite':
specifier: ^4.1.6
version: 4.1.10(vite@6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4))
'@tanstack/react-query-devtools':
specifier: ^5.74.11
version: 5.81.2(@tanstack/react-query@5.81.2(react@19.1.0))(react@19.1.0)
@ -432,9 +435,21 @@ importers:
'@ag-grid-community/locale':
specifier: 34.0.0
version: 34.0.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.1.0)
'@erp/core':
specifier: workspace:*
version: link:../core
'@erp/customers':
specifier: workspace:*
version: link:../customers
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
@ -456,6 +471,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.74.11
version: 5.81.2(react@19.1.0)
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
ag-grid-community:
specifier: ^33.3.0
version: 33.3.2
@ -498,6 +516,15 @@ importers:
slugify:
specifier: ^1.6.6
version: 1.6.6
sonner:
specifier: ^2.0.5
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
tw-animate-css:
specifier: ^1.3.4
version: 1.3.4
zod:
specifier: ^3.25.67
version: 3.25.67
@ -524,6 +551,103 @@ importers:
specifier: ^5.8.3
version: 5.8.3
modules/customers:
dependencies:
'@ag-grid-community/locale':
specifier: 34.0.0
version: 34.0.0
'@erp/core':
specifier: workspace:*
version: link:../core
'@hookform/resolvers':
specifier: ^5.0.1
version: 5.1.1(react-hook-form@7.58.1(react@19.1.0))
'@repo/rdx-criteria':
specifier: workspace:*
version: link:../../packages/rdx-criteria
'@repo/rdx-ddd':
specifier: workspace:*
version: link:../../packages/rdx-ddd
'@repo/rdx-ui':
specifier: workspace:*
version: link:../../packages/rdx-ui
'@repo/rdx-utils':
specifier: workspace:*
version: link:../../packages/rdx-utils
'@repo/shadcn-ui':
specifier: workspace:*
version: link:../../packages/shadcn-ui
'@tanstack/react-query':
specifier: ^5.74.11
version: 5.81.2(react@19.1.0)
ag-grid-community:
specifier: ^33.3.0
version: 33.3.2
ag-grid-react:
specifier: ^33.3.0
version: 33.3.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
date-fns:
specifier: ^4.1.0
version: 4.1.0
express:
specifier: ^4.18.2
version: 4.21.2
i18next:
specifier: ^25.1.1
version: 25.2.1(typescript@5.8.3)
lucide-react:
specifier: ^0.503.0
version: 0.503.0(react@19.1.0)
react:
specifier: ^19.1.0
version: 19.1.0
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
react-hook-form:
specifier: ^7.58.1
version: 7.58.1(react@19.1.0)
react-i18next:
specifier: ^15.5.1
version: 15.5.3(i18next@25.2.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
react-router-dom:
specifier: ^6.26.0
version: 6.30.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sequelize:
specifier: ^6.37.5
version: 6.37.7(mysql2@3.14.1)
slugify:
specifier: ^1.6.6
version: 1.6.6
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
tw-animate-css:
specifier: ^1.3.5
version: 1.3.5
zod:
specifier: ^3.25.67
version: 3.25.67
devDependencies:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
'@types/express':
specifier: ^4.17.21
version: 4.17.23
'@types/react':
specifier: ^19.1.2
version: 19.1.8
'@types/react-dom':
specifier: ^19.1.3
version: 19.1.6(@types/react@19.1.8)
'@types/react-i18next':
specifier: ^8.1.0
version: 8.1.0(i18next@25.2.1(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
typescript:
specifier: ^5.8.3
version: 5.8.3
packages/rdx-criteria:
dependencies:
'@codelytv/criteria':
@ -551,9 +675,6 @@ importers:
shallow-equal-object:
specifier: ^1.1.1
version: 1.1.1
uuid:
specifier: ^11.0.5
version: 11.1.0
zod:
specifier: ^3.24.4
version: 3.25.67
@ -657,7 +778,7 @@ importers:
version: 0.0.4
tailwindcss:
specifier: ^4.1.5
version: 4.1.10
version: 4.1.11
tsup:
specifier: ^8.4.0
version: 8.4.0(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.4)(typescript@5.8.3)
@ -679,6 +800,9 @@ importers:
joi:
specifier: ^17.13.3
version: 17.13.3
uuid:
specifier: ^11.0.5
version: 11.1.0
devDependencies:
'@repo/typescript-config':
specifier: workspace:*
@ -835,10 +959,13 @@ importers:
version: 3.3.1
tailwindcss:
specifier: ^4.1.5
version: 4.1.10
version: 4.1.11
tw-animate-css:
specifier: ^1.2.9
version: 1.3.4
typescript:
specifier: ^5.8.3
version: 5.8.3
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -870,9 +997,6 @@ importers:
postcss:
specifier: ^8.5.3
version: 8.5.6
typescript:
specifier: ^5.8.3
version: 5.8.3
packages/typescript-config: {}
@ -2505,60 +2629,117 @@ packages:
'@tailwindcss/node@4.1.10':
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
'@tailwindcss/node@4.1.11':
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
'@tailwindcss/oxide-android-arm64@4.1.10':
resolution: {integrity: sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-android-arm64@4.1.11':
resolution: {integrity: sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.10':
resolution: {integrity: sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-arm64@4.1.11':
resolution: {integrity: sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.10':
resolution: {integrity: sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.11':
resolution: {integrity: sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.10':
resolution: {integrity: sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-freebsd-x64@4.1.11':
resolution: {integrity: sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10':
resolution: {integrity: sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11':
resolution: {integrity: sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.10':
resolution: {integrity: sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.11':
resolution: {integrity: sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.10':
resolution: {integrity: sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.11':
resolution: {integrity: sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.10':
resolution: {integrity: sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.11':
resolution: {integrity: sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.10':
resolution: {integrity: sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.11':
resolution: {integrity: sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.10':
resolution: {integrity: sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==}
engines: {node: '>=14.0.0'}
@ -2571,29 +2752,57 @@ packages:
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-wasm32-wasi@4.1.11':
resolution: {integrity: sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.10':
resolution: {integrity: sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-arm64-msvc@4.1.11':
resolution: {integrity: sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.10':
resolution: {integrity: sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.11':
resolution: {integrity: sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.10':
resolution: {integrity: sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==}
engines: {node: '>= 10'}
'@tailwindcss/oxide@4.1.11':
resolution: {integrity: sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==}
engines: {node: '>= 10'}
'@tailwindcss/postcss@4.1.10':
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==}
'@tailwindcss/vite@4.1.10':
resolution: {integrity: sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A==}
'@tailwindcss/vite@4.1.11':
resolution: {integrity: sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==}
peerDependencies:
vite: ^5.2.0 || ^6
vite: ^5.2.0 || ^6 || ^7
'@tanstack/query-core@5.81.2':
resolution: {integrity: sha512-QLYkPdrudoMATDFa3MiLEwRhNnAlzHWDf0LKaXUqJd0/+QxN8uTPi7bahRlxoAyH0UbLMBdeDbYzWALj7THOtw==}
@ -5725,6 +5934,9 @@ packages:
tailwindcss@4.1.10:
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
tailwindcss@4.1.11:
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
tapable@2.2.2:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'}
@ -5951,6 +6163,9 @@ packages:
tw-animate-css@1.3.4:
resolution: {integrity: sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==}
tw-animate-css@1.3.5:
resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==}
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
@ -7984,42 +8199,88 @@ snapshots:
source-map-js: 1.2.1
tailwindcss: 4.1.10
'@tailwindcss/node@4.1.11':
dependencies:
'@ampproject/remapping': 2.3.0
enhanced-resolve: 5.18.1
jiti: 2.4.2
lightningcss: 1.30.1
magic-string: 0.30.17
source-map-js: 1.2.1
tailwindcss: 4.1.11
'@tailwindcss/oxide-android-arm64@4.1.10':
optional: true
'@tailwindcss/oxide-android-arm64@4.1.11':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.10':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.11':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.10':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.11':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.10':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.11':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.11':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.10':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.11':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.10':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.11':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.10':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.11':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.10':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.11':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.10':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.11':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.10':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.11':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.10':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.11':
optional: true
'@tailwindcss/oxide@4.1.10':
dependencies:
detect-libc: 2.0.4
@ -8038,6 +8299,24 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.10
'@tailwindcss/oxide-win32-x64-msvc': 4.1.10
'@tailwindcss/oxide@4.1.11':
dependencies:
detect-libc: 2.0.4
tar: 7.4.3
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.11
'@tailwindcss/oxide-darwin-arm64': 4.1.11
'@tailwindcss/oxide-darwin-x64': 4.1.11
'@tailwindcss/oxide-freebsd-x64': 4.1.11
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.11
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.11
'@tailwindcss/oxide-linux-arm64-musl': 4.1.11
'@tailwindcss/oxide-linux-x64-gnu': 4.1.11
'@tailwindcss/oxide-linux-x64-musl': 4.1.11
'@tailwindcss/oxide-wasm32-wasi': 4.1.11
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11
'@tailwindcss/oxide-win32-x64-msvc': 4.1.11
'@tailwindcss/postcss@4.1.10':
dependencies:
'@alloc/quick-lru': 5.2.0
@ -8046,11 +8325,11 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.10
'@tailwindcss/vite@4.1.10(vite@6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4))':
'@tailwindcss/vite@4.1.11(vite@6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4))':
dependencies:
'@tailwindcss/node': 4.1.10
'@tailwindcss/oxide': 4.1.10
tailwindcss: 4.1.10
'@tailwindcss/node': 4.1.11
'@tailwindcss/oxide': 4.1.11
tailwindcss: 4.1.11
vite: 6.3.5(@types/node@22.15.32)(jiti@2.4.2)(less@4.3.0)(lightningcss@1.30.1)(sass@1.89.0)(stylus@0.62.0)(terser@5.40.0)(tsx@4.19.4)
'@tanstack/query-core@5.81.2': {}
@ -11439,6 +11718,8 @@ snapshots:
tailwindcss@4.1.10: {}
tailwindcss@4.1.11: {}
tapable@2.2.2: {}
tar@6.2.1:
@ -11547,7 +11828,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(esbuild@0.25.5)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
ts-jest@29.4.0(@babel/core@7.27.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.15.32)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@ -11565,7 +11846,6 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.4)
esbuild: 0.25.5
jest-util: 29.7.0
ts-node@10.9.2(@types/node@22.15.32)(typescript@5.8.3):
@ -11707,6 +11987,8 @@ snapshots:
tw-animate-css@1.3.4: {}
tw-animate-css@1.3.5: {}
type-detect@4.0.8: {}
type-fest@0.21.3: {}