Facturas de cliente

This commit is contained in:
David Arranz 2025-09-29 20:22:59 +02:00
parent 5584b6039b
commit 2043bbb78b
52 changed files with 1910 additions and 169 deletions

View File

@ -1 +1,2 @@
export * from "./form-debug.tsx";
export * from "./taxes-multi-select-field.tsx";

View File

@ -41,7 +41,7 @@ export class CustomerInvoiceItemsFullPresenter extends Presenter {
discount_amount: allAmounts.discountAmount.toObjectString(),
taxable_amount: allAmounts.taxableAmount.toObjectString(),
taxes: invoiceItem.taxes.getCodesToString(),
tax_codes: invoiceItem.taxes.getCodesToString().split(","),
taxes_amount: allAmounts.taxesAmount.toObjectString(),
total_amount: allAmounts.totalAmount.toObjectString(),

View File

@ -39,7 +39,7 @@ export class CustomerInvoiceFullPresenter extends Presenter<
id: invoice.id.toString(),
company_id: invoice.companyId.toString(),
invoice_number: invoice.invoiceNumber.toString(),
invoice_number: toEmptyString(invoice.invoiceNumber, (value) => value.toString()),
status: invoice.status.toPrimitive(),
series: toEmptyString(invoice.series, (value) => value.toString()),

View File

@ -61,7 +61,7 @@ export const GetCustomerInvoiceByIdResponseSchema = z.object({
quantity: QuantitySchema,
unit_amount: MoneySchema,
taxes: z.string(),
tax_codes: z.array(z.string()),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,

View File

@ -7,6 +7,15 @@
"insert_row_below": "Insert row below",
"remove_row": "Remove"
},
"catalog": {
"status": {
"draft": "Draft",
"emitted": "Emitted",
"sent": "Sent",
"received": "Received",
"rejected": "Rejected"
}
},
"pages": {
"title": "Customer invoices",
"description": "Manage your customer invoices",
@ -44,28 +53,52 @@
"description": "View the details of the selected customer invoice"
}
},
"status": {
"draft": "Draft",
"emitted": "Emitted",
"sent": "Sent",
"received": "Received",
"rejected": "Rejected"
"form_groups": {
"customer": {
"title": "Customer",
"description": "Select the customer for this invoice"
},
"items": {
"title": "Invoice details",
"description": ""
},
"basic_into": {
"title": "Invoice information",
"description": ""
},
"totals": {
"title": "Invoice totals",
"description": ""
},
"tax_resume": {
"title": "Resumen de impuestos",
"description": ""
},
"preferences": {
"title": "Preferences",
"description": "Additional invoice settings"
}
},
"form_fields": {
"status": {
"label": "Estado",
"placeholder": "",
"description": ""
},
"invoice_number": {
"label": "Invoice number",
"placeholder": "",
"description": ""
},
"invoice_date": {
"label": "Date",
"label": "Invoice date",
"placeholder": "Select a date",
"description": "Invoice issue date"
"description": "Invoice date"
},
"invoice_series": {
"series": {
"label": "Serie",
"placeholder": "",
"description": ""
"description": "Invoice serie"
},
"operation_date": {
"label": "Operation date",

View File

@ -1,11 +1,186 @@
{
"common": {},
"common": {
"append_empty_row": "Añadir fila",
"append_empty_row_tooltip": "Añadir una fila vacía",
"duplicate_row": "Duplicar",
"insert_row_above": "Insertar fila arriba",
"insert_row_below": "Insertar fila abajo",
"remove_row": "Eliminar"
},
"catalog": {
"status": {
"draft": "Borrador",
"emitted": "Emitida",
"sent": "Enviada",
"received": "Recibida",
"rejected": "Rechazada"
}
},
"pages": {
"title": "Facturas de clientes",
"description": "Gestiona tus facturas de clientes",
"list": {
"title": "Listado de facturas de clientes",
"description": "Lista todas las facturas de clientes",
"grid_columns": {
"invoice_number": "Nº factura",
"series": "Serie",
"status": "Estado",
"invoice_date": "Fecha",
"recipient_tin": "NIF cliente",
"recipient_name": "Nombre cliente",
"recipient_city": "Ciudad cliente",
"recipient_province": "Provincia cliente",
"recipient_postal_code": "Código postal cliente",
"total_amount": "Precio total"
}
},
"create": {
"title": "Nueva factura de cliente",
"description": "Crear una nueva factura de cliente",
"back_to_list": "Volver al listado"
},
"edit": {
"title": "Editar factura de cliente",
"description": "Editar la factura de cliente seleccionada"
},
"delete": {
"title": "Eliminar factura de cliente",
"description": "Eliminar la factura de cliente seleccionada"
},
"view": {
"title": "Ver factura de cliente",
"description": "Ver los detalles de la factura de cliente seleccionada"
}
},
"form_groups": {
"customer": {
"title": "Cliente",
"description": "Selecciona el cliente para esta factura"
},
"items": {
"title": "Detalles de la factura",
"description": ""
},
"basic_into": {
"title": "Información de la factura",
"description": ""
},
"totals": {
"title": "Totales de la factura",
"description": ""
},
"preferences": {
"title": "Preferencias",
"description": "Configuraciones adicionales de la factura"
}
},
"form_fields": {
"invoice_number": {
"label": "Número de factura",
"placeholder": "",
"description": ""
},
"invoice_date": {
"label": "Fecha",
"placeholder": "Selecciona una fecha",
"description": "Fecha de emisión de la factura"
},
"series": {
"label": "Serie",
"placeholder": "",
"description": ""
},
"operation_date": {
"label": "Fecha de operación",
"placeholder": "Selecciona una fecha",
"description": "Fecha de la operación de la factura"
},
"description": {
"label": "Descripción",
"placeholder": "Descripción de la factura",
"description": "Descripción general de la factura"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"desc": "Subtotal de la factura"
},
"discount": {
"label": "Descuento (%)",
"placeholder": "",
"desc": "Porcentaje de descuento"
},
"discount_price": {
"label": "Importe del descuento",
"placeholder": "",
"desc": "Importe del descuento porcentual"
},
"total_price": {
"label": "Precio total",
"placeholder": "",
"desc": "Precio total de la factura"
},
"notes": {
"label": "Notas",
"placeholder": "Notas adicionales sobre la factura",
"description": "Notas adicionales que se pueden incluir en la factura"
},
"items": {
"quantity": {
"label": "Cantidad",
"placeholder": "",
"description": ""
},
"description": {
"label": "Descripción",
"placeholder": "",
"description": ""
},
"unit_price": {
"label": "Precio unitario",
"placeholder": "",
"description": "Precio unitario del producto"
},
"subtotal_price": {
"label": "Subtotal",
"placeholder": "",
"description": ""
},
"discount": {
"label": "Dto (%)",
"placeholder": "",
"description": "Porcentaje de descuento"
},
"discount_price": {
"label": "Importe del descuento",
"placeholder": "",
"desc": "Importe del descuento porcentual"
},
"taxes": {
"label": "Impuestos",
"placeholder": "",
"desc": "Impuestos"
},
"taxes_price": {
"label": "Importe impuestos",
"placeholder": "",
"desc": "Importe porcentual de los impuestos"
},
"total_price": {
"label": "Precio total",
"placeholder": "",
"description": "Precio total con descuento porcentual"
}
}
},
"components": {
"customer_invoice_taxes_multi_select": {
"label": "Impuestos",
"placeholder": "Seleccionar impuestos",
"description": "Seleccionar los impuestos a aplicar a los artículos de la factura",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, seleccione un impuesto válido."
"placeholder": "Selecciona impuestos",
"description": "Selecciona los impuestos a aplicar a los artículos de la factura",
"invalid_tax_selection": "Selección de impuestos no válida. Por favor, selecciona un impuesto válido."
}
}
}

View File

@ -0,0 +1,35 @@
// components/CustomerSkeleton.tsx
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../i18n";
export const CustomerInvoiceEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>
<div className='h-7 w-64 rounded-md bg-muted animate-pulse' />
<div className='h-5 w-96 rounded-md bg-muted animate-pulse' />
</div>
<div className='flex items-center gap-2'>
<BackHistoryButton />
<Button disabled aria-busy>
{t("pages.update.submit")}
</Button>
</div>
</div>
<div className='mt-6 grid gap-4' aria-hidden='true'>
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-10 w-full rounded-md bg-muted animate-pulse' />
<div className='h-28 w-full rounded-md bg-muted animate-pulse' />
</div>
<span className='sr-only'>
{t("pages.update.loading", "Cargando factura de cliente...")}
</span>
</AppContent>
</>
);
};

View File

@ -57,7 +57,7 @@ export const CustomerInvoiceStatusBadge = forwardRef<
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}`)}
{t(`catalog.status.${status}`)}
</Badge>
);
});

View File

@ -0,0 +1,63 @@
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { FieldErrors, useFormContext } from "react-hook-form";
import { FormDebug } from "@erp/core/components";
import { CustomerModalSelector } from "@erp/customers/components";
import { UserIcon } from "lucide-react";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { InvoiceBasicInfoFields } from "./invoice-basic-info-fields";
import { InvoiceItems } from "./invoice-items-editor";
import { InvoiceTaxSummary } from "./invoice-tax-summary";
import { InvoiceTotals } from "./invoice-totals";
interface CustomerInvoiceFormProps {
formId: string;
onSubmit: (data: CustomerInvoiceFormData) => void;
onError: (errors: FieldErrors<CustomerInvoiceFormData>) => void;
}
export const CustomerInvoiceEditForm = ({
formId,
onSubmit,
onError,
}: CustomerInvoiceFormProps) => {
const { t } = useTranslation();
const form = useFormContext<CustomerInvoiceFormData>();
return (
<form id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<div className='w-full'>
<FormDebug />
</div>
<div className='mx-auto grid w-full grid-cols-1 gap-6 lg:grid-flow-col-dense lg:grid-cols-2 items-stretch'>
<div className='lg:col-start-1 space-y-6'>
<InvoiceBasicInfoFields />
</div>
<div className='space-y-6 '>
<Fieldset>
<Legend className='flex items-center gap-2 text-foreground'>
<UserIcon className='size-5' /> {t("form_groups.customer.title")}
</Legend>
<Description>{t("form_groups.customer.description")}</Description>
<FieldGroup>
<CustomerModalSelector />
</FieldGroup>
</Fieldset>
</div>
<div className='lg:col-start-1 lg:col-span-2 space-y-6'>
<InvoiceItems />
</div>
<div className='lg:col-start-1 space-y-6'>
<InvoiceTaxSummary />
</div>
<div className='space-y-6 '>
<InvoiceTotals />
</div>
</div>
</form>
);
};

View File

@ -0,0 +1 @@
export * from "./customer-invoice-edit-form";

View File

@ -0,0 +1,90 @@
import {
DatePickerInputField,
Description,
Field,
FieldGroup,
Fieldset,
Legend,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
export const InvoiceBasicInfoFields = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const status = useWatch({
control,
name: "status",
defaultValue: "",
});
return (
<Fieldset>
<Legend className='flex items-center gap-2 text-foreground'>
<FileTextIcon className='h-5 w-5' /> {t("form_groups.basic_into.title")}
</Legend>
<Description>{t("form_groups.basic_into.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<TextField
control={control}
name='invoice_number'
required
readOnly
label={t("form_fields.invoice_number.label")}
placeholder={t("form_fields.invoice_number.placeholder")}
description={t("form_fields.invoice_number.description")}
/>
<TextField
control={control}
name='series'
label={t("form_fields.series.label")}
placeholder={t("form_fields.series.placeholder")}
description={t("form_fields.series.description")}
/>
<Field className='lg:col-span-2 2xl:col-span-1'>
<DatePickerInputField
control={control}
name='invoice_date'
required
label={t("form_fields.invoice_date.label")}
placeholder={t("form_fields.invoice_date.placeholder")}
description={t("form_fields.invoice_date.description")}
/>
</Field>
<Field className='lg:col-span-2 lg:col-start-1 2xl:col-auto'>
<DatePickerInputField
control={control}
name='operation_date'
label={t("form_fields.operation_date.label")}
placeholder={t("form_fields.operation_date.placeholder")}
description={t("form_fields.operation_date.description")}
/>
</Field>
<TextField
className='lg:col-span-2'
control={control}
name='description'
label={t("form_fields.description.label")}
placeholder={t("form_fields.description.placeholder")}
description={t("form_fields.description.description")}
/>
<TextAreaField
className='lg:col-span-2'
control={control}
name='notes'
label={t("form_fields.notes.label")}
placeholder={t("form_fields.notes.placeholder")}
description={t("form_fields.notes.description")}
/>
</FieldGroup>
</Fieldset>
);
};

View File

@ -0,0 +1,123 @@
import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { Grid3X3Icon, Package, PlusIcon, TableIcon } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
import { BlocksView, TableView } from "./items";
export const InvoiceItems = () => {
const [viewMode, setViewMode] = useState<"blocks" | "table">("blocks");
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const invoice = useWatch({ control });
const { fields: items, ...fieldActions } = useFieldArray({
control,
name: "items",
});
const addItem = () => {
/*const newItem = {
position: invoice.items ? invoice.items.length + 1 : 0,
description: "",
quantity: 1,
unit_amount: 0,
taxes: [],
subtotal_amount: 0,
discount_percentage: 0,
discount_amount: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
};
setInvoice({
...invoice,
items: [...invoice.items, newItem],
});*/
};
const removeItem = (index: number) => {
/*const newItems = invoice.items.filter((_: any, i: number) => i !== index);
setInvoice({
...invoice,
items: newItems,
});*/
};
const updateItem = (index: number, field: string, value: any) => {
/*const newItems = [...invoice.items];
newItems[index] = { ...newItems[index], [field]: value };
// Recalculate amounts
const item = newItems[index];
item.subtotal_amount = item.quantity * item.unit_amount;
item.discount_amount = (item.subtotal_amount * item.discount_percentage) / 100;
item.taxable_amount = item.subtotal_amount - item.discount_amount;
item.taxes_amount = item.taxable_amount * 0.21; // Mock 21% tax
item.total_amount = item.taxable_amount + item.taxes_amount;
setInvoice({
...invoice,
items: newItems,
});*/
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(amount);
};
return (
<Card className='border-none shadow-none'>
<CardHeader>
<div className='flex items-center justify-between'>
<CardTitle className='text-lg font-medium flex items-center gap-2'>
<Package className='h-5 w-5' />
Detalles de la Factura
</CardTitle>
<div className='flex items-center gap-2'>
<div className='flex items-center border rounded-lg p-1'>
<Button
variant={viewMode === "blocks" ? "default" : "ghost"}
size='sm'
onClick={() => setViewMode("blocks")}
className='h-8 px-3'
>
<Grid3X3Icon className='h-4 w-4 mr-1' />
Bloques
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size='sm'
onClick={() => setViewMode("table")}
className='h-8 px-3'
>
<TableIcon className='h-4 w-4 mr-1' />
Tabla
</Button>
</div>
<Button onClick={addItem} size='sm'>
<PlusIcon className='h-4 w-4 mr-2' />
Añadir Línea
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{viewMode === "blocks" ? (
<BlocksView items={items} removeItem={removeItem} updateItem={updateItem} />
) : (
<TableView items={items} removeItem={removeItem} updateItem={updateItem} />
)}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,108 @@
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Badge } from "@repo/shadcn-ui/components";
import { ReceiptIcon } from "lucide-react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
export const InvoiceTaxSummary = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
const taxes = useWatch({
control,
name: "taxes",
defaultValue: [],
});
const formatCurrency = (amount: {
value: string;
scale: string;
currency_code: string;
}) => {
const { currency_code, value, scale } = amount;
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: currency_code,
minimumFractionDigits: Number(scale),
maximumFractionDigits: Number(scale),
compactDisplay: "short",
currencyDisplay: "symbol",
}).format(Number(value) / 10 ** Number(scale));
};
// Mock tax data
const mockTaxes = [
{
tax_code: "IVA 21%",
taxable_amount: {
value: "10000",
scale: "2",
currency_code: "EUR",
},
taxes_amount: {
value: "21000",
scale: "2",
currency_code: "EUR",
},
},
{
tax_code: "IVA 10%",
taxable_amount: {
value: "50000",
scale: "2",
currency_code: "EUR",
},
taxes_amount: {
value: "5000",
scale: "2",
currency_code: "EUR",
},
},
];
const displayTaxes = taxes ? taxes : mockTaxes;
return (
<Fieldset>
<Legend className='flex items-center gap-2 text-foreground'>
<ReceiptIcon className='h-5 w-5' /> {t("form_groups.tax_resume.title")}
</Legend>
<Description>{t("form_groups.tax_resume.description")}</Description>
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
{displayTaxes.map((tax, index) => (
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3'>
<div className='flex items-center justify-between mb-2'>
<Badge variant='secondary' className='text-xs'>
{tax.tax_code}
</Badge>
</div>
<div className='space-y-1 text-sm'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Base Imponible:</span>
<span className='font-medium'>{formatCurrency(tax.taxable_amount)}</span>
</div>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Importe Impuesto:</span>
<span className='font-medium text-primary'>
{formatCurrency(tax.taxes_amount)}
</span>
</div>
</div>
</div>
))}
{displayTaxes.length === 0 && (
<div className='text-center py-6 text-muted-foreground'>
<ReceiptIcon className='h-8 w-8 mx-auto mb-2 opacity-50' />
<p className='text-sm'>No hay impuestos aplicados</p>
</div>
)}
</div>
</FieldGroup>
</Fieldset>
);
};

View File

@ -0,0 +1,114 @@
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
import { Input, Label, Separator } from "@repo/shadcn-ui/components";
import { CalculatorIcon } from "lucide-react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceFormData } from "../../schemas";
export const InvoiceTotals = () => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
//const invoiceFormData = useWatch({ control });
const [invoice, setInvoice] = useState({
items: [],
subtotal_amount: 0,
discount_percentage: 0,
discount_amount: 0,
taxable_amount: 0,
taxes_amount: 0,
total_amount: 0,
});
const updateDiscount = (value: number) => {
const subtotal = invoice.items.reduce(
(sum: number, item: any) => sum + item.subtotal_amount,
0
);
const discountAmount = (subtotal * value) / 100;
const taxableAmount = subtotal - discountAmount;
const taxesAmount = taxableAmount * 0.21; // Mock calculation
const totalAmount = taxableAmount + taxesAmount;
setInvoice({
...invoice,
subtotal_amount: subtotal,
discount_percentage: value,
discount_amount: discountAmount,
taxable_amount: taxableAmount,
taxes_amount: taxesAmount,
total_amount: totalAmount,
});
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
return (
<Fieldset>
<Legend className='flex items-center gap-2 text-foreground'>
<CalculatorIcon className='h-5 w-5' /> {t("form_groups.totals.title")}
</Legend>
<Description>{t("form_groups.totals.description")}</Description>
<FieldGroup className='grid grid-cols-1'>
<div className='space-y-3'>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Subtotal</Label>
<span className='font-medium'>{formatCurrency(invoice.subtotal_amount)}</span>
</div>
<div className='flex justify-between items-center gap-4'>
<Label className='text-sm text-muted-foreground'>Descuento Global</Label>
<div className='flex items-center gap-2'>
<Input
type='number'
step='0.01'
value={invoice.discount_percentage}
onChange={(e) => updateDiscount(Number.parseFloat(e.target.value) || 0)}
className='w-20 text-right'
/>
<span className='text-sm text-muted-foreground'>%</span>
</div>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Importe Descuento</Label>
<span className='font-medium text-destructive'>
-{formatCurrency(invoice.discount_amount)}
</span>
</div>
<Separator />
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Base Imponible</Label>
<span className='font-medium'>{formatCurrency(invoice.taxable_amount)}</span>
</div>
<div className='flex justify-between items-center'>
<Label className='text-sm text-muted-foreground'>Total Impuestos</Label>
<span className='font-medium'>{formatCurrency(invoice.taxes_amount)}</span>
</div>
<Separator />
<div className='flex justify-between items-center'>
<Label className='text-lg font-semibold'>Total Factura</Label>
<span className='text-xl font-bold text-primary'>
{formatCurrency(invoice.total_amount)}
</span>
</div>
</div>
</FieldGroup>
</Fieldset>
);
};

View File

@ -0,0 +1,147 @@
import { TaxesMultiSelectField } from "@erp/core/components";
import { Badge, Button, Input, Label } from "@repo/shadcn-ui/components";
import { Trash2 } from "lucide-react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import { CustomerInvoiceFormData } from "../../../schemas";
import { CustomItemViewProps } from "./types";
export interface BlocksViewProps extends CustomItemViewProps {}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(amount);
};
export const BlocksView = ({ items, removeItem, updateItem }: BlocksViewProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerInvoiceFormData>();
return (
<div className='space-y-4'>
{items.map((item: any, index: number) => (
<div key={`item-${String(index)}`} className='border rounded-lg p-4 space-y-4'>
<div className='flex items-center justify-between'>
<Badge variant='outline' className='text-xs'>
Línea {item.position}
</Badge>
{items.length > 1 && (
<Button
variant='ghost'
size='sm'
onClick={() => removeItem(index)}
className='text-destructive hover:text-destructive'
>
<Trash2 className='h-4 w-4' />
</Button>
)}
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
<div className='col-span-full space-y-2'>
<Label className='text-sm font-medium'>Descripción</Label>
<Input
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
placeholder='Descripción del producto o servicio...'
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>Cantidad</Label>
<Input
type='number'
step='0.01'
value={item.quantity}
onChange={(e) =>
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
}
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>Precio Unitario</Label>
<Input
type='number'
step='0.0001'
value={item.unit_amount}
onChange={(e) =>
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0)
}
/>
</div>
<div className='space-y-2'>
<Label className='text-sm font-medium'>% Descuento</Label>
<Input
type='number'
step='0.0001'
value={item.discount_percentage}
onChange={(e) =>
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0)
}
/>
</div>
<div className='space-y-2 col-start-1'>
<TaxesMultiSelectField
control={control}
name={`items.${index}.tax_codes`}
required
label={t("form_fields.item.tax_codes.label")}
placeholder={t("form_fields.item.tax_codes.placeholder")}
description={t("form_fields.item.tax_codes.description")}
/>
{/*
<Label className='text-sm font-medium'>Impuestos</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder='Seleccionar...' />
</SelectTrigger>
<SelectContent>
<SelectItem value='iva21'>IVA 21%</SelectItem>
<SelectItem value='iva10'>IVA 10%</SelectItem>
<SelectItem value='iva4'>IVA 4%</SelectItem>
<SelectItem value='exento'>Exento</SelectItem>
</SelectContent>
</Select>
*/}
</div>
</div>
{/* Calculated amounts */}
<div className='bg-muted/30 rounded-lg p-3'>
<div className='grid grid-cols-2 md:grid-cols-5 gap-4 text-sm'>
<div>
<Label className='text-xs text-muted-foreground'>SUBTOTAL</Label>
<p className='font-medium'>{formatCurrency(item.subtotal_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>DESCUENTO</Label>
<p className='font-medium'>{formatCurrency(item.discount_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>BASE IMPONIBLE</Label>
<p className='font-medium'>{formatCurrency(item.taxable_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>IMPUESTOS</Label>
<p className='font-medium'>{formatCurrency(item.taxes_amount)}</p>
</div>
<div>
<Label className='text-xs text-muted-foreground'>TOTAL</Label>
<p className='font-semibold text-primary'>{formatCurrency(item.total_amount)}</p>
</div>
</div>
</div>
</div>
))}
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./blocks-view";
export * from "./table-view";

View File

@ -0,0 +1,141 @@
import {
Badge,
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components";
import { Trash2Icon } from "lucide-react";
import { CustomItemViewProps } from "./types";
export interface TableViewProps extends CustomItemViewProps {}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("es-ES", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(amount);
};
export const TableView = ({ items, removeItem, updateItem }: TableViewProps) => {
return (
<div className='overflow-x-auto'>
<table className='w-full border-collapse'>
<thead>
<tr className='border-b bg-muted/30'>
<th className='text-left p-3 text-sm font-medium'>#</th>
<th className='text-left p-3 text-sm font-medium min-w-[200px]'>Descripción</th>
<th className='text-left p-3 text-sm font-medium w-24'>Cantidad</th>
<th className='text-left p-3 text-sm font-medium w-32'>Precio Unit.</th>
<th className='text-left p-3 text-sm font-medium w-24'>% Desc.</th>
<th className='text-left p-3 text-sm font-medium w-32'>Impuestos</th>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Subtotal</th>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Descuento</th>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Base Imp.</th>
<th className='text-left p-3 text-sm font-medium w-32 sr-only'>Impuestos</th>
<th className='text-left p-3 text-sm font-medium w-32'>Total</th>
<th className='text-left p-3 text-sm font-medium w-16'>Acciones</th>
</tr>
</thead>
<tbody>
{items.map((item: any, index: number) => (
<tr key={`item-${String(index)}`} className='border-b hover:bg-muted/20'>
<td className='p-3'>
<Badge variant='outline' className='text-xs'>
{item.position}
</Badge>
</td>
<td className='p-3'>
<Input
value={item.description}
onChange={(e) => updateItem(index, "description", e.target.value)}
placeholder='Descripción...'
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0'
/>
</td>
<td className='p-3'>
<Input
type='number'
step='0.01'
value={item.quantity}
onChange={(e) =>
updateItem(index, "quantity", Number.parseFloat(e.target.value) || 0)
}
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right'
/>
</td>
<td className='p-3'>
<Input
type='number'
step='0.0001'
value={item.unit_amount}
onChange={(e) =>
updateItem(index, "unit_amount", Number.parseFloat(e.target.value) || 0)
}
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right'
/>
</td>
<td className='p-3'>
<Input
type='number'
step='0.0001'
value={item.discount_percentage}
onChange={(e) =>
updateItem(index, "discount_percentage", Number.parseFloat(e.target.value) || 0)
}
className='border-0 bg-transparent p-0 h-auto focus-visible:ring-0 text-right'
/>
</td>
<td className='p-3'>
<Select>
<SelectTrigger className='border-0 bg-transparent p-0 h-auto focus:ring-0'>
<SelectValue placeholder='IVA 21%' />
</SelectTrigger>
<SelectContent>
<SelectItem value='iva21'>IVA 21%</SelectItem>
<SelectItem value='iva10'>IVA 10%</SelectItem>
<SelectItem value='iva4'>IVA 4%</SelectItem>
<SelectItem value='exento'>Exento</SelectItem>
</SelectContent>
</Select>
</td>
<td className='sr-only p-3 text-right text-sm font-medium'>
{formatCurrency(item.subtotal_amount)}
</td>
<td className='sr-only p-3 text-right text-sm font-medium'>
{formatCurrency(item.discount_amount)}
</td>
<td className='sr-only p-3 text-right text-sm font-medium'>
{formatCurrency(item.taxable_amount)}
</td>
<td className='sr-only p-3 text-right text-sm font-medium'>
{formatCurrency(item.taxes_amount)}
</td>
<td className='p-3 text-right text-sm font-semibold text-primary'>
{formatCurrency(item.total_amount)}
</td>
<td className='p-3'>
{items.length > 1 && (
<Button
variant='ghost'
size='sm'
onClick={() => removeItem(index)}
className='text-destructive hover:text-destructive h-8 w-8 p-0'
>
<Trash2Icon className='h-4 w-4' />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@ -0,0 +1,5 @@
export interface CustomItemViewProps {
items: any;
updateItem: (index: number, field: string, value: any) => void;
removeItem: (index: number) => void;
}

View File

@ -1,5 +1,10 @@
export * from "./customer-invoice-editor-skeleton";
export * from "./customer-invoice-prices-card";
export * from "./customer-invoice-status-badge";
export * from "./customer-invoice-taxes-multi-select";
export * from "./customer-invoices-layout";
export * from "./customer-invoices-list-grid";
export * from "./editor";
export * from "./editor/invoice-tax-summary";
export * from "./editor/invoice-totals";
export * from "./page-header";

View File

@ -0,0 +1,41 @@
// features/common/components/page-header.tsx
import type { ReactNode } from "react";
import { CustomerInvoiceStatusBadge } from "./customer-invoice-status-badge";
interface PageHeaderProps {
/** Icono que aparece a la izquierda del título */
icon?: ReactNode;
/** Contenido del título (texto plano o nodo complejo) */
title: ReactNode;
/** Descripción secundaria debajo del título */
description?: ReactNode;
/** Estado opcional (ej. "draft", "paid") */
status?: string;
/** Contenido del lado derecho (botones, menús, etc.) */
rightSlot?: ReactNode;
}
export function PageHeader({ icon, title, description, status, rightSlot }: PageHeaderProps) {
return (
<div className='border-b bg-card -px-4'>
<div className='mx-auto w-full px-6 pt-2 pb-8'>
<div className='flex items-center justify-between'>
{/* Lado izquierdo */}
<div className='flex items-center gap-3'>
{icon && <div className='shrink-0'>{icon}</div>}
<div>
<div className='flex items-center gap-3'>
<h1 className='text-2xl font-semibold text-foreground'>{title}</h1>
{status && <CustomerInvoiceStatusBadge status={status} />}
</div>
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
</div>
</div>
{/* Lado derecho parametrizable */}
{rightSlot && <div>{rightSlot}</div>}
</div>
</div>
</div>
);
}

View File

@ -14,6 +14,9 @@ const CustomerInvoicesList = lazy(() =>
const CustomerInvoiceAdd = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerInvoiceCreate }))
);
const CustomerInvoiceUpdate = lazy(() =>
import("./pages").then((m) => ({ default: m.CustomerInvoiceUpdate }))
);
export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[] => {
return [
@ -28,6 +31,7 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
{ path: "", index: true, element: <CustomerInvoicesList /> }, // index
{ path: "list", element: <CustomerInvoicesList /> },
{ path: "create", element: <CustomerInvoiceAdd /> },
{ path: ":id/edit", element: <CustomerInvoiceUpdate /> },
//
/*{ path: "create", element: <CustomerInvoicesList /> },

View File

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

View File

@ -2,7 +2,7 @@ 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 { CustomerModalSelector } from "@erp/customers/components";
import { DevTool } from "@hookform/devtools";
import { DatePickerInputField, TextAreaField, TextField } from "@repo/rdx-ui/components";
@ -229,7 +229,7 @@ interface InvoiceFormProps {
onSubmit?: (data: CustomerInvoiceData) => void;
}
export const CustomerInvoiceEditForm = ({
export const CreateCustomerInvoiceEditForm = ({
initialData = defaultInvoiceData,
onSubmit,
isPending,
@ -387,7 +387,7 @@ export const CustomerInvoiceEditForm = ({
<CardTitle>Cliente</CardTitle>
</CardHeader>
<CardContent className='grid grid-cols-1 gap-4 space-y-6'>
<ClientSelector />
<CustomerModalSelector />
<TextField
control={form.control}
name='customer_id'

View File

@ -3,7 +3,7 @@ import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerInvoiceMutation } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerInvoiceEditForm } from "./customer-invoice-edit-form";
import { CreateCustomerInvoiceEditForm } from "./create-customer-invoice-edit-form";
export const CustomerInvoiceCreate = () => {
const { t } = useTranslation();
@ -64,7 +64,7 @@ export const CustomerInvoiceCreate = () => {
</div>
</div>
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerInvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
<CreateCustomerInvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
</div>
</AppContent>
</>

View File

@ -1,2 +1,3 @@
export * from "./create";
export * from "./customer-invoices-list";
export * from "./update";

View File

@ -1,9 +1,28 @@
import { useHookForm, useUrlParamId } from "@erp/core/hooks";
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import {
FormCommitButtonGroup,
UnsavedChangesProvider,
useHookForm,
useUrlParamId,
} from "@erp/core/hooks";
import { ErrorAlert, NotFoundCard } from "@erp/customers/components";
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { FilePenIcon } from "lucide-react";
import { FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useCustomerInvoiceQuery } from "../../hooks";
import {
CustomerInvoiceEditForm,
CustomerInvoiceEditorSkeleton,
PageHeader,
} from "../../components";
import { useCustomerInvoiceQuery, useUpdateCustomerInvoice } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
CustomerInvoiceFormData,
CustomerInvoiceFormSchema,
defaultCustomerInvoiceFormData,
} from "../../schemas";
export const CustomerInvoiceUpdate = () => {
const invoiceId = useUrlParamId();
@ -29,124 +48,130 @@ export const CustomerInvoiceUpdate = () => {
// 3) Form hook
const form = useHookForm<CustomerInvoiceFormData>({
resolverSchema: CustomerInvoiceFormSchema,
initialValues: customerInvoiceData ?? defaultCustomerInvoiceFormData,
initialValues: invoiceData ?? defaultCustomerInvoiceFormData,
disabled: isUpdating,
});
const handleSubmit = (data: any) => {
// Handle form submission logic here
console.log("Form submitted with data:", data);
mutate(data);
const handleSubmit = (formData: CustomerInvoiceFormData) => {
const { dirtyFields } = form.formState;
// Navigate to the list page after submission
navigate("/customer-invoices/list");
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast("No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
mutate(
{ id: invoiceId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
},
}
);
};
if (isError) {
console.error("Error creating customer invoice:", error);
// Optionally, you can show an error message to the user
const handleReset = () => form.reset(invoiceData ?? defaultCustomerInvoiceFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<CustomerInvoiceFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
if (isLoadingInvoice) {
return <CustomerInvoiceEditorSkeleton />;
}
// Render the component
// You can also handle loading state if needed
// For example, you can disable the submit button while the mutation is in progress
// const isLoading = useCreateCustomerInvoiceMutation().isLoading;
if (isLoadError) {
return (
<>
<AppBreadcrumb />
<AppContent>
<ErrorAlert
title={t("pages.update.loadErrorTitle", "No se pudo cargar la factura")}
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
/>
// Return the JSX for the component
// You can customize the form and its fields as needed
// For example, you can use a form library like react-hook-form or Formik to handle form state and validation
// Here, we are using a simple form with a submit button
<div className='flex items-center justify-end'>
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
// Note: Make sure to replace the form fields with your actual invoice fields
// and handle validation as needed.
// This is just a basic example to demonstrate the structure of the component.
// If you are using a form library, you can pass the handleSubmit function to the form's onSubmit prop
// and use the form library's methods to handle form state and validation.
// Example of a simple form submission handler
// You can replace this with your actual form handling logic
// const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
// event.preventDefault();
// const formData = new FormData(event.currentTarget);
if (!invoiceData)
return (
<>
<AppBreadcrumb />
<AppContent>
<NotFoundCard
title={t("pages.update.notFoundTitle", "Factura de cliente no encontrada")}
message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
/>
</AppContent>
</>
);
return (
<>
<AppBreadcrumb />
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<AppBreadcrumb />
<PageHeader
status={invoiceData.status}
title={
<>
{t("pages.edit.title")} {invoiceData.invoice_number}
</>
}
description={t("pages.edit.description")}
icon={<FilePenIcon className='size-12 text-primary stroke-1' aria-hidden />}
rightSlot={
<FormCommitButtonGroup
isLoading={isUpdating}
disabled={isUpdating}
cancel={{ to: "/customer-invoices/list", disabled: isUpdating }}
submit={{ formId: "customer-invoice-update-form", disabled: isUpdating }}
onBack={handleBack}
onReset={handleReset}
/>
}
/>
</AppHeader>
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>{t("pages.create.title")}</h2>
<p className='text-muted-foreground'>{t("pages.create.description")}</p>
</div>
<div className='flex items-center justify-end mb-4'>
<Button className='cursor-pointer' onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-4 p-4'>
<CustomerInvoiceEditForm onSubmit={handleSubmit} isPending={isPending} />
</div>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
/>
)}
<FormProvider {...form}>
<CustomerInvoiceEditForm
formId={"customer-invoice-update-form"} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit}
onError={handleError}
/>
</FormProvider>
</AppContent>
</>
</UnsavedChangesProvider>
);
};
/*
return (
<>
<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.subtitle' />
</p>
</div>
<div className='flex items-center space-x-2'>
<Button onClick={() => navigate("/CustomerInvoices/add")}>
<PlusIcon className='w-4 h-4 mr-2' />
{t("customerInvoices.create.title")}
</Button>
</div>
</div>
<Tabs value={status} onValueChange={setStatus}>
<div className='flex flex-col items-start justify-between mb-4 sm:flex-row sm:items-center'>
<div className='w-full mb-4 sm:w-auto sm:mb-0'>
<TabsList className='hidden sm:flex'>
{CustomerInvoiceStatuses.map((s) => (
<TabsTrigger key={s.value} value={s.value}>
{s.label}
</TabsTrigger>
))}
</TabsList>
<div className='flex items-center w-full space-x-2 sm:hidden'>
<Label>{t("customerInvoices.list.tabs_title")}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder='Seleccionar estado' />
</SelectTrigger>
<SelectContent>
{CustomerInvoiceStatuses.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{CustomerInvoiceStatuses.map((s) => (
<TabsContent key={s.value} value={s.value}>
<CustomerInvoicesGrid />
</TabsContent>
))}
</Tabs>
</>
);
};
*/

View File

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

View File

@ -1,16 +1,18 @@
import { z } from "zod/v4";
import {
CreateCustomerInvoiceRequestSchema,
GetCustomerInvoiceByIdResponseSchema,
ListCustomerInvoicesResponseDTO,
} from "@erp/customer-invoices/common";
UpdateCustomerInvoiceByIdRequestSchema,
} from "../../common";
/*export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;*/
export const CustomerSchema = GetCustomerInvoiceByIdResponseSchema.omit({
export const CustomerInvoiceCreateSchema = CreateCustomerInvoiceRequestSchema;
export const CustomerInvoiceUpdateSchema = UpdateCustomerInvoiceByIdRequestSchema;
export const CustomerInvoiceSchema = GetCustomerInvoiceByIdResponseSchema.omit({
metadata: true,
});
export type CustomerInvoiceData = z.infer<typeof CustomerSchema>;
export type CustomerInvoiceData = z.infer<typeof CustomerInvoiceSchema>;
export type CustomerInvoicesListData = ListCustomerInvoicesResponseDTO;

View File

@ -1,15 +1,118 @@
import { MoneySchema, PercentageSchema, QuantitySchema } from "@erp/core";
import { z } from "zod/v4";
export const CustomerInvoiceFormSchema = z.object({
invoice_number: z.string().optional(),
status: z.string().optional(),
status: z.string(),
series: z.string().optional(),
invoice_date: z.string().optional(),
operation_date: z.string().optional(),
customer_id: z.string().optional(),
notes: z.string().optional(),
language_code: z
.string({
error: "El idioma es obligatorio",
})
.min(1, "Debe indicar un idioma")
.toUpperCase() // asegura mayúsculas
.default("es"),
currency_code: z
.string({
error: "La moneda es obligatoria",
})
.min(1, "La moneda no puede estar vacía")
.toUpperCase() // asegura mayúsculas
.default("EUR"),
taxes: z
.array(
z.object({
tax_code: z.string(),
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
})
)
.optional(),
items: z
.array(
z.object({
position: z.string(),
description: z.string(),
quantity: QuantitySchema,
unit_amount: MoneySchema,
tax_codes: z.array(z.string()).default([]),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
})
)
.optional(),
subtotal_amount: MoneySchema,
discount_percentage: PercentageSchema,
discount_amount: MoneySchema,
taxable_amount: MoneySchema,
taxes_amount: MoneySchema,
total_amount: MoneySchema,
});
export type CustomerInvoiceFormData = z.infer<typeof CustomerInvoiceFormSchema>;
export const defaultCustomerFormData: CustomerInvoiceFormData = {
export const defaultCustomerInvoiceFormData: CustomerInvoiceFormData = {
invoice_number: "",
status: "draft",
series: "",
invoice_date: "",
operation_date: "",
notes: "",
language_code: "es",
currency_code: "EUR",
taxes: [],
items: [],
subtotal_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
discount_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
discount_percentage: {
value: "0",
scale: "2",
},
taxable_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
taxes_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
total_amount: {
currency_code: "EUR",
value: "0",
scale: "2",
},
};

View File

@ -121,7 +121,7 @@ export class CustomerRepository
});
const converter = new CriteriaToSequelizeConverter();
const query = converter.convert(criteria);
const query = converter.convert(criteria, { name: "name" });
query.where = {
...query.where,

View File

@ -121,7 +121,7 @@ async function fetchClientes(search: string): Promise<Customer[]> {
);
}
export const ClientSelector = () => {
export const ClientSelectorModal = () => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");

View File

@ -0,0 +1,436 @@
"use client";
import {
Badge,
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import {
Building2Icon,
Check,
FileTextIcon,
Mail,
Phone,
Plus,
PlusIcon,
Search,
SearchIcon,
User,
XIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
interface Client {
id: string;
name: string;
email: string;
phone?: string;
company?: string;
taxId?: string;
}
interface ClientSelectorProps {
value?: string;
onValueChange?: (clientId: string) => void;
placeholder?: string;
}
// Datos de ejemplo - en una app real vendrían de tu base de datos
const mockClients: Client[] = [
{
id: "1",
name: "María García",
email: "maria.garcia@empresa.com",
phone: "+34 666 123 456",
company: "Empresa ABC S.L.",
taxId: "B12345678",
},
{
id: "2",
name: "Juan Pérez",
email: "juan.perez@gmail.com",
phone: "+34 677 987 654",
company: "Autónomo",
taxId: "12345678Z",
},
{
id: "3",
name: "Ana Martínez",
email: "ana@startup.io",
phone: "+34 688 555 777",
company: "StartUp Innovadora",
taxId: "B87654321",
},
{
id: "4",
name: "Carlos López",
email: "carlos.lopez@corporacion.es",
phone: "+34 699 111 222",
company: "Corporación XYZ",
taxId: "A11111111",
},
];
export function CustomerModalSelector({
value,
onValueChange,
placeholder = "Seleccionar cliente...",
}: ClientSelectorProps) {
const [showClientSelector, setShowClientSelector] = useState(false);
const [clients, setClients] = useState<Client[]>(mockClients);
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [showNewClientDialog, setShowNewClientDialog] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [newClient, setNewClient] = useState({
name: "",
email: "",
phone: "",
company: "",
taxId: "",
});
useEffect(() => {
if (value) {
const client = clients.find((c) => c.id === value);
setSelectedClient(client || null);
}
}, [value, clients]);
const filteredClients = clients.filter(
(client) =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.company?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleSelectClient = (client: Client) => {
setSelectedClient(client);
onValueChange?.(client.id);
setShowClientSelector(false);
};
const handleCreateClient = () => {
if (!newClient.name || !newClient.email) return;
const client: Client = {
id: Date.now().toString(),
name: newClient.name,
email: newClient.email,
phone: newClient.phone || undefined,
company: newClient.company || undefined,
taxId: newClient.taxId || undefined,
};
setClients((prev) => [...prev, client]);
setSelectedClient(client);
onValueChange?.(client.id);
setShowNewClientDialog(false);
setShowClientSelector(false);
setNewClient({
name: "",
email: "",
phone: "",
company: "",
taxId: "",
});
};
const openNewClientDialog = () => {
setNewClient({ ...newClient, name: searchQuery });
setShowNewClientDialog(true);
};
return (
<>
<div
role='button'
tabIndex={0}
onClick={() => setShowClientSelector(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setShowClientSelector(true);
}
}}
className='group w-full cursor-pointer rounded-lg border border-border bg-card p-4 transition-all hover:bg-accent/50 hover:border-primary'
>
{selectedClient ? (
<div className='flex items-start gap-4'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 border border-primary/20'>
<User className='h-6 w-6 text-primary' />
</div>
<div className='flex-1 space-y-2'>
<div className='flex items-center justify-between'>
<h3 className='font-semibold text-foreground text-lg'>{selectedClient.name}</h3>
<Search className='size-4 text-muted-foreground' />
</div>
<div className='space-y-1'>
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Mail className='h-3 w-3' />
{selectedClient.email}
</div>
{selectedClient.phone && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Phone className='h-3 w-3' />
{selectedClient.phone}
</div>
)}
{selectedClient.company && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<Building2Icon className='h-3 w-3' />
{selectedClient.company}
</div>
)}
{selectedClient.taxId && (
<div className='flex items-center gap-2 text-sm text-muted-foreground'>
<FileTextIcon className='h-3 w-3' />
{selectedClient.taxId}
</div>
)}
</div>
</div>
</div>
) : (
<div className='flex items-center gap-4 group-hover:text-primary'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted border-2 border-dashed border-muted-foreground/30 group-hover:border-primary/60'>
<PlusIcon className='h-6 w-6 text-muted-foreground group-hover:text-primary' />
</div>
<div className='flex-1'>
<h3 className='font-medium text-muted-foreground mb-1 group-hover:text-primary'>
Seleccionar Cliente
</h3>
<p className='text-sm text-muted-foreground/70 group-hover:text-primary'>
Haz clic para buscar un cliente existente o crear uno nuevo
</p>
</div>
<SearchIcon className='size-5 text-muted-foreground group-hover:text-primary' />
</div>
)}
</div>
<Dialog open={showClientSelector} onOpenChange={setShowClientSelector}>
<DialogContent className='sm:max-w-[600px] bg-card border-border p-0'>
<DialogHeader className='px-6 pt-6 pb-4'>
<DialogTitle className='flex items-center justify-between text-foreground'>
<div className='flex items-center gap-2'>
<User className='h-5 w-5' />
Seleccionar Cliente
</div>
<Button
variant='ghost'
size='sm'
onClick={() => setShowClientSelector(false)}
className='size-6 p-0 hover:bg-accent'
>
<XIcon className='size-4' />
</Button>
</DialogTitle>
<DialogDescription className='text-muted-foreground'>
Busca un cliente existente o crea uno nuevo.
</DialogDescription>
</DialogHeader>
<div className='px-6 pb-6'>
<Command className='border border-border rounded-lg'>
<div className='flex items-center border-b border-border px-3'>
<Search className='mr-2 size-4 shrink-0 opacity-50' />
<CommandInput
placeholder='Buscar cliente por nombre, email o empresa...'
value={searchQuery}
onValueChange={setSearchQuery}
className='flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
<CommandList className='max-h-[400px]'>
<CommandEmpty className='py-6 text-center text-sm text-muted-foreground'>
<div className='flex flex-col items-center gap-2'>
<User className='h-8 w-8 text-muted-foreground/50' />
<p>No se encontraron clientes</p>
{searchQuery && (
<Button
variant='outline'
size='sm'
onClick={openNewClientDialog}
className='mt-2 bg-transparent'
>
<Plus className='mr-2 size-4' />
Crear cliente "{searchQuery}"
</Button>
)}
</div>
</CommandEmpty>
<CommandGroup>
{filteredClients.map((client) => (
<CommandItem
key={client.id}
value={client.id}
onSelect={() => handleSelectClient(client)}
className='flex items-center gap-3 p-3 cursor-pointer hover:bg-accent'
>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary/10'>
<User className='size-4 text-primary' />
</div>
<div className='flex-1 space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium text-foreground'>{client.name}</span>
{client.company && (
<Badge variant='secondary' className='text-xs'>
{client.company}
</Badge>
)}
</div>
<div className='flex items-center gap-4 text-xs text-muted-foreground'>
<div className='flex items-center gap-1'>
<Mail className='h-3 w-3' />
{client.email}
</div>
{client.phone && (
<div className='flex items-center gap-1'>
<Phone className='h-3 w-3' />
{client.phone}
</div>
)}
</div>
</div>
<Check
className={cn(
"ml-auto size-4",
selectedClient?.id === client.id ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
{filteredClients.length > 0 && (
<CommandGroup>
<CommandItem
onSelect={openNewClientDialog}
className='flex items-center gap-3 p-3 cursor-pointer hover:bg-accent border-t border-border'
>
<div className='flex h-8 w-8 items-center justify-center rounded-full bg-primary'>
<Plus className='size-4 text-primary-foreground' />
</div>
<span className='font-medium text-foreground'>Agregar nuevo cliente</span>
</CommandItem>
</CommandGroup>
)}
</CommandList>
</Command>
</div>
</DialogContent>
</Dialog>
<Dialog open={showNewClientDialog} onOpenChange={setShowNewClientDialog}>
<DialogContent className='sm:max-w-[500px] bg-card border-border'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2 text-foreground'>
<Plus className='h-5 w-5' />
Agregar Nuevo Cliente
</DialogTitle>
<DialogDescription className='text-muted-foreground'>
Complete la información del cliente. Los campos marcados con * son obligatorios.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='name' className='text-foreground'>
Nombre completo *
</Label>
<Input
id='name'
value={newClient.name}
onChange={(e) => setNewClient({ ...newClient, name: e.target.value })}
placeholder='Ej: María García'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='email' className='text-foreground'>
Email *
</Label>
<Input
id='email'
type='email'
value={newClient.email}
onChange={(e) => setNewClient({ ...newClient, email: e.target.value })}
placeholder='Ej: maria@empresa.com'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='phone' className='text-foreground'>
Teléfono
</Label>
<Input
id='phone'
value={newClient.phone}
onChange={(e) => setNewClient({ ...newClient, phone: e.target.value })}
placeholder='Ej: +34 666 123 456'
className='bg-input border-border text-foreground'
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='taxId' className='text-foreground'>
NIF/CIF
</Label>
<Input
id='taxId'
value={newClient.taxId}
onChange={(e) => setNewClient({ ...newClient, taxId: e.target.value })}
placeholder='Ej: 12345678Z'
className='bg-input border-border text-foreground'
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='company' className='text-foreground'>
Empresa
</Label>
<Input
id='company'
value={newClient.company}
onChange={(e) => setNewClient({ ...newClient, company: e.target.value })}
placeholder='Ej: Empresa ABC S.L.'
className='bg-input border-border text-foreground'
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowNewClientDialog(false)}
className='border-border text-foreground hover:bg-accent'
>
Cancelar
</Button>
<Button
onClick={handleCreateClient}
disabled={!newClient.name || !newClient.email}
className='bg-primary text-primary-foreground hover:bg-primary/90'
>
<Plus className='mr-2 size-4' />
Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

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

View File

@ -14,7 +14,7 @@ export const CustomerAdditionalConfigFields = () => {
<Fieldset>
<Legend>{t("form_groups.preferences.title")}</Legend>
<Description>{t("form_groups.preferences.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field className='lg:col-span-2'>
<SelectField
control={control}

View File

@ -20,7 +20,7 @@ export const CustomerAddressFields = () => {
<Fieldset>
<Legend>{t("form_groups.address.title")}</Legend>
<Description>{t("form_groups.address.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<TextField
className='lg:col-span-2'
control={control}

View File

@ -35,7 +35,7 @@ export const CustomerBasicInfoFields = () => {
<Fieldset>
<Legend>{t("form_groups.basic_info.title")}</Legend>
<Description>{t("form_groups.basic_info.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field className='lg:col-span-2'>
<TextField
control={control}

View File

@ -6,7 +6,12 @@ import {
Legend,
TextField,
} from "@repo/rdx-ui/components";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@repo/shadcn-ui/components";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Separator,
} from "@repo/shadcn-ui/components";
import { ChevronDown, MailIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
import { useState } from "react";
@ -22,7 +27,7 @@ export const CustomerContactFields = () => {
<Fieldset>
<Legend>{t("form_groups.contact_info.title")}</Legend>
<Description>{t("form_groups.contact_info.description")}</Description>
<FieldGroup className='grid grid-cols-1 gap-6 lg:grid-cols-4'>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<TextField
className='lg:col-span-2'
control={control}
@ -61,7 +66,9 @@ export const CustomerContactFields = () => {
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
</FieldGroup>
<Separator className='mt-6' />
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<TextField
className='lg:col-span-2 lg:col-start-1'
control={control}
@ -97,7 +104,9 @@ export const CustomerContactFields = () => {
<PhoneIcon className='h-[18px] w-[18px] text-muted-foreground' strokeWidth={1.5} />
}
/>
</FieldGroup>
<Separator className='mt-6' />
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Collapsible
open={open}
onOpenChange={setOpen}

View File

@ -1,7 +1,7 @@
import { FormDebug } from "@erp/core/components";
import { FieldErrors, useFormContext } from "react-hook-form";
import { CustomerFormData } from "../../schemas";
import { FormDebug } from "../form-debug";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
@ -24,8 +24,8 @@ export const CustomerEditForm = ({ formId, onSubmit, onError }: CustomerFormProp
</div>
<div className='w-full xl:grow space-y-6'>
<CustomerBasicInfoFields />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerContactFields />
<CustomerAdditionalConfigFields />
</div>
</div>

View File

@ -1,7 +1,7 @@
// components/CustomerSkeleton.tsx
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../i18n";
import { useTranslation } from "../../i18n";
export const CustomerEditorSkeleton = () => {
const { t } = useTranslation();

View File

@ -1 +1,2 @@
export * from "./customer-edit-form";
export * from "./customer-editor-skeleton";

View File

@ -1,8 +1,7 @@
export * from "./client-selector";
export * from "./customer-editor-skeleton";
export * from "./client-selector-modal";
export * from "./customer-modal-selector";
export * from "./customers-layout";
export * from "./customers-list-grid";
export * from "./editor";
export * from "./error-alert";
export * from "./form-debug";
export * from "./not-found-card";

View File

@ -5,7 +5,7 @@ import {
GetCustomerByIdResponseSchema,
ListCustomersResponseDTO,
UpdateCustomerByIdRequestSchema,
} from "@erp/customers";
} from "../../common";
export const CustomerCreateSchema = CreateCustomerRequestSchema;
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;

View File

@ -28,6 +28,10 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": [
"src",
"../core/src/web/components/form/form-debug.tsx",
"../customer-invoices/src/web/components/editor/invoice-basic-info-fields.tsx"
],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,38 @@
## 💻 Usage
The criteria converter expect an url with the following format:
* `filters`: An array of filters. Composed by:
- `field`: The field to filter by.
- `operator`: The operator to apply. [You can see here](https://github.com/CodelyTV/php-criteria/tree/main/packages/criteria) the valid operators list.
- `value`: The value to filter by.
* `orderBy`: The field to order by.
* `order`: The order to apply. `asc` or `desc`.
* `pageSize`: The number of items per page.
* `pageNumber`: The page number.
### Valid operators
* `=`: Equal
* `!=`: Not equal
* `>`: Greater than
* `<`: Less than
* `CONTAINS`: Contains. It will translate to `like` in SQL.
* `NOT_CONTAINS`: Not contains. It will translate to `not like` in SQL.
### Url examples
Url with one filter and no order or pagination:
```
http://localhost:3000/api/users?filters[0][field]=name&filters[0][operator]=CONTAINS&filters[0][value]=Javi
```
Url with two filter, order and pagination:
```
http://localhost:3000/api/users
?filters[0][field]=name&filters[0][operator]=CONTAINS&filters[0][value]=Javi
&filters[1][field]=email&filters[1][operator]=CONTAINS&filters[1][value]=gmail
&orderBy=name
&order=asc
&pageSize=10
&pageNumber=2
```

View File

@ -2,11 +2,11 @@ import { Filter } from "@codelytv/criteria";
import { FindOptions, Op, OrderItem, WhereOptions } from "sequelize";
import { Criteria } from "./critera";
type Mappings = { [key: string]: string };
type CriteriaMappings = { [key: string]: string };
export class CriteriaToSequelizeConverter {
//convert(fieldsToSelect: string[], criteria: Criteria, mappings: Mappings = {}): FindOptions {
convert(criteria: Criteria, mappings: Mappings = {}): FindOptions {
convert(criteria: Criteria, mappings: CriteriaMappings = {}): FindOptions {
const options: FindOptions = {};
// Selección de campos
@ -38,7 +38,7 @@ export class CriteriaToSequelizeConverter {
return options;
}
private buildWhere(filters: Filter[], mappings: Mappings): WhereOptions {
private buildWhere(filters: Filter[], mappings: CriteriaMappings): WhereOptions {
const where: WhereOptions = {};
filters.forEach((filter) => {

View File

@ -126,13 +126,13 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
aria-label={t("common.clearDate") || "Limpiar fecha"}
className='text-muted-foreground hover:text-foreground focus:outline-none'
>
<XIcon className='w-4 h-4' />
<XIcon className='size-4 hover:text-destructive' />
</button>
)}
{isReadOnly ? (
<LockIcon className='w-4 h-4 text-muted-foreground' />
<LockIcon className='size-4 text-muted-foreground' />
) : (
<CalendarIcon className='w-4 h-4 text-muted-foreground' />
<CalendarIcon className='size-4 text-muted-foreground hover:text-primary hover:cursor-pointer' />
)}
</div>
</div>

View File

@ -5,7 +5,7 @@ export const Fieldset = ({ className, children, ...props }: React.ComponentProps
<fieldset
data-slot='fieldset'
className={cn(
"*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6 bg-gray-50/50 rounded-xl p-6",
" *:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6 bg-card rounded-xl p-6",
className
)}
{...props}
@ -23,7 +23,7 @@ export const FieldGroup = ({ className, children, ...props }: React.ComponentPro
export const Field = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div
className={cn(
"[&>[data-slot=label]+[data-slot=control]]:mt-3 [&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=description]+[data-slot=control]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3 [&>[data-slot=control]+[data-slot=error]]:mt-3 *:data-[slot=label]:font-medium",
"[&>[data-slot=label]+[data-slot=control]]:mt-3 [&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=description]+[data-slot=control]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3 [&>[data-slot=control]+[data-slot=error]]:mt-3 *:data-[slot=label]:font-medium bg-transparent",
className
)}
{...props}
@ -35,7 +35,10 @@ export const Field = ({ className, children, ...props }: React.ComponentProps<"d
export const Legend = ({ className, children, ...props }: React.ComponentProps<"div">) => (
<div
data-slot='legend'
className={cn("text-base/6 font-semibold data-disabled:opacity-50 sm:text-sm/6", className)}
className={cn(
"text-base/6 font-semibold data-disabled:opacity-50 sm:text-sm/6 text-foreground",
className
)}
{...props}
>
{children}
@ -43,7 +46,11 @@ export const Legend = ({ className, children, ...props }: React.ComponentProps<"
);
export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => (
<p data-slot='text' className={cn("text-base/6 sm:text-sm/6", className)} {...props}>
<p
data-slot='text'
className={cn("text-base/6 sm:text-sm/6 text-muted-foreground", className)}
{...props}
>
{children}
</p>
);

View File

@ -12,7 +12,7 @@ import {
export const AppBreadcrumb = () => {
return (
<header className='app-breadcrumb flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12'>
<div className='flex items-center gap-2 px-4'>
<div className='flex items-center gap-2 px-6'>
<SidebarTrigger className='-ml-1' />
<Separator orientation='vertical' className='mr-2 h-4' />
<Breadcrumb>

View File

@ -1,9 +1,19 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { PropsWithChildren } from "react";
export const AppContent = ({ className, children }: PropsWithChildren<{ className?: string }>) => {
export const AppContent = ({
className,
children,
...props
}: PropsWithChildren<{ className?: string }>) => {
return (
<div className={cn("app-content flex flex-1 flex-col gap-4 p-4 pt-0", className)}>
<div
className={cn(
"app-content flex flex-1 flex-col gap-4 p-6 pt-8 bg-sidebar/25 min-h-screen",
className
)}
{...props}
>
{children}
</div>
);

View File

@ -0,0 +1,14 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { PropsWithChildren } from "react";
export const AppHeader = ({
className,
children,
...props
}: PropsWithChildren<{ className?: string }>) => {
return (
<div className={cn("app-header bg-background", className)} {...props}>
{children}
</div>
);
};

View File

@ -1,3 +1,4 @@
export * from "./app-breadcrumb.tsx";
export * from "./app-content.tsx";
export * from "./app-header.tsx";
export * from "./app-layout.tsx";