Facturas de cliente
This commit is contained in:
parent
cb9c8e5061
commit
acada25dfd
@ -71,9 +71,9 @@
|
||||
"title": "Invoice details",
|
||||
"description": ""
|
||||
},
|
||||
"basic_into": {
|
||||
"basic_info": {
|
||||
"title": "Invoice information",
|
||||
"description": ""
|
||||
"description": "Basic invoice information"
|
||||
},
|
||||
"totals": {
|
||||
"title": "Invoice totals",
|
||||
|
||||
@ -72,9 +72,9 @@
|
||||
"title": "Detalles de la factura",
|
||||
"description": ""
|
||||
},
|
||||
"basic_into": {
|
||||
"basic_info": {
|
||||
"title": "Información de la factura",
|
||||
"description": ""
|
||||
"description": "Información básica de la factura"
|
||||
},
|
||||
"totals": {
|
||||
"title": "Totales de la factura",
|
||||
|
||||
@ -25,28 +25,19 @@ export const CustomerInvoiceEditForm = ({
|
||||
return (
|
||||
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
|
||||
<section className={cn("space-y-6", className)}>
|
||||
<div className='w-full grid grid-cols-4'>
|
||||
<div className="col-span-3">
|
||||
<InvoiceBasicInfoFields className="flex flex-col" />
|
||||
</div>
|
||||
<div className='col-span-1'>
|
||||
<InvoiceRecipient className="flex flex-col" />
|
||||
</div>
|
||||
<div className="w-full border p-6 bg-background">
|
||||
<InvoiceBasicInfoFields className="flex flex-col" />
|
||||
</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 className="w-full border p-6 bg-background">
|
||||
<InvoiceTotals />
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
|
||||
@ -1,13 +1,8 @@
|
||||
import {
|
||||
DatePickerInputField,
|
||||
Description,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Fieldset,
|
||||
Legend,
|
||||
TextField
|
||||
} 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 { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@ -17,86 +12,64 @@ export const InvoiceBasicInfoFields = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<InvoiceFormData>();
|
||||
|
||||
|
||||
return (
|
||||
<Fieldset {...props} className='border n'>
|
||||
<Legend>
|
||||
<FileTextIcon className='size-4 stroke-2' />{t("form_groups.basic_into.title")}
|
||||
</Legend>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||
{t("form_groups.basic_info.title")}
|
||||
</FieldLegend>
|
||||
<FieldDescription className='hidden'>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||
|
||||
<Description>{t("form_groups.basic_into.description")}</Description>
|
||||
<FieldGroup className='grid grid-cols-1'>
|
||||
<Field>
|
||||
<TextField
|
||||
className='hidden'
|
||||
control={control}
|
||||
name='invoice_number'
|
||||
readOnly
|
||||
label={t("form_fields.invoice_number.label")}
|
||||
placeholder={t("form_fields.invoice_number.placeholder")}
|
||||
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>
|
||||
<FieldGroup className='flex flex-row flex-wrap gap-6 xl:flex-nowrap'>
|
||||
<DatePickerInputField
|
||||
className='min-w-44 flex-1 sm:max-w-44'
|
||||
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>
|
||||
<DatePickerInputField
|
||||
control={control}
|
||||
numberOfMonths={2}
|
||||
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>
|
||||
<DatePickerInputField
|
||||
className='min-w-44 flex-1 sm:max-w-44'
|
||||
control={control}
|
||||
numberOfMonths={2}
|
||||
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
|
||||
typePreset='text'
|
||||
control={control}
|
||||
name='series'
|
||||
label={t("form_fields.series.label")}
|
||||
placeholder={t("form_fields.series.placeholder")}
|
||||
description={t("form_fields.series.description")}
|
||||
/>
|
||||
</Field>
|
||||
<TextField
|
||||
className='min-w-16 flex-1 sm:max-w-16'
|
||||
control={control}
|
||||
name='series'
|
||||
label={t("form_fields.series.label")}
|
||||
placeholder={t("form_fields.series.placeholder")}
|
||||
description={t("form_fields.series.description")}
|
||||
/>
|
||||
|
||||
<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")}
|
||||
/>
|
||||
|
||||
|
||||
<Field>
|
||||
<TextField
|
||||
typePreset='text'
|
||||
maxLength={256}
|
||||
control={control}
|
||||
name='reference'
|
||||
label={t("form_fields.reference.label")}
|
||||
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>
|
||||
<TextField
|
||||
className='min-w-32 flex-1 xs:max-w-full'
|
||||
maxLength={256}
|
||||
control={control}
|
||||
name='description'
|
||||
label={t("form_fields.description.label")}
|
||||
placeholder={t("form_fields.description.placeholder")}
|
||||
description={t("form_fields.description.description")}
|
||||
/>
|
||||
</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 { useTranslation } from '../../i18n';
|
||||
import { ItemsEditor } from "./items";
|
||||
|
||||
|
||||
export const InvoiceItems = ({ className, ...props }: ComponentProps<"fieldset">) => {
|
||||
export const InvoiceItems = (props: ComponentProps<"fieldset">) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Fieldset {...props}>
|
||||
<Legend>
|
||||
<Rows4Icon className='size-6 text-muted-foreground' />{t('form_groups.items.title')}
|
||||
</Legend>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||
{t('form_groups.items.title')}
|
||||
</FieldLegend>
|
||||
<FieldDescription className='hidden'>{t("form_groups.items.description")}</FieldDescription>
|
||||
|
||||
<Description>{t("form_groups.items.description")}</Description>
|
||||
<FieldGroup className='grid grid-cols-1'>
|
||||
<ItemsEditor />
|
||||
</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 { ComponentProps } from 'react';
|
||||
import { useFormContext } from "react-hook-form";
|
||||
@ -10,12 +11,12 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
||||
const { control } = useFormContext<InvoiceFormData>();
|
||||
|
||||
return (
|
||||
<Fieldset {...props}>
|
||||
<Legend>
|
||||
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_into.title")}
|
||||
</Legend>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend>
|
||||
<StickyNoteIcon className='size-6 text-muted-foreground' />{t("form_groups.basic_info.title")}
|
||||
</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'>
|
||||
<TextAreaField
|
||||
maxLength={1024}
|
||||
@ -27,6 +28,6 @@ export const InvoiceNotes = (props: ComponentProps<"fieldset">) => {
|
||||
description={t("form_fields.notes.description")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { formatCurrency } from '@erp/core';
|
||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
||||
import { Badge } from "@repo/shadcn-ui/components";
|
||||
import { Badge, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||
import { ReceiptIcon } from "lucide-react";
|
||||
import { ComponentProps } from 'react';
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
@ -23,45 +22,47 @@ export const InvoiceTaxSummary = (props: ComponentProps<"fieldset">) => {
|
||||
const displayTaxes = taxes || [];
|
||||
|
||||
return (
|
||||
<Fieldset {...props}>
|
||||
<Legend className='flex items-center gap-2 text-foreground'>
|
||||
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
|
||||
</Legend>
|
||||
<FieldGroup>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className='flex items-center gap-2 text-foreground'>
|
||||
<ReceiptIcon className='size-5' /> {t("form_groups.tax_resume.title")}
|
||||
</FieldLegend>
|
||||
|
||||
<Description>{t("form_groups.tax_resume.description")}</Description>
|
||||
<FieldGroup className='grid grid-cols-1'>
|
||||
<div className='space-y-3'>
|
||||
{displayTaxes.map((tax, index) => (
|
||||
<FieldDescription>{t("form_groups.tax_resume.description")}</FieldDescription>
|
||||
<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 space-y-2 text-base '>
|
||||
<div className='flex items-center justify-between mb-2 '>
|
||||
<Badge variant='secondary' className='text-sm font-semibold'>
|
||||
{tax.tax_label}
|
||||
</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 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 '>
|
||||
<Badge variant='secondary' className='text-sm font-semibold'>
|
||||
{tax.tax_label}
|
||||
</Badge>
|
||||
</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 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 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>
|
||||
))}
|
||||
))}
|
||||
|
||||
{displayTaxes.length === 0 && (
|
||||
<div className='text-center py-6 text-muted-foreground'>
|
||||
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
|
||||
<p className='text-sm'>No hay impuestos aplicados</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
{displayTaxes.length === 0 && (
|
||||
<div className='text-center py-6 text-muted-foreground'>
|
||||
<ReceiptIcon className='size-8 mx-auto mb-2 opacity-50' />
|
||||
<p className='text-sm'>No hay impuestos aplicados</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</FieldGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { formatCurrency } from "@erp/core";
|
||||
import { Description, FieldGroup, Fieldset, Legend } from "@repo/rdx-ui/components";
|
||||
import { Separator } from "@repo/shadcn-ui/components";
|
||||
import { FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator } from '@repo/shadcn-ui/components';
|
||||
import { ReceiptIcon } from "lucide-react";
|
||||
import { ComponentProps } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
@ -22,12 +21,12 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
||||
|
||||
|
||||
return (
|
||||
<Fieldset {...props}>
|
||||
<Legend>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend>
|
||||
<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'>
|
||||
{/* Sección: Subtotal y Descuentos */}
|
||||
<div className="space-y-1.5">
|
||||
@ -145,6 +144,6 @@ export const InvoiceTotals = (props: ComponentProps<"fieldset">) => {
|
||||
</div>
|
||||
</div>
|
||||
</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 { UserIcon } from "lucide-react";
|
||||
import { ComponentProps } from 'react';
|
||||
import { useTranslation } from "../../../i18n";
|
||||
import { RecipientModalSelectorField } from "./recipient-modal-selector-field";
|
||||
@ -14,18 +13,18 @@ export const InvoiceRecipient = (props: ComponentProps<"fieldset">) => {
|
||||
const recipient = getValues('recipient');
|
||||
|
||||
return (
|
||||
<Fieldset {...props}>
|
||||
<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>
|
||||
<FieldSet {...props}>
|
||||
<FieldLegend className='hidden text-foreground' variant='label'>
|
||||
{t('form_groups.recipient.title')}
|
||||
</FieldLegend>
|
||||
<FieldDescription className='hidden'>{t("form_groups.recipient.description")}</FieldDescription>
|
||||
<FieldGroup className='grid grid-cols-1'>
|
||||
<RecipientModalSelectorField
|
||||
control={control}
|
||||
name='customer_id'
|
||||
initialRecipient={recipient}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,7 +28,7 @@ export function PageHeader({ icon, title, description, status, rightSlot, classN
|
||||
{icon && <div className='shrink-0'>{icon}</div>}
|
||||
<div>
|
||||
<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} />}
|
||||
</div>
|
||||
{description && <p className='text-sm text-muted-foreground'>{description}</p>}
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
UnsavedChangesProvider,
|
||||
useHookForm
|
||||
} 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 { FilePenIcon } from "lucide-react";
|
||||
import { useMemo } from 'react';
|
||||
@ -86,9 +86,10 @@ export const InvoiceUpdateComp = ({
|
||||
return (
|
||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||
<AppHeader>
|
||||
<AppBreadcrumb />
|
||||
<PageHeader
|
||||
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={
|
||||
<FormCommitButtonGroup
|
||||
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 { useFormContext } from "react-hook-form";
|
||||
@ -11,9 +11,9 @@ export const CustomerAdditionalConfigFields = () => {
|
||||
const { control } = useFormContext<CustomerFormData>();
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<Legend>{t("form_groups.preferences.title")}</Legend>
|
||||
<Description>{t("form_groups.preferences.description")}</Description>
|
||||
<FieldSet>
|
||||
<FieldLegend>{t("form_groups.preferences.title")}</FieldLegend>
|
||||
<FieldDescription>{t("form_groups.preferences.description")}</FieldDescription>
|
||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||
<Field className='lg:col-span-2'>
|
||||
<SelectField
|
||||
@ -39,6 +39,6 @@ export const CustomerAdditionalConfigFields = () => {
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import {
|
||||
Description,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Fieldset,
|
||||
Legend,
|
||||
SelectField,
|
||||
TextField,
|
||||
TextField
|
||||
} from "@repo/rdx-ui/components";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@repo/shadcn-ui/components';
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { COUNTRY_OPTIONS } from "../../constants";
|
||||
import { useTranslation } from "../../i18n";
|
||||
@ -17,9 +13,9 @@ export const CustomerAddressFields = () => {
|
||||
const { control } = useFormContext<CustomerFormData>();
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<Legend>{t("form_groups.address.title")}</Legend>
|
||||
<Description>{t("form_groups.address.description")}</Description>
|
||||
<FieldSet>
|
||||
<FieldLegend>{t("form_groups.address.title")}</FieldLegend>
|
||||
<FieldDescription>{t("form_groups.address.description")}</FieldDescription>
|
||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||
<TextField
|
||||
className='lg:col-span-2'
|
||||
@ -75,6 +71,6 @@ export const CustomerAddressFields = () => {
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,21 +1,16 @@
|
||||
import {
|
||||
Description,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Fieldset,
|
||||
Legend,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
TextField
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
FormControl,
|
||||
Field, FieldDescription, FieldGroup, FieldLegend, FieldSet, FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
RadioGroupItem
|
||||
} from '@repo/shadcn-ui/components';
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
|
||||
import { useTranslation } from "../../i18n";
|
||||
@ -32,9 +27,9 @@ export const CustomerBasicInfoFields = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<Legend>{t("form_groups.basic_info.title")}</Legend>
|
||||
<Description>{t("form_groups.basic_info.description")}</Description>
|
||||
<FieldSet>
|
||||
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
|
||||
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
|
||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||
<Field className='lg:col-span-2'>
|
||||
<TextField
|
||||
@ -131,6 +126,6 @@ export const CustomerBasicInfoFields = () => {
|
||||
description={t("form_fields.legal_record.description")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import {
|
||||
Description,
|
||||
Field,
|
||||
FieldGroup,
|
||||
Fieldset,
|
||||
Legend,
|
||||
TextField,
|
||||
TextField
|
||||
} from "@repo/rdx-ui/components";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
Separator,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
CollapsibleTrigger, Field, FieldDescription, FieldGroup, FieldLegend, FieldSet, Separator
|
||||
} from '@repo/shadcn-ui/components';
|
||||
|
||||
import { AtSignIcon, ChevronDown, GlobeIcon, PhoneIcon, SmartphoneIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@ -24,9 +18,9 @@ export const CustomerContactFields = () => {
|
||||
const { control } = useFormContext();
|
||||
|
||||
return (
|
||||
<Fieldset>
|
||||
<Legend>{t("form_groups.contact_info.title")}</Legend>
|
||||
<Description>{t("form_groups.contact_info.description")}</Description>
|
||||
<FieldSet>
|
||||
<FieldLegend>{t("form_groups.contact_info.title")}</FieldLegend>
|
||||
<FieldDescription>{t("form_groups.contact_info.description")}</FieldDescription>
|
||||
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
|
||||
<TextField
|
||||
className='lg:col-span-2'
|
||||
@ -153,6 +147,6 @@ export const CustomerContactFields = () => {
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FieldGroup>
|
||||
</Fieldset>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,116 +1,77 @@
|
||||
// DatePickerField.tsx
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Textarea,
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { Control, FieldPath, FieldValues, useController } from "react-hook-form";
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form";
|
||||
import { CommonInputProps } from "./types.js";
|
||||
|
||||
type TextAreaFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
|
||||
/** Contador de caracteres (si usas maxLength) */
|
||||
showCounter?: boolean;
|
||||
maxLength?: number;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
description?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive",
|
||||
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
export function TextAreaField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = 'vertical',
|
||||
|
||||
className,
|
||||
showCounter = false,
|
||||
maxLength,
|
||||
rows = 3
|
||||
inputClassName,
|
||||
|
||||
...inputRest
|
||||
}: TextAreaFieldProps<TFormValues>) {
|
||||
const { t } = useTranslation();
|
||||
const isDisabled = disabled || readOnly;
|
||||
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;
|
||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||
const disabled = isSubmitting || inputRest.disabled;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn("space-y-0 flex flex-col ", className)}>
|
||||
{label && (
|
||||
<div className='mb-1 flex justify-between gap-2'>
|
||||
<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>
|
||||
render={({ field, fieldState }) => {
|
||||
return (
|
||||
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||
|
||||
<Textarea
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
className={"placeholder:font-normal placeholder:italic bg-background flex flex-1 min-h-0 h-full"}
|
||||
maxLength={maxLength}
|
||||
spellCheck={true}
|
||||
rows={rows}
|
||||
|
||||
{...field}
|
||||
ref={field.ref}
|
||||
id={name}
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-busy={isValidating}
|
||||
{...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" && (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{valueLength} / {maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormMessage id={errorId} />
|
||||
</FormItem>
|
||||
)}
|
||||
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,471 +1,83 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
Input
|
||||
} from "@repo/shadcn-ui/components";
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import { Control, FieldPath, FieldValues, useController, useFormState } from "react-hook-form";
|
||||
import { useTranslation } from "../../locales/i18n.ts";
|
||||
import { Control, Controller, FieldPath, FieldValues, useFormState } from "react-hook-form";
|
||||
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 TextFieldProps<TFormValues extends FieldValues> = CommonInputProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive",
|
||||
|
||||
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>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
readOnly,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
|
||||
orientation = 'vertical',
|
||||
|
||||
className,
|
||||
inputClassName,
|
||||
typePreset = "text",
|
||||
|
||||
icon,
|
||||
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
|
||||
...inputRest
|
||||
}: TextFieldProps<TFormValues>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isSubmitting, isValidating } = useFormState({ control, name });
|
||||
const { field, fieldState } = useController({ control, name });
|
||||
|
||||
// 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();
|
||||
}
|
||||
const disabled = isSubmitting || inputRest.disabled;
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (submitOnEnter && e.key === "Enter") {
|
||||
if (e.key === "Enter") {
|
||||
const form = (e.currentTarget as HTMLInputElement).form;
|
||||
if (form) form.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
field.onChange("");
|
||||
}
|
||||
|
||||
const valueLength = (field.value?.length ?? 0) as number;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn("space-y-0", className)}>
|
||||
{label && (
|
||||
<div className='mb-1 flex justify-between gap-2'>
|
||||
<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>
|
||||
)}
|
||||
render={({ field, fieldState }) => {
|
||||
return (
|
||||
<Field data-invalid={fieldState.invalid} orientation={orientation} className={className}>
|
||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={name}>{label}</FieldLabel>}
|
||||
|
||||
<FormControl>
|
||||
<div className={cn("relative")}>
|
||||
{/* Prefix clicable (si tiene onClick) */}
|
||||
{hasPrefix && (
|
||||
<button
|
||||
type={onPrefixClick ? "button" : undefined}
|
||||
onClick={onPrefixClick}
|
||||
tabIndex={onPrefixClick ? 0 : -1}
|
||||
className={cn(
|
||||
"absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground",
|
||||
!onPrefixClick && "pointer-events-none"
|
||||
)}
|
||||
aria-label='prefix'
|
||||
>
|
||||
{prefix}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<Input
|
||||
ref={field.ref}
|
||||
id={name}
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-invalid={fieldState.invalid}
|
||||
aria-busy={isValidating}
|
||||
{...inputRest}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
className={cn(inputClassName)}
|
||||
/>
|
||||
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,51 +8,59 @@ import {
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
PopoverTrigger
|
||||
} from "@repo/shadcn-ui/components";
|
||||
import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils";
|
||||
import { format, isValid, parse } from "date-fns";
|
||||
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 { ControllerFieldState, ControllerRenderProps, UseFormStateReturn } from "react-hook-form";
|
||||
|
||||
export type SUICalendarProps = Omit<React.ComponentProps<
|
||||
typeof Calendar>, "select" | "onSelect">
|
||||
|
||||
type DatePickerInputCompProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
||||
field: ControllerRenderProps<TFormValues>;
|
||||
type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = SUICalendarProps & {
|
||||
field: ControllerRenderProps<TFormValues, Path<TFormValues>>;
|
||||
fieldState: ControllerFieldState;
|
||||
formState: UseFormStateReturn<TFormValues>;
|
||||
|
||||
htmlFor: string,
|
||||
|
||||
displayDateFormat: string; // e.g. "dd/MM/yyyy"
|
||||
parseDateFormat: string; // e.g. "yyyy/MM/dd"
|
||||
|
||||
label: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
invalid?: boolean;
|
||||
|
||||
orientation?: "vertical" | "horizontal" | "responsive",
|
||||
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||
export function DatePickerInputComp<TFormValues>({
|
||||
field,
|
||||
fieldState,
|
||||
formState,
|
||||
|
||||
htmlFor,
|
||||
|
||||
parseDateFormat,
|
||||
displayDateFormat,
|
||||
|
||||
@ -62,6 +70,10 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||
disabled = false,
|
||||
required = false,
|
||||
readOnly = false,
|
||||
invalid = false,
|
||||
|
||||
orientation = "vertical",
|
||||
|
||||
className,
|
||||
...calendarProps
|
||||
}: DatePickerInputCompProps<TFormValues>) {
|
||||
@ -69,9 +81,6 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||
const isDisabled = 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 [displayValue, setDisplayValue] = useState<string>("");
|
||||
|
||||
@ -120,30 +129,14 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem className={cn("space-y-0", className)}>
|
||||
{label && (
|
||||
<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>
|
||||
)}
|
||||
<Field data-invalid={invalid} orientation={orientation} className={className}>
|
||||
{label && <FieldLabel className='text-xs text-muted-foreground text-nowrap' htmlFor={htmlFor}>{label}</FieldLabel>}
|
||||
|
||||
<Popover modal={true} open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<div className='relative'>
|
||||
<input
|
||||
<Input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={(e) => handleDisplayValueChange(e.target.value)}
|
||||
@ -250,16 +243,9 @@ export function DatePickerInputComp<TFormValues extends FieldValues>({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='mt-1 flex items-start justify-between'>
|
||||
<FormDescription
|
||||
id={describedById}
|
||||
className={cn("text-xs truncate", !description && "invisible")}
|
||||
>
|
||||
{description || "\u00A0"}
|
||||
</FormDescription>
|
||||
</div>
|
||||
{false && <FieldDescription className='text-xs'>{description || "\u00A0"}</FieldDescription>}
|
||||
|
||||
<FormMessage id={errorId} />
|
||||
</FormItem>
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
</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";
|
||||
|
||||
type DatePickerInputFieldProps<TFormValues extends FieldValues> = SUICalendarProps & {
|
||||
control: Control<TFormValues>;
|
||||
name: FieldPath<TFormValues>;
|
||||
label: string;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
|
||||
displayDateFormat?: string; // e.g. "dd/MM/yyyy"
|
||||
parseDateFormat?: string; // e.g. "yyyy-MM-dd"
|
||||
orientation?: "vertical" | "horizontal" | "responsive",
|
||||
};
|
||||
|
||||
export function DatePickerInputField<TFormValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
displayDateFormat = "dd-MM-yyyy",
|
||||
displayDateFormat = "dd-MM-y1qyyy",
|
||||
parseDateFormat = "yyyy-MM-dd",
|
||||
...props
|
||||
}: DatePickerInputFieldProps<TFormValues>) {
|
||||
return (
|
||||
<FormField
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, fieldState, formState }) => (
|
||||
@ -33,6 +35,9 @@ export function DatePickerInputField<TFormValues extends FieldValues>({
|
||||
field={field}
|
||||
fieldState={fieldState}
|
||||
formState={formState}
|
||||
htmlFor={name}
|
||||
|
||||
|
||||
displayDateFormat={displayDateFormat}
|
||||
parseDateFormat={parseDateFormat}
|
||||
{...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 "./DatePickerField.tsx";
|
||||
export * from "./fieldset.tsx";
|
||||
|
||||
export * from "./multi-select-field.tsx";
|
||||
export * from "./SelectField.tsx";
|
||||
export * from "./TextAreaField.tsx";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export type CommonInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"name" | "value" | "onChange" | "onBlur" | "ref" | "type"
|
||||
>;
|
||||
|
||||
@ -21,16 +21,16 @@ export function TeamSwitcher({
|
||||
teams,
|
||||
}: {
|
||||
teams: {
|
||||
name: string;
|
||||
logo: React.ElementType;
|
||||
plan: string;
|
||||
}[];
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}[]
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
||||
const { isMobile } = useSidebar()
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||
|
||||
if (!activeTeam) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@ -39,49 +39,51 @@ export function TeamSwitcher({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
size="lg"
|
||||
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'>
|
||||
<activeTeam.logo className='size-4' />
|
||||
<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" />
|
||||
</div>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-medium'>{activeTeam.name}</span>
|
||||
<span className='truncate text-xs'>{activeTeam.plan}</span>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{activeTeam.name}</span>
|
||||
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ml-auto' />
|
||||
<ChevronsUpDown className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
align='start'
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='text-muted-foreground text-xs'>Teams</DropdownMenuLabel>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
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'>
|
||||
<team.logo className='size-3.5 shrink-0' />
|
||||
<div className="flex size-6 items-center justify-center rounded-md border">
|
||||
<team.logo className="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className='gap-2 p-2'>
|
||||
<div className='flex size-6 items-center justify-center rounded-md border bg-transparent'>
|
||||
<Plus className='size-4' />
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className='text-muted-foreground font-medium'>Add team</div>
|
||||
<div className="text-muted-foreground font-medium">Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,6 +22,12 @@
|
||||
"no_results": "No se han encontrado resultados.",
|
||||
"select_options": "Seleccionar opciones",
|
||||
"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 { 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"
|
||||
|
||||
@ -35,7 +35,6 @@ const buttonVariants = cva(
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
@ -47,7 +46,6 @@ function Button({
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
@ -56,5 +54,4 @@ function Button({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority"
|
||||
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 { Separator } from "@repo/shadcn-ui/components/separator"
|
||||
import { cn } from "@repo/shadcn-ui/lib/utils"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
@ -198,7 +198,7 @@ function FieldError({
|
||||
return null
|
||||
}
|
||||
|
||||
if (errors?.length == 1) {
|
||||
if (errors?.length === 1) {
|
||||
return errors[0]?.message
|
||||
}
|
||||
|
||||
@ -206,7 +206,7 @@ function FieldError({
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
error?.message && <li key={`error-${index}`}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
@ -229,14 +229,10 @@ function FieldError({
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
Field, FieldContent, FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldGroup, FieldLabel, FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
FieldSet, FieldTitle
|
||||
}
|
||||
|
||||
|
||||
@ -81,12 +81,6 @@
|
||||
--sidebar-border: oklch(0.9173 0.0067 286.2663);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
--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-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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user