Facturas de cliente
This commit is contained in:
parent
cb9c8e5061
commit
acada25dfd
@ -71,9 +71,9 @@
|
|||||||
"title": "Invoice details",
|
"title": "Invoice details",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
},
|
||||||
"basic_into": {
|
"basic_info": {
|
||||||
"title": "Invoice information",
|
"title": "Invoice information",
|
||||||
"description": ""
|
"description": "Basic invoice information"
|
||||||
},
|
},
|
||||||
"totals": {
|
"totals": {
|
||||||
"title": "Invoice totals",
|
"title": "Invoice totals",
|
||||||
|
|||||||
@ -72,9 +72,9 @@
|
|||||||
"title": "Detalles de la factura",
|
"title": "Detalles de la factura",
|
||||||
"description": ""
|
"description": ""
|
||||||
},
|
},
|
||||||
"basic_into": {
|
"basic_info": {
|
||||||
"title": "Información de la factura",
|
"title": "Información de la factura",
|
||||||
"description": ""
|
"description": "Información básica de la factura"
|
||||||
},
|
},
|
||||||
"totals": {
|
"totals": {
|
||||||
"title": "Totales de la factura",
|
"title": "Totales de la factura",
|
||||||
|
|||||||
@ -25,28 +25,19 @@ export const CustomerInvoiceEditForm = ({
|
|||||||
return (
|
return (
|
||||||
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
||||||
<section className={cn("space-y-6", className)}>
|
<section className={cn("space-y-6", className)}>
|
||||||
<div className='w-full grid grid-cols-4'>
|
<div className="w-full border p-6 bg-background">
|
||||||
<div className="col-span-3">
|
<InvoiceBasicInfoFields className="flex flex-col" />
|
||||||
<InvoiceBasicInfoFields className="flex flex-col" />
|
|
||||||
</div>
|
|
||||||
<div className='col-span-1'>
|
|
||||||
<InvoiceRecipient className="flex flex-col" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto grid w-full grid-cols-1 grid-flow-col gap-6 lg:grid-cols-4 items-stretch">
|
|
||||||
<div className="lg:col-start-1 lg:col-span-3 h-full">
|
|
||||||
<InvoiceItems className="h-full flex flex-col" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="h-full ">
|
|
||||||
<InvoiceTotals />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className='w-full grid grid-cols-1 lg:grid-cols-4 gap-6'>
|
||||||
|
|
||||||
|
<InvoiceItems className="col-start-1 lg:col-span-3 border p-6 bg-background -p-6" />
|
||||||
|
<InvoiceRecipient className='lg:col-span-1 border p-6 bg-background' />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full border p-6 bg-background">
|
||||||
|
<InvoiceTotals />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -1,13 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DatePickerInputField,
|
DatePickerInputField,
|
||||||
Description,
|
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
Fieldset,
|
|
||||||
Legend,
|
|
||||||
TextField
|
TextField
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
import { FileTextIcon } from "lucide-react";
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
@ -17,86 +12,64 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { control } = useFormContext<InvoiceFormData>();
|
const { control } = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props} className='border n'>
|
<FieldSet {...props}>
|
||||||
<Legend>
|
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||||
<FileTextIcon className='size-4 stroke-2' />{t("form_groups.basic_into.title")}
|
{t("form_groups.basic_info.title")}
|
||||||
</Legend>
|
</FieldLegend>
|
||||||
|
<FieldDescription className='hidden'>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||||
|
|
||||||
<Description>{t("form_groups.basic_into.description")}</Description>
|
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
<DatePickerInputField
|
||||||
<Field>
|
className='min-w-44 flex-1 sm:max-w-44'
|
||||||
<TextField
|
control={control}
|
||||||
className='hidden'
|
name='invoice_date'
|
||||||
control={control}
|
numberOfMonths={2}
|
||||||
name='invoice_number'
|
required
|
||||||
readOnly
|
label={t("form_fields.invoice_date.label")}
|
||||||
label={t("form_fields.invoice_number.label")}
|
placeholder={t("form_fields.invoice_date.placeholder")}
|
||||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
description={t("form_fields.invoice_date.description")}
|
||||||
description={t("form_fields.invoice_number.description")}
|
/>
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<DatePickerInputField
|
|
||||||
control={control}
|
|
||||||
name='invoice_date'
|
|
||||||
numberOfMonths={2}
|
|
||||||
required
|
|
||||||
label={t("form_fields.invoice_date.label")}
|
|
||||||
placeholder={t("form_fields.invoice_date.placeholder")}
|
|
||||||
description={t("form_fields.invoice_date.description")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
<DatePickerInputField
|
||||||
<DatePickerInputField
|
className='min-w-44 flex-1 sm:max-w-44'
|
||||||
control={control}
|
control={control}
|
||||||
numberOfMonths={2}
|
numberOfMonths={2}
|
||||||
name='operation_date'
|
name='operation_date'
|
||||||
label={t("form_fields.operation_date.label")}
|
label={t("form_fields.operation_date.label")}
|
||||||
placeholder={t("form_fields.operation_date.placeholder")}
|
placeholder={t("form_fields.operation_date.placeholder")}
|
||||||
description={t("form_fields.operation_date.description")}
|
description={t("form_fields.operation_date.description")}
|
||||||
/>
|
/>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field >
|
<TextField
|
||||||
<TextField
|
className='min-w-16 flex-1 sm:max-w-16'
|
||||||
typePreset='text'
|
control={control}
|
||||||
control={control}
|
name='series'
|
||||||
name='series'
|
label={t("form_fields.series.label")}
|
||||||
label={t("form_fields.series.label")}
|
placeholder={t("form_fields.series.placeholder")}
|
||||||
placeholder={t("form_fields.series.placeholder")}
|
description={t("form_fields.series.description")}
|
||||||
description={t("form_fields.series.description")}
|
/>
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
className='min-w-32 flex-1 sm:max-w-44'
|
||||||
|
maxLength={256}
|
||||||
|
control={control}
|
||||||
|
name='reference'
|
||||||
|
label={t("form_fields.reference.label")}
|
||||||
|
placeholder={t("form_fields.reference.placeholder")}
|
||||||
|
description={t("form_fields.reference.description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
<Field>
|
className='min-w-32 flex-1 xs:max-w-full'
|
||||||
<TextField
|
maxLength={256}
|
||||||
typePreset='text'
|
control={control}
|
||||||
maxLength={256}
|
name='description'
|
||||||
control={control}
|
label={t("form_fields.description.label")}
|
||||||
name='reference'
|
placeholder={t("form_fields.description.placeholder")}
|
||||||
label={t("form_fields.reference.label")}
|
description={t("form_fields.description.description")}
|
||||||
placeholder={t("form_fields.reference.placeholder")}
|
/>
|
||||||
description={t("form_fields.reference.description")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<TextField
|
|
||||||
typePreset='text'
|
|
||||||
maxLength={256}
|
|
||||||
control={control}
|
|
||||||
name='description'
|
|
||||||
label={t("form_fields.description.label")}
|
|
||||||
placeholder={t("form_fields.description.placeholder")}
|
|
||||||
description={t("form_fields.description.description")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,25 +1,25 @@
|
|||||||
import { Rows4Icon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Description, FieldGroup, Fieldset, Legend } from '@repo/rdx-ui/components';
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { ItemsEditor } from "./items";
|
import { ItemsEditor } from "./items";
|
||||||
|
|
||||||
|
|
||||||
export const InvoiceItems = ({ className, ...props }: ComponentProps<"fieldset">) => {
|
export const InvoiceItems = (props: ComponentProps<"fieldset">) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<FieldSet {...props}>
|
||||||
<Legend>
|
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||||
<Rows4Icon className='size-6 text-muted-foreground' />{t('form_groups.items.title')}
|
{t('form_groups.items.title')}
|
||||||
</Legend>
|
</FieldLegend>
|
||||||
|
<FieldDescription className='hidden'>{t("form_groups.items.description")}</FieldDescription>
|
||||||
|
|
||||||
<Description>{t("form_groups.items.description")}</Description>
|
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
<ItemsEditor />
|
<ItemsEditor />
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Description, FieldGroup, Fieldset, Legend, TextAreaField } from "@repo/rdx-ui/components";
|
import { TextAreaField } from "@repo/rdx-ui/components";
|
||||||
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { StickyNoteIcon } from "lucide-react";
|
import { StickyNoteIcon } from "lucide-react";
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
@ -10,12 +11,12 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
|||||||
const { control } = useFormContext<InvoiceFormData>();
|
const { control } = useFormContext<InvoiceFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<FieldSet {...props}>
|
||||||
<Legend>
|
<FieldLegend>
|
||||||
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_into.title")}
|
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_info.title")}
|
||||||
</Legend>
|
</FieldLegend>
|
||||||
|
|
||||||
<Description>{t("form_groups.basic_into.description")}</Description>
|
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 h-full min-h-0'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 h-full min-h-0'>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
maxLength={1024}
|
maxLength={1024}
|
||||||
@ -27,6 +28,6 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
|||||||
description={t("form_fields.notes.description")}
|
description={t("form_fields.notes.description")}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { formatCurrency } from '@erp/core';
|
import { formatCurrency } from '@erp/core';
|
||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { Badge, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { Badge } from "@repo/shadcn-ui/components";
|
|
||||||
import { ReceiptIcon } from "lucide-react";
|
import { ReceiptIcon } from "lucide-react";
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
@ -23,45 +22,47 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
|||||||
const displayTaxes = taxes || [];
|
const displayTaxes = taxes || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<FieldGroup>
|
||||||
<Legend className='flex items-center gap-2 text-foreground'>
|
<FieldSet {...props}>
|
||||||
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
|
<FieldLegend className='flex items-center gap-2 text-foreground'>
|
||||||
</Legend>
|
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
|
||||||
|
</FieldLegend>
|
||||||
|
|
||||||
<Description>{t("form_groups.tax_resume.description")}</Description>
|
<FieldDescription>{t("form_groups.tax_resume.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{displayTaxes.map((tax, index) => (
|
{displayTaxes.map((tax, index) => (
|
||||||
|
|
||||||
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2 text-base '>
|
<div key={`${tax.tax_code}-${index}`} className='border rounded-lg p-3 space-y-2 text-base '>
|
||||||
<div className='flex items-center justify-between mb-2 '>
|
<div className='flex items-center justify-between mb-2 '>
|
||||||
<Badge variant='secondary' className='text-sm font-semibold'>
|
<Badge variant='secondary' className='text-sm font-semibold'>
|
||||||
{tax.tax_label}
|
{tax.tax_label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div className='space-y-2 text-sm'>
|
|
||||||
<div className='flex justify-between'>
|
|
||||||
<span className='text-current'>Base para el impuesto:</span>
|
|
||||||
<span className='text-base text-current tabular-nums'>{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between'>
|
<div className='space-y-2 text-sm'>
|
||||||
<span className='text-current font-semibold'>Importe de impuesto:</span>
|
<div className='flex justify-between'>
|
||||||
<span className='text-base text-current font-semibold tabular-nums'>
|
<span className='text-current'>Base para el impuesto:</span>
|
||||||
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
|
<span className='text-base text-current tabular-nums'>{formatCurrency(tax.taxable_amount, 2, currency_code, language_code)}</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-current font-semibold'>Importe de impuesto:</span>
|
||||||
|
<span className='text-base text-current font-semibold tabular-nums'>
|
||||||
|
{formatCurrency(tax.taxes_amount, 2, currency_code, language_code)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
|
|
||||||
{displayTaxes.length === 0 && (
|
{displayTaxes.length === 0 && (
|
||||||
<div className='text-center py-6 text-muted-foreground'>
|
<div className='text-center py-6 text-muted-foreground'>
|
||||||
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
|
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
|
||||||
<p className='text-sm'>No hay impuestos aplicados</p>
|
<p className='text-sm'>No hay impuestos aplicados</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
|
</FieldGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { formatCurrency } from "@erp/core";
|
import { formatCurrency } from "@erp/core";
|
||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator } from '@repo/shadcn-ui/components';
|
||||||
import { Separator } from "@repo/shadcn-ui/components";
|
|
||||||
import { ReceiptIcon } from "lucide-react";
|
import { ReceiptIcon } from "lucide-react";
|
||||||
import { ComponentProps } from "react";
|
import { ComponentProps } from "react";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { useFormContext, useWatch } from "react-hook-form";
|
||||||
@ -22,12 +21,12 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<FieldSet {...props}>
|
||||||
<Legend>
|
<FieldLegend>
|
||||||
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
|
<ReceiptIcon className='size-6 text-muted-foreground' />{t("form_groups.totals.title")}
|
||||||
</Legend>
|
</FieldLegend>
|
||||||
|
|
||||||
<Description>{t("form_groups.totals.description")}</Description>
|
<FieldDescription>{t("form_groups.totals.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1'>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
{/* Sección: Subtotal y Descuentos */}
|
{/* Sección: Subtotal y Descuentos */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@ -145,6 +144,6 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset >
|
</FieldSet >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
import { UserIcon } from "lucide-react";
|
|
||||||
import { ComponentProps } from 'react';
|
import { ComponentProps } from 'react';
|
||||||
import { useTranslation } from "../../../i18n";
|
import { useTranslation } from "../../../i18n";
|
||||||
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
|
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
|
||||||
@ -14,18 +13,18 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
|||||||
const recipient = getValues('recipient');
|
const recipient = getValues('recipient');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset {...props}>
|
<FieldSet {...props}>
|
||||||
<Legend className='flex items-center gap-2 text-foreground'>
|
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||||
<UserIcon className='size-5' /> {t("form_groups.customer.title")}
|
{t('form_groups.recipient.title')}
|
||||||
</Legend>
|
</FieldLegend>
|
||||||
<Description>{t("form_groups.customer.description")}</Description>
|
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
|
||||||
<FieldGroup>
|
<FieldGroup className='grid grid-cols-1'>
|
||||||
<RecipientModalSelectorField
|
<RecipientModalSelectorField
|
||||||
control={control}
|
control={control}
|
||||||
name='customer_id'
|
name='customer_id'
|
||||||
initialRecipient={recipient}
|
initialRecipient={recipient}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function PageHeader({ icon, title, description, status, rightSlot, classN
|
|||||||
{icon && <div className='shrink-0'>{icon}</div>}
|
{icon && <div className='shrink-0'>{icon}</div>}
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<h1 className='text-xl font-semibold text-foreground'>{title}</h1>
|
<h1 className='text-lg font-semibold text-foreground'>{title}</h1>
|
||||||
{status && <CustomerInvoiceStatusBadge status={status} />}
|
{status && <CustomerInvoiceStatusBadge status={status} />}
|
||||||
</div>
|
</div>
|
||||||
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import {
|
|||||||
UnsavedChangesProvider,
|
UnsavedChangesProvider,
|
||||||
useHookForm
|
useHookForm
|
||||||
} from "@erp/core/hooks";
|
} from "@erp/core/hooks";
|
||||||
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
|
import { AppBreadcrumb, AppContent, AppHeader } from "@repo/rdx-ui/components";
|
||||||
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
|
||||||
import { FilePenIcon } from "lucide-react";
|
import { FilePenIcon } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
@ -86,9 +86,10 @@ export const InvoiceUpdateComp = ({
|
|||||||
return (
|
return (
|
||||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||||
<AppHeader>
|
<AppHeader>
|
||||||
|
<AppBreadcrumb />
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
|
title={`${t("pages.edit.title")} ${invoiceData.invoice_number}`}
|
||||||
icon={<FilePenIcon className='size-12 text-primary' aria-hidden />}
|
icon={<FilePenIcon className='size-6 text-primary' aria-hidden />}
|
||||||
rightSlot={
|
rightSlot={
|
||||||
<FormCommitButtonGroup
|
<FormCommitButtonGroup
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Description, Field, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
import { Field, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
|
|
||||||
import { SelectField } from "@repo/rdx-ui/components";
|
import { SelectField } from "@repo/rdx-ui/components";
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
@ -11,9 +11,9 @@ export const CustomerAdditionalConfigFields = () => {
|
|||||||
const { control } = useFormContext<CustomerFormData>();
|
const { control } = useFormContext<CustomerFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset>
|
<FieldSet>
|
||||||
<Legend>{t("form_groups.preferences.title")}</Legend>
|
<FieldLegend>{t("form_groups.preferences.title")}</FieldLegend>
|
||||||
<Description>{t("form_groups.preferences.description")}</Description>
|
<FieldDescription>{t("form_groups.preferences.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||||
<Field className='lg:col-span-2'>
|
<Field className='lg:col-span-2'>
|
||||||
<SelectField
|
<SelectField
|
||||||
@ -39,6 +39,6 @@ export const CustomerAdditionalConfigFields = () => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Description,
|
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
Fieldset,
|
|
||||||
Legend,
|
|
||||||
SelectField,
|
SelectField,
|
||||||
TextField,
|
TextField
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||||
import { useFormContext } from "react-hook-form";
|
import { useFormContext } from "react-hook-form";
|
||||||
import { COUNTRY_OPTIONS } from "../../constants";
|
import { COUNTRY_OPTIONS } from "../../constants";
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
@ -17,9 +13,9 @@ export const CustomerAddressFields = () => {
|
|||||||
const { control } = useFormContext<CustomerFormData>();
|
const { control } = useFormContext<CustomerFormData>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset>
|
<FieldSet>
|
||||||
<Legend>{t("form_groups.address.title")}</Legend>
|
<FieldLegend>{t("form_groups.address.title")}</FieldLegend>
|
||||||
<Description>{t("form_groups.address.description")}</Description>
|
<FieldDescription>{t("form_groups.address.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||||
<TextField
|
<TextField
|
||||||
className='lg:col-span-2'
|
className='lg:col-span-2'
|
||||||
@ -75,6 +71,6 @@ export const CustomerAddressFields = () => {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
Description,
|
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
Fieldset,
|
|
||||||
Legend,
|
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextField,
|
TextField
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
Field, FieldDescription, FieldGroup, FieldLegend, FieldSet, FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
RadioGroupItem,
|
RadioGroupItem
|
||||||
} from "@repo/shadcn-ui/components";
|
} from '@repo/shadcn-ui/components';
|
||||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
|
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
|
||||||
import { useTranslation } from "../../i18n";
|
import { useTranslation } from "../../i18n";
|
||||||
@ -32,9 +27,9 @@ export const CustomerBasicInfoFields = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset>
|
<FieldSet>
|
||||||
<Legend>{t("form_groups.basic_info.title")}</Legend>
|
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
||||||
<Description>{t("form_groups.basic_info.description")}</Description>
|
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||||
<Field className='lg:col-span-2'>
|
<Field className='lg:col-span-2'>
|
||||||
<TextField
|
<TextField
|
||||||
@ -131,6 +126,6 @@ export const CustomerBasicInfoFields = () => {
|
|||||||
description={t("form_fields.legal_record.description")}
|
description={t("form_fields.legal_record.description")}
|
||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,17 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Description,
|
TextField
|
||||||
Field,
|
|
||||||
FieldGroup,
|
|
||||||
Fieldset,
|
|
||||||
Legend,
|
|
||||||
TextField,
|
|
||||||
} from "@repo/rdx-ui/components";
|
} from "@repo/rdx-ui/components";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger, Field, FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator
|
||||||
Separator,
|
} from '@repo/shadcn-ui/components';
|
||||||
} from "@repo/shadcn-ui/components";
|
|
||||||
|
|
||||||
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@ -24,9 +18,9 @@ export const CustomerContactFields = () => {
|
|||||||
const { control } = useFormContext();
|
const { control } = useFormContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset>
|
<FieldSet>
|
||||||
<Legend>{t("form_groups.contact_info.title")}</Legend>
|
<FieldLegend>{t("form_groups.contact_info.title")}</FieldLegend>
|
||||||
<Description>{t("form_groups.contact_info.description")}</Description>
|
<FieldDescription>{t("form_groups.contact_info.description")}</FieldDescription>
|
||||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||||
<TextField
|
<TextField
|
||||||
className='lg:col-span-2'
|
className='lg:col-span-2'
|
||||||
@ -153,6 +147,6 @@ export const CustomerContactFields = () => {
|
|||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</Fieldset>
|
</FieldSet>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,116 +1,77 @@
|
|||||||
// DatePickerField.tsx
|
// DatePickerField.tsx
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
Field,
|
||||||
FormDescription,
|
FieldDescription,
|
||||||
FormField,
|
FieldError,
|
||||||
FormItem,
|
FieldLabel,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||||
import { Control, FieldPath, FieldValues, useController } from "react-hook-form";
|
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form";
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
|
||||||
import { CommonInputProps } from "./types.js";
|
import { CommonInputProps } from "./types.js";
|
||||||
|
|
||||||
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
description?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
readOnly?: boolean;
|
|
||||||
className?: string;
|
|
||||||
|
|
||||||
/** Contador de caracteres (si usas maxLength) */
|
label?: string;
|
||||||
showCounter?: boolean;
|
description?: string;
|
||||||
maxLength?: number;
|
|
||||||
rows?: number;
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
|
|
||||||
|
inputClassName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TextAreaField<TFormValues extends FieldValues>({
|
export function TextAreaField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
|
||||||
description,
|
description,
|
||||||
disabled = false,
|
|
||||||
required = false,
|
required = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
|
||||||
|
orientation = 'vertical',
|
||||||
|
|
||||||
className,
|
className,
|
||||||
showCounter = false,
|
inputClassName,
|
||||||
maxLength,
|
|
||||||
rows = 3
|
...inputRest
|
||||||
}: TextAreaFieldProps<TFormValues>) {
|
}: TextAreaFieldProps<TFormValues>) {
|
||||||
const { t } = useTranslation();
|
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||||
const isDisabled = disabled || readOnly;
|
const disabled = isSubmitting || inputRest.disabled;
|
||||||
const { field, fieldState } = useController({ control, name });
|
|
||||||
|
|
||||||
const describedById = description ? `${name}-desc` : undefined;
|
|
||||||
const errorId = fieldState.error ? `${name}-err` : undefined;
|
|
||||||
|
|
||||||
const valueLength = (field.value?.length ?? 0) as number;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => {
|
||||||
<FormItem className={cn("space-y-0 flex flex-col ", className)}>
|
return (
|
||||||
{label && (
|
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
||||||
<div className='mb-1 flex justify-between gap-2'>
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<FormLabel
|
|
||||||
htmlFor={name}
|
|
||||||
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</FormLabel>
|
|
||||||
{required && (
|
|
||||||
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Punto “unsaved” */}
|
|
||||||
{fieldState.isDirty && (
|
|
||||||
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
disabled={isDisabled}
|
ref={field.ref}
|
||||||
placeholder={placeholder}
|
id={name}
|
||||||
className={"placeholder:font-normal placeholder:italic bg-background flex flex-1 min-h-0 h-full"}
|
value={field.value ?? ""}
|
||||||
maxLength={maxLength}
|
onChange={field.onChange}
|
||||||
spellCheck={true}
|
onBlur={field.onBlur}
|
||||||
rows={rows}
|
aria-invalid={fieldState.invalid}
|
||||||
|
aria-busy={isValidating}
|
||||||
{...field}
|
{...inputRest}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
className={cn(inputClassName)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className='mt-1 flex items-start justify-between'>
|
|
||||||
<FormDescription
|
|
||||||
id={describedById}
|
|
||||||
className={cn("text-xs truncate", !description && "invisible")}
|
|
||||||
>
|
|
||||||
{description || "\u00A0"}
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
{showCounter && typeof maxLength === "number" && (
|
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||||
<p className='text-xs text-muted-foreground'>
|
<FieldError errors={[fieldState.error]} />
|
||||||
{valueLength} / {maxLength}
|
</Field>
|
||||||
</p>
|
);
|
||||||
)}
|
}}
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage id={errorId} />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,471 +1,83 @@
|
|||||||
import {
|
import {
|
||||||
FormControl,
|
Field,
|
||||||
FormDescription,
|
FieldDescription,
|
||||||
FormField,
|
FieldError,
|
||||||
FormItem,
|
FieldLabel,
|
||||||
FormLabel,
|
Input
|
||||||
FormMessage,
|
|
||||||
Input,
|
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form";
|
||||||
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form";
|
|
||||||
import { useTranslation } from "../../locales/i18n.ts";
|
|
||||||
import { CommonInputProps } from "./types.js";
|
import { CommonInputProps } from "./types.js";
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* // Email con normalización y contador
|
|
||||||
* <TextField
|
|
||||||
* name="email"
|
|
||||||
* label="Email"
|
|
||||||
* description="Usa tu email de trabajo"
|
|
||||||
* typePreset="email"
|
|
||||||
* placeholder="tú@empresa.com"
|
|
||||||
* icon={<Mail className="h-4 w-4" />}
|
|
||||||
* iconPosition="left"
|
|
||||||
* maxLength={120}
|
|
||||||
* showCounter
|
|
||||||
* clearable
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // Teléfono con normalización (mantiene + y dígitos), prefix clicable
|
|
||||||
* <TextField
|
|
||||||
* name="mobile"
|
|
||||||
* label="Móvil"
|
|
||||||
* description="Incluye prefijo internacional"
|
|
||||||
* typePreset="phone"
|
|
||||||
* placeholder="+34 600 000 000"
|
|
||||||
* prefix={<span className="text-xs">+34</span>}
|
|
||||||
* onPrefixClick={() => {
|
|
||||||
* // Alternar prefijo, o abrir selector de país...
|
|
||||||
* }}
|
|
||||||
* clearable
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // Número decimal con normalización (',' → '.'), suffix clicable para unidad
|
|
||||||
* <TextField
|
|
||||||
* name="price"
|
|
||||||
* label="Precio"
|
|
||||||
* description="Con IVA"
|
|
||||||
* typePreset="number"
|
|
||||||
* placeholder="0.00"
|
|
||||||
* suffix={<span className="text-xs">EUR</span>}
|
|
||||||
* onSuffixClick={() => {
|
|
||||||
* // Cambiar moneda, etc.
|
|
||||||
* }}
|
|
||||||
* inputMode="decimal" // si quieres sobreescribir
|
|
||||||
* icon={<Hash className="h-4 w-4" />}
|
|
||||||
* iconPosition="left"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // Password (sin normalizaciones)
|
|
||||||
* <TextField
|
|
||||||
* name="password"
|
|
||||||
* label="Contraseña"
|
|
||||||
* typePreset="password"
|
|
||||||
* placeholder="••••••••"
|
|
||||||
* autoComplete="new-password"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Presets de comportamiento */
|
|
||||||
type TextFieldTypePreset = "text" | "email" | "phone" | "number" | "password";
|
|
||||||
|
|
||||||
type Normalizer = (value: string) => string;
|
type Normalizer = (value: string) => string;
|
||||||
|
|
||||||
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
type TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
|
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
|
||||||
readOnly?: boolean;
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
className?: string;
|
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
|
|
||||||
typePreset?: TextFieldTypePreset;
|
|
||||||
|
|
||||||
icon?: React.ReactNode; // Icono con tamaño: <MailIcon className="h-[18px] w-[18px]" />
|
|
||||||
iconPosition?: "left" | "right"; // 'left' por defecto
|
|
||||||
|
|
||||||
/** Addons laterales (pueden ser clicables, a diferencia de `icon`) */
|
|
||||||
prefix?: React.ReactNode;
|
|
||||||
suffix?: React.ReactNode;
|
|
||||||
onPrefixClick?: () => void;
|
|
||||||
onSuffixClick?: () => void;
|
|
||||||
|
|
||||||
/** UX extra */
|
|
||||||
clearable?: boolean;
|
|
||||||
submitOnEnter?: boolean;
|
|
||||||
disabledWhileSubmitting?: boolean;
|
|
||||||
showSuccessWhenValid?: boolean;
|
|
||||||
showValidatingSpinner?: boolean;
|
|
||||||
|
|
||||||
/** Transformaciones */
|
|
||||||
transformOnBlur?: (value: string) => string;
|
|
||||||
normalizeOnChange?: (value: string) => string;
|
|
||||||
|
|
||||||
/** Contador de caracteres (si usas maxLength) */
|
|
||||||
showCounter?: boolean;
|
|
||||||
|
|
||||||
/** Forzar type/inputMode/autocomplete si no quieres los del preset */
|
|
||||||
forceType?: React.HTMLInputTypeAttribute;
|
|
||||||
forceInputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
|
|
||||||
forceAutoComplete?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Helpers de presets ---------- */
|
|
||||||
|
|
||||||
function presetInputType(p: TextFieldTypePreset): React.HTMLInputTypeAttribute {
|
|
||||||
switch (p) {
|
|
||||||
case "password":
|
|
||||||
return "password";
|
|
||||||
case "number":
|
|
||||||
return "text"; // usamos text + normalización para control fino (decimales, signos)
|
|
||||||
case "email":
|
|
||||||
return "email";
|
|
||||||
case "phone":
|
|
||||||
return "tel";
|
|
||||||
case "text":
|
|
||||||
return "text";
|
|
||||||
default:
|
|
||||||
return "text";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetInputMode(
|
|
||||||
p: TextFieldTypePreset
|
|
||||||
): React.HTMLAttributes<HTMLInputElement>["inputMode"] {
|
|
||||||
switch (p) {
|
|
||||||
case "phone":
|
|
||||||
return "tel";
|
|
||||||
case "number":
|
|
||||||
return "decimal";
|
|
||||||
case "email":
|
|
||||||
return "email";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetAutoComplete(p: TextFieldTypePreset): string | undefined {
|
|
||||||
switch (p) {
|
|
||||||
case "email":
|
|
||||||
return "email";
|
|
||||||
case "phone":
|
|
||||||
return "tel";
|
|
||||||
case "password":
|
|
||||||
return "current-password";
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetTransformOnBlur(p: TextFieldTypePreset): ((v: string) => string) | undefined {
|
|
||||||
switch (p) {
|
|
||||||
case "email":
|
|
||||||
return (v) => v.trim().toLowerCase();
|
|
||||||
case "text":
|
|
||||||
return undefined;
|
|
||||||
case "password":
|
|
||||||
return undefined;
|
|
||||||
case "phone":
|
|
||||||
return undefined;
|
|
||||||
case "number":
|
|
||||||
return undefined;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalizador “suave” para números: permite signo inicial y un solo separador decimal '.' */
|
|
||||||
function normalizeNumber(value: string): string {
|
|
||||||
// Sustituye comas por punto y elimina caracteres inválidos
|
|
||||||
let v = value.replace(/,/g, ".");
|
|
||||||
// Mantén solo dígitos, un punto y signo inicial
|
|
||||||
v = v
|
|
||||||
.replace(/[^\d.+-]/g, "")
|
|
||||||
.replace(/(?!^)-/g, "") // solo un signo al inicio
|
|
||||||
.replace(/(\..*)\./g, "$1"); // solo un punto
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalizador para teléfonos: mantiene dígitos y '+' inicial */
|
|
||||||
function normalizePhone(value: string): string {
|
|
||||||
let v = value.replace(/[^\d+]/g, "");
|
|
||||||
v = v.replace(/(?!^)\+/g, ""); // '+' solo al inicio
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function presetNormalizeOnChange(p: TextFieldTypePreset): Normalizer | undefined {
|
|
||||||
switch (p) {
|
|
||||||
case "phone":
|
|
||||||
return normalizePhone;
|
|
||||||
case "number":
|
|
||||||
return normalizeNumber;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Componente ---------- */
|
|
||||||
|
|
||||||
export function TextField<TFormValues extends FieldValues>({
|
export function TextField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
required,
|
required = false,
|
||||||
readOnly,
|
readOnly = false,
|
||||||
|
|
||||||
|
orientation = 'vertical',
|
||||||
|
|
||||||
className,
|
className,
|
||||||
inputClassName,
|
inputClassName,
|
||||||
typePreset = "text",
|
|
||||||
|
|
||||||
icon,
|
...inputRest
|
||||||
iconPosition = "left",
|
|
||||||
|
|
||||||
prefix,
|
|
||||||
suffix,
|
|
||||||
onPrefixClick,
|
|
||||||
onSuffixClick,
|
|
||||||
|
|
||||||
clearable = false,
|
|
||||||
submitOnEnter = false,
|
|
||||||
disabledWhileSubmitting = true,
|
|
||||||
showSuccessWhenValid = false,
|
|
||||||
showValidatingSpinner = true,
|
|
||||||
|
|
||||||
transformOnBlur,
|
|
||||||
normalizeOnChange,
|
|
||||||
|
|
||||||
showCounter = false,
|
|
||||||
|
|
||||||
forceType,
|
|
||||||
forceInputMode,
|
|
||||||
forceAutoComplete,
|
|
||||||
|
|
||||||
maxLength,
|
|
||||||
...rest
|
|
||||||
}: TextFieldProps<TFormValues>) {
|
}: TextFieldProps<TFormValues>) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||||
const { field, fieldState } = useController({ control, name });
|
const disabled = isSubmitting || inputRest.disabled;
|
||||||
|
|
||||||
// Presets → defaults (permiten override por props explícitas)
|
|
||||||
const effectiveType = forceType ?? presetInputType(typePreset);
|
|
||||||
const effectiveInputMode = forceInputMode ?? presetInputMode(typePreset);
|
|
||||||
const effectiveAutoComplete = forceAutoComplete ?? presetAutoComplete(typePreset);
|
|
||||||
const effectiveTransformOnBlur = transformOnBlur ?? presetTransformOnBlur(typePreset);
|
|
||||||
const effectiveNormalizeOnChange = normalizeOnChange ?? presetNormalizeOnChange(typePreset);
|
|
||||||
|
|
||||||
const hasIcon = Boolean(icon);
|
|
||||||
const isLeftIcon = iconPosition === "left";
|
|
||||||
const hasPrefix = prefix != null;
|
|
||||||
const hasSuffix = suffix != null;
|
|
||||||
|
|
||||||
// padding a partir de adornos
|
|
||||||
const inputPadding = cn(
|
|
||||||
hasIcon && isLeftIcon && "pl-10",
|
|
||||||
hasIcon && !isLeftIcon && "pr-10",
|
|
||||||
hasPrefix && "pl-10",
|
|
||||||
hasSuffix && "pr-10"
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalid = fieldState.invalid && (fieldState.isTouched || fieldState.isDirty);
|
|
||||||
const valid =
|
|
||||||
!fieldState.invalid &&
|
|
||||||
(fieldState.isTouched || fieldState.isDirty) &&
|
|
||||||
field.value != null &&
|
|
||||||
String(field.value).length > 0;
|
|
||||||
|
|
||||||
const disabled = (disabledWhileSubmitting && isSubmitting) || rest.disabled || readOnly;
|
|
||||||
|
|
||||||
const describedById = description ? `${name}-desc` : undefined;
|
|
||||||
const errorId = fieldState.error ? `${name}-err` : undefined;
|
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const raw = e.target.value;
|
|
||||||
const next = effectiveNormalizeOnChange ? effectiveNormalizeOnChange(raw) : raw;
|
|
||||||
field.onChange(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur(e: React.FocusEvent<HTMLInputElement>) {
|
|
||||||
if (effectiveTransformOnBlur) {
|
|
||||||
const next = effectiveTransformOnBlur(e.target.value ?? "");
|
|
||||||
if (next !== e.target.value) {
|
|
||||||
field.onChange(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
field.onBlur();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
if (submitOnEnter && e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
const form = (e.currentTarget as HTMLInputElement).form;
|
const form = (e.currentTarget as HTMLInputElement).form;
|
||||||
if (form) form.requestSubmit();
|
if (form) form.requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
field.onChange("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueLength = (field.value?.length ?? 0) as number;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => (
|
render={({ field, fieldState }) => {
|
||||||
<FormItem className={cn("space-y-0", className)}>
|
return (
|
||||||
{label && (
|
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
||||||
<div className='mb-1 flex justify-between gap-2'>
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<FormLabel
|
|
||||||
htmlFor={name}
|
|
||||||
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</FormLabel>
|
|
||||||
{required && (
|
|
||||||
<span className='text-xs text-destructive'>{t("common.required")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Punto “unsaved” */}
|
|
||||||
{fieldState.isDirty && (
|
|
||||||
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormControl>
|
<Input
|
||||||
<div className={cn("relative")}>
|
ref={field.ref}
|
||||||
{/* Prefix clicable (si tiene onClick) */}
|
id={name}
|
||||||
{hasPrefix && (
|
value={field.value ?? ""}
|
||||||
<button
|
onChange={field.onChange}
|
||||||
type={onPrefixClick ? "button" : undefined}
|
onBlur={field.onBlur}
|
||||||
onClick={onPrefixClick}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={onPrefixClick ? 0 : -1}
|
aria-invalid={fieldState.invalid}
|
||||||
className={cn(
|
aria-busy={isValidating}
|
||||||
"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
{...inputRest}
|
||||||
!onPrefixClick && "pointer-events-none"
|
disabled={disabled}
|
||||||
)}
|
aria-disabled={disabled}
|
||||||
aria-label='prefix'
|
className={cn(inputClassName)}
|
||||||
>
|
/>
|
||||||
{prefix}
|
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||||
</button>
|
<FieldError errors={[fieldState.error]} />
|
||||||
)}
|
</Field>
|
||||||
|
);
|
||||||
{/* Icono decorativo */}
|
}}
|
||||||
{hasIcon && (
|
|
||||||
<span
|
|
||||||
aria-hidden='true'
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none absolute top-1/2 -translate-y-1/2",
|
|
||||||
isLeftIcon ? "left-3" : "right-3"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id={name}
|
|
||||||
type={effectiveType}
|
|
||||||
inputMode={effectiveInputMode}
|
|
||||||
autoComplete={effectiveAutoComplete}
|
|
||||||
placeholder={rest.placeholder}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={handleChange}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
ref={field.ref}
|
|
||||||
disabled={disabled}
|
|
||||||
readOnly={readOnly}
|
|
||||||
aria-invalid={invalid || undefined}
|
|
||||||
aria-describedby={cn(describedById, errorId)}
|
|
||||||
aria-errormessage={errorId}
|
|
||||||
aria-busy={(showValidatingSpinner && isValidating) || undefined}
|
|
||||||
maxLength={maxLength}
|
|
||||||
{...rest}
|
|
||||||
className={cn(
|
|
||||||
"placeholder:font-normal placeholder:italic bg-background",
|
|
||||||
inputPadding,
|
|
||||||
invalid && "border-destructive focus-visible:ring-destructive",
|
|
||||||
valid && showSuccessWhenValid && "border-green-500 focus-visible:ring-green-500",
|
|
||||||
// Si hay suffix interactivo y spinner/check, reserva padding derecho
|
|
||||||
(hasSuffix || showValidatingSpinner || valid) && "pr-10",
|
|
||||||
inputClassName
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Suffix clicable */}
|
|
||||||
{hasSuffix && (
|
|
||||||
<button
|
|
||||||
type={onSuffixClick ? "button" : undefined}
|
|
||||||
onClick={onSuffixClick}
|
|
||||||
tabIndex={onSuffixClick ? 0 : -1}
|
|
||||||
className={cn(
|
|
||||||
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
|
||||||
!onSuffixClick && "pointer-events-none"
|
|
||||||
)}
|
|
||||||
aria-label='suffix'
|
|
||||||
>
|
|
||||||
{suffix}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Spinner de validación */}
|
|
||||||
{showValidatingSpinner && isValidating && (
|
|
||||||
<span className='absolute right-2 top-1/2 -translate-y-1/2'>
|
|
||||||
<Loader2Icon className='h-4 w-4 animate-spin' />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Check de válido */}
|
|
||||||
{showSuccessWhenValid && valid && !isValidating && !invalid && (
|
|
||||||
<span className='absolute right-2 top-1/2 -translate-y-1/2 text-green-600'>
|
|
||||||
<CheckIcon className='h-4 w-4' />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Botón clear */}
|
|
||||||
{clearable && !disabled && (field.value ?? "") !== "" && (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
aria-label='Borrar'
|
|
||||||
onClick={handleClear}
|
|
||||||
className='absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 hover:bg-muted'
|
|
||||||
>
|
|
||||||
<XIcon className='h-4 w-4' />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className='mt-1 flex items-start justify-between'>
|
|
||||||
<FormDescription
|
|
||||||
id={describedById}
|
|
||||||
className={cn("text-xs truncate", !description && "invisible")}
|
|
||||||
>
|
|
||||||
{description || "\u00A0"}
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
{showCounter && typeof maxLength === "number" && (
|
|
||||||
<p className='text-xs text-muted-foreground'>
|
|
||||||
{valueLength} / {maxLength}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage id={errorId} />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,51 +8,59 @@ import {
|
|||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
Input,
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger
|
||||||
} from "@repo/shadcn-ui/components";
|
} from "@repo/shadcn-ui/components";
|
||||||
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||||
import { format, isValid, parse } from "date-fns";
|
import { format, isValid, parse } from "date-fns";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FieldValues } from "react-hook-form";
|
import { ControllerFieldState, ControllerRenderProps, FieldValues, Path, UseFormStateReturn } from "react-hook-form";
|
||||||
import { useTranslation } from "../../../locales/i18n.ts";
|
import { useTranslation } from "../../../locales/i18n.ts";
|
||||||
|
|
||||||
import { ControllerFieldState, ControllerRenderProps, UseFormStateReturn } from "react-hook-form";
|
|
||||||
|
|
||||||
export type SUICalendarProps = Omit<React.ComponentProps<
|
export type SUICalendarProps = Omit<React.ComponentProps<
|
||||||
typeof Calendar>, "select" | "onSelect">
|
typeof Calendar>, "select" | "onSelect">
|
||||||
|
|
||||||
type DatePickerInputCompProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = SUICalendarProps & {
|
||||||
field: ControllerRenderProps<TFormValues>;
|
field: ControllerRenderProps<TFormValues, Path<TFormValues>>;
|
||||||
fieldState: ControllerFieldState;
|
fieldState: ControllerFieldState;
|
||||||
formState: UseFormStateReturn<TFormValues>;
|
formState: UseFormStateReturn<TFormValues>;
|
||||||
|
|
||||||
|
htmlFor: string,
|
||||||
|
|
||||||
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
||||||
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
||||||
|
|
||||||
label: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
|
|
||||||
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DatePickerInputComp<TFormValues extends FieldValues>({
|
export function DatePickerInputComp<TFormValues>({
|
||||||
field,
|
field,
|
||||||
fieldState,
|
fieldState,
|
||||||
formState,
|
formState,
|
||||||
|
|
||||||
|
htmlFor,
|
||||||
|
|
||||||
parseDateFormat,
|
parseDateFormat,
|
||||||
displayDateFormat,
|
displayDateFormat,
|
||||||
|
|
||||||
@ -62,6 +70,10 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
invalid = false,
|
||||||
|
|
||||||
|
orientation = "vertical",
|
||||||
|
|
||||||
className,
|
className,
|
||||||
...calendarProps
|
...calendarProps
|
||||||
}: DatePickerInputCompProps<TFormValues>) {
|
}: DatePickerInputCompProps<TFormValues>) {
|
||||||
@ -69,9 +81,6 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
|||||||
const isDisabled = disabled;
|
const isDisabled = disabled;
|
||||||
const isReadOnly = readOnly && !disabled;
|
const isReadOnly = readOnly && !disabled;
|
||||||
|
|
||||||
const describedById = description ? `${field.name}-desc` : undefined;
|
|
||||||
const errorId = fieldState.error ? `${field.name}-err` : undefined;
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false); // Popover
|
const [open, setOpen] = useState(false); // Popover
|
||||||
const [displayValue, setDisplayValue] = useState<string>("");
|
const [displayValue, setDisplayValue] = useState<string>("");
|
||||||
|
|
||||||
@ -120,30 +129,14 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className={cn("space-y-0", className)}>
|
<Field data-invalid={invalid} orientation={orientation} className={className}>
|
||||||
{label && (
|
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={htmlFor}>{label}</FieldLabel>}
|
||||||
<div className='mb-1 flex justify-between gap-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<FormLabel
|
|
||||||
htmlFor={field.name}
|
|
||||||
className={cn("m-0", disabled ? "text-muted-foreground" : "")}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</FormLabel>
|
|
||||||
{required && <span className='text-xs text-destructive'>{t("common.required")}</span>}
|
|
||||||
</div>
|
|
||||||
{/* Punto “unsaved” */}
|
|
||||||
{fieldState.isDirty && (
|
|
||||||
<span className='text-[10px] text-muted-foreground'>{t("common.modified")}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<input
|
<Input
|
||||||
type='text'
|
type='text'
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
||||||
@ -250,16 +243,9 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='mt-1 flex items-start justify-between'>
|
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||||
<FormDescription
|
|
||||||
id={describedById}
|
|
||||||
className={cn("text-xs truncate", !description && "invisible")}
|
|
||||||
>
|
|
||||||
{description || "\u00A0"}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage id={errorId} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
</FormItem>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,33 @@
|
|||||||
import { FormField } from "@repo/shadcn-ui/components";
|
|
||||||
|
|
||||||
import { Control, FieldPath, FieldValues } from "react-hook-form";
|
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form";
|
||||||
import { DatePickerInputComp, SUICalendarProps } from "./date-picker-input-comp.tsx";
|
import { DatePickerInputComp, SUICalendarProps } from "./date-picker-input-comp.tsx";
|
||||||
|
|
||||||
type DatePickerInputFieldProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
type DatePickerInputFieldProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
||||||
control: Control<TFormValues>;
|
control: Control<TFormValues>;
|
||||||
name: FieldPath<TFormValues>;
|
name: FieldPath<TFormValues>;
|
||||||
label: string;
|
|
||||||
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
|
||||||
displayDateFormat?: string; // e.g. "dd/MM/yyyy"
|
displayDateFormat?: string; // e.g. "dd/MM/yyyy"
|
||||||
parseDateFormat?: string; // e.g. "yyyy-MM-dd"
|
parseDateFormat?: string; // e.g. "yyyy-MM-dd"
|
||||||
|
orientation?: "vertical" | "horizontal" | "responsive",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DatePickerInputField<TFormValues extends FieldValues>({
|
export function DatePickerInputField<TFormValues extends FieldValues>({
|
||||||
control,
|
control,
|
||||||
name,
|
name,
|
||||||
displayDateFormat = "dd-MM-yyyy",
|
displayDateFormat = "dd-MM-y1qyyy",
|
||||||
parseDateFormat = "yyyy-MM-dd",
|
parseDateFormat = "yyyy-MM-dd",
|
||||||
...props
|
...props
|
||||||
}: DatePickerInputFieldProps<TFormValues>) {
|
}: DatePickerInputFieldProps<TFormValues>) {
|
||||||
return (
|
return (
|
||||||
<FormField
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field, fieldState, formState }) => (
|
render={({ field, fieldState, formState }) => (
|
||||||
@ -33,6 +35,9 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
|
|||||||
field={field}
|
field={field}
|
||||||
fieldState={fieldState}
|
fieldState={fieldState}
|
||||||
formState={formState}
|
formState={formState}
|
||||||
|
htmlFor={name}
|
||||||
|
|
||||||
|
|
||||||
displayDateFormat={displayDateFormat}
|
displayDateFormat={displayDateFormat}
|
||||||
parseDateFormat={parseDateFormat}
|
parseDateFormat={parseDateFormat}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
export const Fieldset = ({ className, children, ...props }: React.ComponentProps<"fieldset">) => (
|
|
||||||
<fieldset
|
|
||||||
data-slot='fieldset'
|
|
||||||
className={cn(
|
|
||||||
"bg-card rounded-xl p-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</fieldset>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const FieldGroup = ({ className, children, ...props }: React.ComponentProps<"div">) => (
|
|
||||||
<div data-slot='control' className={cn("space-y-6", className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Field = ({ className, children, ...props }: React.ComponentProps<"div">) => (
|
|
||||||
<div
|
|
||||||
data-slot='field'
|
|
||||||
className={cn(
|
|
||||||
"bg-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Legend = ({ className, children, ...props }: React.ComponentProps<"div">) => (
|
|
||||||
<div
|
|
||||||
data-slot='legend'
|
|
||||||
className={cn(
|
|
||||||
"text-sm flex items-center gap-2 text-muted-foreground font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Description = ({ className, children, ...props }: React.ComponentProps<"p">) => (
|
|
||||||
<p
|
|
||||||
data-slot='text'
|
|
||||||
className={cn("text-base text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
export * from "./date-picker-input-field/index.ts";
|
export * from "./date-picker-input-field/index.ts";
|
||||||
export * from "./DatePickerField.tsx";
|
export * from "./DatePickerField.tsx";
|
||||||
export * from "./fieldset.tsx";
|
|
||||||
export * from "./multi-select-field.tsx";
|
export * from "./multi-select-field.tsx";
|
||||||
export * from "./SelectField.tsx";
|
export * from "./SelectField.tsx";
|
||||||
export * from "./TextAreaField.tsx";
|
export * from "./TextAreaField.tsx";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export type CommonInputProps = Omit<
|
export type CommonInputProps = Omit<
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
||||||
>;
|
>;
|
||||||
|
|||||||
@ -21,16 +21,16 @@ export function TeamSwitcher({
|
|||||||
teams,
|
teams,
|
||||||
}: {
|
}: {
|
||||||
teams: {
|
teams: {
|
||||||
name: string;
|
name: string
|
||||||
logo: React.ElementType;
|
logo: React.ElementType
|
||||||
plan: string;
|
plan: string
|
||||||
}[];
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar()
|
||||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||||
|
|
||||||
if (!activeTeam) {
|
if (!activeTeam) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -39,49 +39,51 @@ export function TeamSwitcher({
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
size='lg'
|
size="lg"
|
||||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<div className='bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
<activeTeam.logo className='size-4' />
|
<activeTeam.logo className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className='truncate font-medium'>{activeTeam.name}</span>
|
<span className="truncate font-medium">{activeTeam.name}</span>
|
||||||
<span className='truncate text-xs'>{activeTeam.plan}</span>
|
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown className='ml-auto' />
|
<ChevronsUpDown className="ml-auto" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||||
align='start'
|
align="start"
|
||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel className='text-muted-foreground text-xs'>Teams</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||||
|
Teams
|
||||||
|
</DropdownMenuLabel>
|
||||||
{teams.map((team, index) => (
|
{teams.map((team, index) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={team.name}
|
key={team.name}
|
||||||
onClick={() => setActiveTeam(team)}
|
onClick={() => setActiveTeam(team)}
|
||||||
className='gap-2 p-2'
|
className="gap-2 p-2"
|
||||||
>
|
>
|
||||||
<div className='flex size-6 items-center justify-center rounded-md border'>
|
<div className="flex size-6 items-center justify-center rounded-md border">
|
||||||
<team.logo className='size-3.5 shrink-0' />
|
<team.logo className="size-3.5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
{team.name}
|
{team.name}
|
||||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className='gap-2 p-2'>
|
<DropdownMenuItem className="gap-2 p-2">
|
||||||
<div className='flex size-6 items-center justify-center rounded-md border bg-transparent'>
|
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||||
<Plus className='size-4' />
|
<Plus className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground font-medium'>Add team</div>
|
<div className="text-muted-foreground font-medium">Add team</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,12 @@
|
|||||||
"no_results": "No se han encontrado resultados.",
|
"no_results": "No se han encontrado resultados.",
|
||||||
"select_options": "Seleccionar opciones",
|
"select_options": "Seleccionar opciones",
|
||||||
"select_all": "Seleccionar todo"
|
"select_all": "Seleccionar todo"
|
||||||
|
},
|
||||||
|
"date_picker_input_field": {
|
||||||
|
"invalid_date": "Fecha inválida",
|
||||||
|
"clear_date": "Limpiar fecha",
|
||||||
|
"today": "Hoy",
|
||||||
|
"close": "Cerrar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { type VariantProps, cva } from "class-variance-authority"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
import { cn } from "@repo/shadcn-ui/lib/utils"
|
||||||
|
|
||||||
@ -35,7 +35,6 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant,
|
variant,
|
||||||
@ -47,7 +46,6 @@ function Button({
|
|||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
@ -56,5 +54,4 @@ function Button({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
|
import { type VariantProps, cva } from "class-variance-authority"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
|
||||||
import { Label } from "@repo/shadcn-ui/components/label"
|
import { Label } from "@repo/shadcn-ui/components/label"
|
||||||
import { Separator } from "@repo/shadcn-ui/components/separator"
|
import { Separator } from "@repo/shadcn-ui/components/separator"
|
||||||
|
import { cn } from "@repo/shadcn-ui/lib/utils"
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
return (
|
return (
|
||||||
@ -198,7 +198,7 @@ function FieldError({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors?.length == 1) {
|
if (errors?.length === 1) {
|
||||||
return errors[0]?.message
|
return errors[0]?.message
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,7 +206,7 @@ function FieldError({
|
|||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
{errors.map(
|
{errors.map(
|
||||||
(error, index) =>
|
(error, index) =>
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
error?.message && <li key={`error-${index}`}>{error.message}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
@ -229,14 +229,10 @@ function FieldError({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Field,
|
Field, FieldContent, FieldDescription,
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup, FieldLabel, FieldLegend,
|
||||||
FieldLegend,
|
|
||||||
FieldSeparator,
|
FieldSeparator,
|
||||||
FieldSet,
|
FieldSet, FieldTitle
|
||||||
FieldContent,
|
|
||||||
FieldTitle,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -81,12 +81,6 @@
|
|||||||
--sidebar-border: oklch(0.9173 0.0067 286.2663);
|
--sidebar-border: oklch(0.9173 0.0067 286.2663);
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
--radius: 0.40rem;
|
--radius: 0.40rem;
|
||||||
--shadow-x: 0px;
|
|
||||||
--shadow-y: 1px;
|
|
||||||
--shadow-blur: 3px;
|
|
||||||
--shadow-spread: 0px;
|
|
||||||
--shadow-opacity: 0.1;
|
|
||||||
--shadow-color: oklch(0 0 0);
|
|
||||||
--shadow-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
--shadow-2xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||||
--shadow-xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
--shadow-xs: 1px 1px 6px 0px hsl(0 0% 0% / 0.05);
|
||||||
--shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);
|
--shadow-sm: 1px 1px 6px 0px hsl(0 0% 0% / 0.1), 1px 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user