Facturas de cliente
This commit is contained in:
parent
5584b6039b
commit
2043bbb78b
@ -1 +1,2 @@
|
||||
export * from "./form-debug.tsx";
|
||||
export * from "./taxes-multi-select-field.tsx";
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()),
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer-invoice-edit-form";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./blocks-view";
|
||||
export * from "./table-view";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
5
modules/customer-invoices/src/web/components/editor/items/types.d.ts
vendored
Normal file
5
modules/customer-invoices/src/web/components/editor/items/types.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
export interface CustomItemViewProps {
|
||||
items: any;
|
||||
updateItem: (index: number, field: string, value: any) => void;
|
||||
removeItem: (index: number) => void;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
41
modules/customer-invoices/src/web/components/page-header.tsx
Normal file
41
modules/customer-invoices/src/web/components/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 /> },
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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'
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./create";
|
||||
export * from "./customer-invoices-list";
|
||||
export * from "./update";
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
*/
|
||||
|
||||
1
modules/customer-invoices/src/web/pages/update/index.ts
Normal file
1
modules/customer-invoices/src/web/pages/update/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./customer-invoices-update";
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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("");
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./customer-modal-selector";
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
@ -1 +1,2 @@
|
||||
export * from "./customer-edit-form";
|
||||
export * from "./customer-editor-skeleton";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
GetCustomerByIdResponseSchema,
|
||||
ListCustomersResponseDTO,
|
||||
UpdateCustomerByIdRequestSchema,
|
||||
} from "@erp/customers";
|
||||
} from "../../common";
|
||||
|
||||
export const CustomerCreateSchema = CreateCustomerRequestSchema;
|
||||
export const CustomerUpdateSchema = UpdateCustomerByIdRequestSchema;
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
38
packages/rdx-criteria/readme.md
Normal file
38
packages/rdx-criteria/readme.md
Normal 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
|
||||
```
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
14
packages/rdx-ui/src/components/layout/app-header.tsx
Normal file
14
packages/rdx-ui/src/components/layout/app-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./app-breadcrumb.tsx";
|
||||
export * from "./app-content.tsx";
|
||||
export * from "./app-header.tsx";
|
||||
export * from "./app-layout.tsx";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user