This commit is contained in:
David Arranz 2026-04-09 20:39:54 +02:00
parent bd209374bc
commit b88632d395
25 changed files with 1175 additions and 644 deletions

View File

@ -1,5 +1,5 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
export const ProformaLayout = ({ children }: PropsWithChildren) => { export const ProformaLayout = ({ children }: PropsWithChildren) => {
return <div>{children}</div>; return <div className="overflow-y-scroll">{children}</div>;
}; };

View File

@ -1,2 +1,5 @@
export * from "./proforma-form-field-shell";
export * from "./proforma-header-fields-card"; export * from "./proforma-header-fields-card";
export * from "./proforma-update-editor"; export * from "./proforma-header-form-grid";
export * from "./proforma-section-card";
export * from "./proforma-update-editor-form";

View File

@ -0,0 +1,74 @@
import { DatePickerInputField, TextField } from "@repo/rdx-ui/components";
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
export const ProformaBasicInfoFields = (props: ComponentProps<"fieldset">) => {
const { t } = useTranslation();
const { control } = useFormContext<ProformaFormData>();
return (
<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>
<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}
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
numberOfMonths={2}
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<DatePickerInputField
className="min-w-44 flex-1 sm:max-w-44"
control={control}
description={t("form_fields.operation_date.description")}
label={t("form_fields.operation_date.label")}
name="operation_date"
numberOfMonths={2}
placeholder={t("form_fields.operation_date.placeholder")}
/>
<TextField
className="min-w-16 flex-1 sm:max-w-16"
control={control}
description={t("form_fields.series.description")}
label={t("form_fields.series.label")}
name="series"
placeholder={t("form_fields.series.placeholder")}
/>
<TextField
className="min-w-32 flex-1 sm:max-w-44"
control={control}
description={t("form_fields.reference.description")}
label={t("form_fields.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.reference.placeholder")}
/>
<TextField
className="min-w-32 flex-1 xs:max-w-full"
control={control}
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.description.placeholder")}
/>
</FieldGroup>
</FieldSet>
);
};

View File

@ -0,0 +1,55 @@
// update/ui/blocks/proforma-form-field-shell.tsx
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
export type ProformaFieldSpan = "xs" | "sm" | "md" | "lg" | "full";
const fieldSpanClasses: Record<ProformaFieldSpan, string> = {
xs: "md:col-span-2",
sm: "md:col-span-3",
md: "md:col-span-4",
lg: "md:col-span-6",
full: "md:col-span-12",
};
interface ProformaFormFieldShellProps {
label: ReactNode;
htmlFor: string;
span?: ProformaFieldSpan;
required?: boolean;
description?: ReactNode;
error?: ReactNode;
children: ReactNode;
className?: string;
}
export const ProformaFormFieldShell = ({
label,
htmlFor,
span = "md",
required = false,
description,
error,
children,
className,
}: ProformaFormFieldShellProps) => {
return (
<div className={cn(fieldSpanClasses[span], "space-y-1.5", className)}>
<label className="inline-flex items-center gap-1 text-sm font-medium" htmlFor={htmlFor}>
<span>{label}</span>
{required ? <span className="text-destructive">*</span> : null}
</label>
{children}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</div>
);
};

View File

@ -1,17 +1,83 @@
import { DatePickerField, TextField } from "@repo/rdx-ui/components";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
FieldDescription,
FieldGroup,
FieldLegend,
FieldSet,
Input, Input,
Textarea, Textarea,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import type { ComponentProps } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import type { ProformaUpdateForm } from "../../entities"; import type { ProformaUpdateForm } from "../../entities";
export const ProformaHeaderFieldsCard = () => { export const ProformaHeaderFieldsCard = (props: ComponentProps<"fieldset">) => {
const { className, ...rest } = props;
const { t } = useTranslation();
//const { register, formState } = useFormContext<ProformaUpdateForm>();
return (
<FieldSet {...rest}>
<FieldLegend className="text-foreground" variant="label">
{t("form_groups.basic_info.title")}
</FieldLegend>
<FieldDescription className="">{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className={className}>
<TextField
className="md:col-span-2"
description={t("form_fields.series.description")}
label={t("form_fields.series.label")}
name="series"
placeholder={t("form_fields.series.placeholder")}
/>
<DatePickerField
className="md:col-span-2"
description={t("form_fields.invoice_date.description")}
label={t("form_fields.invoice_date.label")}
name="invoice_date"
placeholder={t("form_fields.invoice_date.placeholder")}
required
/>
<DatePickerField
className="md:col-span-2"
description={t("form_fields.operation_date.description")}
label={t("form_fields.operation_date.label")}
name="operation_date"
placeholder={t("form_fields.operation_date.placeholder")}
/>
<TextField
className="md:col-span-7"
description={t("form_fields.reference.description")}
label={t("form_fields.reference.label")}
maxLength={256}
name="reference"
placeholder={t("form_fields.reference.placeholder")}
/>
<TextField
className="md:col-span-12"
description={t("form_fields.description.description")}
label={t("form_fields.description.label")}
maxLength={256}
name="description"
placeholder={t("form_fields.description.placeholder")}
/>
</FieldGroup>
</FieldSet>
);
};
const ProformaHeaderFieldsCard2 = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { register, formState } = useFormContext<ProformaUpdateForm>(); const { register, formState } = useFormContext<ProformaUpdateForm>();

View File

@ -0,0 +1,13 @@
// update/ui/blocks/proforma-header-form-grid.tsx
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
interface ProformaHeaderFormGridProps {
children: ReactNode;
className?: string;
}
export const ProformaHeaderFormGrid = ({ children, className }: ProformaHeaderFormGridProps) => {
return <div className={cn("grid grid-cols-1 gap-4 md:grid-cols-12", className)}>{children}</div>;
};

View File

@ -0,0 +1,29 @@
// update/ui/blocks/proforma-section-card.tsx
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
interface ProformaSectionCardProps {
title: string;
description?: string;
children: ReactNode;
className?: string;
}
export const ProformaSectionCard = ({
title,
description,
children,
className,
}: ProformaSectionCardProps) => {
return (
<section className={cn("rounded-xl border bg-background p-4 md:p-6", className)}>
<div className="mb-4 space-y-1 md:mb-6">
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
</div>
{children}
</section>
);
};

View File

@ -0,0 +1,74 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
import { Button } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useTranslation } from "../../../../i18n";
import type { Proforma } from "../../../shared/entities";
import { ProformaUpdateHeaderEditor } from "../editors";
import { ProformaHeaderFieldsCard } from "./proforma-header-fields-card";
type ProformaUpdateEditorProps = {
formId: string;
proforma?: Proforma;
isSubmitting: boolean;
onSubmit: React.FormEventHandler<HTMLFormElement>;
onReset: () => void;
className?: string;
};
export const ProformaUpdateEditorForm = ({
formId,
isSubmitting,
onSubmit,
onReset,
className,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
return (
<form className="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 xl:px-8" onSubmit={onSubmit}>
<div className="space-y-6">
<ProformaUpdateHeaderEditor
currencyOptions={[]}
customerOptions={[]}
disabled={isSubmitting}
paymentMethodOptions={[]}
priceListOptions={[]}
salesPersonOptions={[]}
statusOptions={[]}
warehouseOptions={[]}
/>
<div className="flex flex-col-reverse gap-3 border-t pt-4 sm:flex-row sm:justify-end">
<Button type="button" variant="outline">
{t("common.cancel")}
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</form>
);
return (
<form id={formId} noValidate onSubmit={onSubmit}>
<section className={cn("rounded-xl border bg-background p-4 md:p-6", className)}>
<ProformaHeaderFieldsCard className="grid grid-cols-1 gap-6 md:grid-cols-12" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-12">
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
{t("common.reset", "Restablecer")}
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? t("common.saving", "Guardando...") : t("common.save", "Guardar")}
</Button>
</div>
</section>
</form>
);
};

View File

@ -1,59 +0,0 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../../i18n";
import type { Proforma } from "../../../shared/entities";
import { ProformaHeaderFieldsCard } from "./proforma-header-fields-card";
type ProformaUpdateEditorProps = {
formId: string;
proforma?: Proforma;
isSubmitting: boolean;
onSubmit: React.FormEventHandler<HTMLFormElement>;
onReset: () => void;
};
export const ProformaUpdateEditor = ({
formId,
proforma,
isSubmitting,
onSubmit,
onReset,
}: ProformaUpdateEditorProps) => {
const { t } = useTranslation();
return (
<AppContent className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h1 className="text-xl font-semibold">
{t("proformas.update.page_title", "Editar proforma")}
</h1>
{proforma?.reference ? (
<p className="text-sm text-muted-foreground">{proforma.reference}</p>
) : null}
</div>
<BackHistoryButton />
</div>
<form className="space-y-6" id={formId} onSubmit={onSubmit}>
<ProformaHeaderFieldsCard />
<div className="flex items-center justify-end gap-2">
<Button disabled={isSubmitting} onClick={onReset} type="button" variant="outline">
{t("common.reset", "Restablecer")}
</Button>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? t("common.saving", "Guardando...") : t("common.save", "Guardar")}
</Button>
</div>
</form>
</AppContent>
);
};

View File

@ -0,0 +1 @@
export * from "./proforma-update-header-editor";

View File

@ -0,0 +1,391 @@
import {
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@repo/shadcn-ui/components";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import type { ProformaUpdateForm } from "../../entities";
import { ProformaFormFieldShell, ProformaHeaderFormGrid, ProformaSectionCard } from "../blocks";
interface SelectOption {
value: string;
label: string;
}
interface ProformaUpdateHeaderEditorProps {
statusOptions: SelectOption[];
customerOptions: SelectOption[];
currencyOptions: SelectOption[];
paymentMethodOptions: SelectOption[];
salesPersonOptions: SelectOption[];
warehouseOptions: SelectOption[];
priceListOptions: SelectOption[];
disabled?: boolean;
readOnly?: boolean;
}
export const ProformaUpdateHeaderEditor = ({
statusOptions,
customerOptions,
currencyOptions,
paymentMethodOptions,
salesPersonOptions,
warehouseOptions,
priceListOptions,
disabled = false,
readOnly = false,
}: ProformaUpdateHeaderEditorProps) => {
const { t } = useTranslation();
const {
register,
control,
formState: { errors },
} = useFormContext<ProformaUpdateForm>();
const isFieldLocked = disabled || readOnly;
return (
<div className="space-y-6">
<ProformaSectionCard
description={t("proformas.update.sections.document_description")}
title={t("proformas.update.sections.document")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.series?.message}
htmlFor="series"
label={t("proformas.fields.series")}
required
span="xs"
>
<Input className="h-10" disabled={isFieldLocked} id="series" {...register("series")} />
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.number?.message}
htmlFor="number"
label={t("proformas.fields.number")}
required
span="sm"
>
<Input className="h-10" disabled={isFieldLocked} id="number" {...register("number")} />
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.document_date?.message}
htmlFor="document_date"
label={t("proformas.fields.document_date")}
required
span="sm"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="document_date"
type="date"
{...register("document_date")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.status?.message}
htmlFor="status"
label={t("proformas.fields.status")}
required
span="md"
>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="status">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.external_reference?.message}
htmlFor="external_reference"
label={t("proformas.fields.external_reference")}
span="md"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="external_reference"
{...register("external_reference")}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.customer_description")}
title={t("proformas.update.sections.customer")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.customer_id?.message}
htmlFor="customer_id"
label={t("proformas.fields.customer")}
required
span="lg"
>
<Controller
control={control}
name="customer_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="customer_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{customerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.customer_tax_id?.message}
htmlFor="customer_tax_id"
label={t("proformas.fields.customer_tax_id")}
span="sm"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="customer_tax_id"
{...register("customer_tax_id")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.customer_reference?.message}
htmlFor="customer_reference"
label={t("proformas.fields.customer_reference")}
span="md"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="customer_reference"
{...register("customer_reference")}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.commercial_description")}
title={t("proformas.update.sections.commercial")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.currency_code?.message}
htmlFor="currency_code"
label={t("proformas.fields.currency_code")}
required
span="sm"
>
<Controller
control={control}
name="currency_code"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="currency_code">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{currencyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.payment_method_id?.message}
htmlFor="payment_method_id"
label={t("proformas.fields.payment_method")}
span="md"
>
<Controller
control={control}
name="payment_method_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="payment_method_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{paymentMethodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.due_date?.message}
htmlFor="due_date"
label={t("proformas.fields.due_date")}
span="sm"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="due_date"
type="date"
{...register("due_date")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.sales_person_id?.message}
htmlFor="sales_person_id"
label={t("proformas.fields.sales_person")}
span="md"
>
<Controller
control={control}
name="sales_person_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="sales_person_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{salesPersonOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.warehouse_id?.message}
htmlFor="warehouse_id"
label={t("proformas.fields.warehouse")}
span="md"
>
<Controller
control={control}
name="warehouse_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="warehouse_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{warehouseOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.price_list_id?.message}
htmlFor="price_list_id"
label={t("proformas.fields.price_list")}
span="md"
>
<Controller
control={control}
name="price_list_id"
render={({ field }) => (
<Select disabled={isFieldLocked} onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="h-10 w-full" id="price_list_id">
<SelectValue placeholder={t("common.select")} />
</SelectTrigger>
<SelectContent>
{priceListOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
<ProformaSectionCard
description={t("proformas.update.sections.content_description")}
title={t("proformas.update.sections.content")}
>
<ProformaHeaderFormGrid>
<ProformaFormFieldShell
error={errors.subject?.message}
htmlFor="subject"
label={t("proformas.fields.subject")}
span="full"
>
<Input
className="h-10"
disabled={isFieldLocked}
id="subject"
{...register("subject")}
/>
</ProformaFormFieldShell>
<ProformaFormFieldShell
error={errors.notes?.message}
htmlFor="notes"
label={t("proformas.fields.notes")}
span="full"
>
<Textarea
className="min-h-28 resize-y"
disabled={isFieldLocked}
id="notes"
{...register("notes")}
/>
</ProformaFormFieldShell>
</ProformaHeaderFormGrid>
</ProformaSectionCard>
</div>
);
};

View File

@ -1,12 +1,14 @@
import { SpainTaxCatalogProvider } from "@erp/core"; import { SpainTaxCatalogProvider } from "@erp/core";
import { ErrorAlert } from "@erp/core/components"; import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Spinner } from "@repo/shadcn-ui/components";
import { useMemo } from "react"; import { useMemo } from "react";
import { FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n"; import { useTranslation } from "../../../../i18n";
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller"; import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
import { ProformaUpdateEditor } from "../blocks"; import { ProformaUpdateEditorForm } from "../blocks";
import { ProformaUpdateSkeleton } from "../components"; import { ProformaUpdateSkeleton } from "../components";
export const ProformaUpdatePage = () => { export const ProformaUpdatePage = () => {
@ -38,57 +40,69 @@ export const ProformaUpdatePage = () => {
); );
} }
return ( if (!updateCtrl.proformaData)
<FormProvider {...updateCtrl.form}>
<ProformaUpdateEditor
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
proforma={updateCtrl.proforma}
/>
</FormProvider>
);
};
/*
if (isLoading) {
return <ProformaEditorSkeleton />;
}
if (isLoadError) {
return ( return (
<> <>
<AppContent> <AppContent>
<ErrorAlert <NotFoundCard
message={ message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
(loadError as Error)?.message ?? title={t("pages.update.notFoundTitle", "Proforma no encontrada")}
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar la proforma")}
/> />
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent> </AppContent>
</> </>
); );
}
// Monta el contexto aquí, así todo lo que esté dentro puede usar hooks
return ( return (
<ProformaProvider <UnsavedChangesProvider isDirty={updateCtrl.form.formState.isDirty}>
company_id={proformaData.company_id} <AppHeader className="bg-red-500 sticky top-0">
currency_code={proformaData.currency_code} <PageHeader
language_code={proformaData.language_code} backIcon
proforma_id={proforma_id!} description={t("pages.update.description")}
status={proformaData.status} rightSlot={
taxCatalog={taxCatalog} <UpdateCommitButtonGroup
> cancel={{
<ProformaUpdateComp proforma={proformaData} /> formId: updateCtrl.formId,
</ProformaProvider> to: "/customers/list",
disabled: updateCtrl.isUpdating,
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
submit={{
formId: updateCtrl.formId,
disabled: updateCtrl.isUpdating,
}}
/>
}
title={t("pages.update.title")}
/>
</AppHeader>
<AppContent>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{updateCtrl.isUpdateError && (
<ErrorAlert
message={
(updateCtrl.updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
/>
)}
{updateCtrl.isLoading && <Spinner />}
{!updateCtrl.isLoading && (
<FormProvider {...updateCtrl.form}>
<ProformaUpdateEditorForm
className="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 xl:px-8"
formId={updateCtrl.formId}
isSubmitting={updateCtrl.isUpdating}
onReset={updateCtrl.resetForm}
onSubmit={updateCtrl.onSubmit}
/>
</FormProvider>
)}
</AppContent>
</UnsavedChangesProvider>
); );
}; };
*/

View File

@ -1,119 +0,0 @@
import { PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useUrlParamId } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { useTranslation } from "../../../i18n";
import { useCustomerUpdatePageController } from "../../../update/controllers/use-customer-update.controller";
import {
CustomerEditForm,
CustomerEditorSkeleton,
ErrorAlert,
NotFoundCard,
} from "../../components";
export const CustomerUpdatePage = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
const {
form,
formId,
onSubmit,
resetForm,
customerData,
isLoading,
isLoadError,
loadError,
isUpdating,
isUpdateError,
updateError,
FormProvider,
} = useCustomerUpdatePageController(customerId, {});
if (isLoading) {
return <CustomerEditorSkeleton />;
}
if (isLoadError) {
return (
<>
<AppContent>
<ErrorAlert
message={
(loadError as Error)?.message ??
t("pages.update.loadErrorMsg", "Inténtalo de nuevo más tarde.")
}
title={t("pages.update.loadErrorTitle", "No se pudo cargar el cliente")}
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
</div>
</AppContent>
</>
);
}
if (!customerData)
return (
<>
<AppContent>
<NotFoundCard
message={t("pages.update.notFoundMsg", "Revisa el identificador o vuelve al listado.")}
title={t("pages.update.notFoundTitle", "Cliente no encontrado")}
/>
</AppContent>
</>
);
return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
<AppHeader>
<PageHeader
backIcon
description={t("pages.update.description")}
rightSlot={
<UpdateCommitButtonGroup
cancel={{
formId,
to: "/customers/list",
disabled: isUpdating,
}}
disabled={isUpdating}
isLoading={isUpdating}
onReset={resetForm}
submit={{
formId,
disabled: isUpdating,
}}
/>
}
title={t("pages.update.title")}
/>
</AppHeader>
<AppContent>
{/* Alerta de error de actualización (si ha fallado el último intento) */}
{isUpdateError && (
<ErrorAlert
message={
(updateError as Error)?.message ??
t("pages.update.errorMsg", "Revisa los datos e inténtalo de nuevo.")
}
title={t("pages.update.errorTitle", "No se pudo guardar los cambios")}
/>
)}
<FormProvider {...form}>
<CustomerEditForm
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" // para que el botón del header pueda hacer submit
formId={formId}
onSubmit={onSubmit}
/>
</FormProvider>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

@ -1,149 +1,101 @@
import { import {
Button,
Calendar,
Field, Field,
FieldDescription, FieldDescription,
FieldError, FieldError,
FormControl, InputGroup,
FormField, InputGroupAddon,
Popover, InputGroupInput,
PopoverContent,
PopoverTrigger,
} 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 { format, isValid, parseISO } from "date-fns"; import { CalendarIcon } from "lucide-react";
import { CalendarIcon, LockIcon } from "lucide-react"; import * as React from "react";
import React from "react";
import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form"; import { type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { FormFieldLabel } from "./form-field-label.tsx"; import { FormFieldLabel } from "./form-field-label.tsx";
import type { NativeInputProps } from "./types.ts";
type DatePickerFieldProps<TFormValues extends FieldValues> = { type DatePickerFieldProps<TFormValues extends FieldValues> = Omit<NativeInputProps, "name"> & {
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
label?: string; label?: string;
description?: string; description?: string;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
placeholder?: string;
orientation?: "vertical" | "horizontal" | "responsive"; orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string; inputClassName?: string;
formatDateFn?: (value: string) => string;
}; };
const parseFieldDate = (value?: string): Date | undefined => { export const DatePickerField = <TFormValues extends FieldValues>({
if (!value) return undefined;
const parsed = parseISO(value);
if (!isValid(parsed)) return undefined;
return parsed;
};
const toDateOnlyString = (date: Date): string => {
return format(date, "yyyy-MM-dd");
};
export function DatePickerField<TFormValues extends FieldValues>({
name, name,
label, label,
placeholder,
description, description,
disabled = false,
required = false, required = false,
readOnly = false, readOnly = false,
orientation = "vertical", orientation = "vertical",
className, className,
inputClassName, inputClassName,
formatDateFn = (value) => {
const parsed = parseFieldDate(value); ...inputRest
return parsed ? format(parsed, "dd/MM/yyyy") : value; }: DatePickerFieldProps<TFormValues>) => {
}, const { register, formState, getFieldState } = useFormContext<TFormValues>();
}: DatePickerFieldProps<TFormValues>) {
const triggerId = React.useId(); const inputId = React.useId();
const { control, formState } = useFormContext<TFormValues>(); const disabled = formState.isSubmitting || inputRest.disabled;
const isDisabled = Boolean(disabled || readOnly || formState.isSubmitting);
const presetProps = {
type: "date",
autoComplete: "off",
spellCheck: false,
};
const rightIcon = <CalendarIcon />;
// Obtener error del campo (tipado seguro)
const fieldError = getFieldState(name, formState).error;
return ( return (
<FormField <Field className={cn("gap-1", className)} data-invalid={!!fieldError} orientation={orientation}>
control={control} {label ? (
name={name} <FormFieldLabel htmlFor={inputId} required={required}>
render={({ field, fieldState }) => { {label}
const selectedDate = parseFieldDate(field.value); </FormFieldLabel>
const displayValue = field.value ? formatDateFn(field.value) : null; ) : null}
return ( <InputGroup
<Field className={cn(
className={cn("gap-1", className)} "bg-muted/50 font-medium",
data-invalid={fieldState.invalid} "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
orientation={orientation} "focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
> "placeholder:text-muted-foreground/50",
{label ? ( inputClassName
<FormFieldLabel htmlFor={triggerId} required={required}> )}
{label} >
</FormFieldLabel> <InputGroupInput
) : null} {...presetProps}
{...inputRest}
{...register(name)}
aria-invalid={!!fieldError}
className="placeholder:text-muted-foreground/50"
disabled={disabled}
id={inputId}
readOnly={readOnly}
required={required}
/>
<Popover> {rightIcon && <InputGroupAddon aria-hidden="true">{rightIcon}</InputGroupAddon>}
<PopoverTrigger asChild> </InputGroup>
<FormControl>
<Button
aria-invalid={fieldState.invalid}
aria-required={required || undefined}
className={cn(
"h-9 w-full justify-start bg-muted/50 text-left font-medium",
"hover:border-ring/60",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
!displayValue && "text-muted-foreground",
inputClassName
)}
disabled={isDisabled}
id={triggerId}
type="button"
variant="outline"
>
{readOnly ? (
<LockIcon aria-hidden="true" className="mr-2 h-4 w-4 opacity-70" />
) : (
<CalendarIcon aria-hidden="true" className="mr-2 h-4 w-4" />
)}
<span className="truncate">{displayValue ?? placeholder ?? "Select date"}</span> {description ? (
</Button> <FieldDescription>{description}</FieldDescription>
</FormControl> ) : (
</PopoverTrigger> <div aria-hidden="true" className="min-h-5" />
)}
{isDisabled ? null : ( <FieldError errors={[fieldError]} />
<PopoverContent align="start" className="w-auto p-0"> </Field>
<Calendar
initialFocus
mode="single"
onSelect={(date) => {
field.onChange(date ? toDateOnlyString(date) : "");
field.onBlur();
}}
selected={selectedDate}
/>
</PopoverContent>
)}
</Popover>
{description ? (
<FieldDescription>{description}</FieldDescription>
) : (
<div aria-hidden="true" className="min-h-5" />
)}
<FieldError errors={[fieldState.error]} />
</Field>
);
}}
/>
); );
} };

View File

@ -1,87 +1,87 @@
import { Button, Input } from '@repo/shadcn-ui/components'; import { Button, Input } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils"; import { cn } from "@repo/shadcn-ui/lib/utils";
// DateInputField.tsx // DateInputField.tsx
import { CalendarIcon, LockIcon, XIcon } from "lucide-react"; import { CalendarIcon, LockIcon, XIcon } from "lucide-react";
export function DateInputField({ export function DateInputField({
id, id,
value, value,
onChange, onChange,
onBlurConfirm, onBlurConfirm,
onClear, onClear,
placeholder, placeholder,
disabled, disabled,
readOnly, readOnly,
required, required,
hasError, hasError,
describedBy, describedBy,
onOpenRequest, onOpenRequest,
triggerButton, // ← PopoverTrigger se inyecta aquí triggerButton, // ← PopoverTrigger se inyecta aquí
}: { }: {
id: string; id: string;
value: string; value: string;
onChange: (val: string) => void; onChange: (val: string) => void;
onBlurConfirm: () => void; // valida+normaliza en blur/Enter onBlurConfirm: () => void; // valida+normaliza en blur/Enter
onClear: () => void; onClear: () => void;
placeholder?: string; placeholder?: string;
disabled: boolean; disabled: boolean;
readOnly: boolean; readOnly: boolean;
required: boolean; required: boolean;
hasError: boolean; hasError: boolean;
describedBy?: string; describedBy?: string;
onOpenRequest?: () => void; onOpenRequest?: () => void;
triggerButton?: React.ReactNode; triggerButton?: React.ReactNode;
}) { }) {
return ( return (
<div className="relative"> <div className="relative">
<Input <Input
id={id} aria-describedby={describedBy}
type="text" aria-invalid={hasError || undefined}
pattern="\d{2}/\d{2}/\d{4}" className={cn(
value={value} "text-ellipsis pr-12",
onChange={(e) => onChange(e.target.value)} disabled && "bg-muted text-muted-foreground cursor-not-allowed",
onBlur={onBlurConfirm} readOnly && "bg-muted text-foreground cursor-default",
onKeyDown={(e) => { !(disabled || readOnly) && "bg-white text-foreground",
if (e.key === "Enter") { hasError && "border-destructive ring-destructive"
e.preventDefault(); )}
onBlurConfirm(); disabled={disabled}
} id={id}
if ((e.altKey || e.metaKey) && e.key === "ArrowDown") { onBlur={onBlurConfirm}
onOpenRequest?.(); onChange={(e) => onChange(e.target.value)}
} onKeyDown={(e) => {
}} if (e.key === "Enter") {
readOnly={readOnly} e.preventDefault();
disabled={disabled} onBlurConfirm();
aria-invalid={hasError || undefined} }
aria-describedby={describedBy} if ((e.altKey || e.metaKey) && e.key === "ArrowDown") {
className={cn( onOpenRequest?.();
"text-ellipsis pr-12", }
disabled && "bg-muted text-muted-foreground cursor-not-allowed", }}
readOnly && "bg-muted text-foreground cursor-default", pattern="\d{2}/\d{2}/\d{4}"
!disabled && !readOnly && "bg-white text-foreground", placeholder={placeholder}
hasError && "border-destructive ring-destructive" readOnly={readOnly}
)} type="text"
placeholder={placeholder} value={value}
/> />
<div className="absolute inset-y-0 right-2 flex items-center gap-2 pr-1"> <div className="absolute inset-y-0 right-2 flex items-center gap-2 pr-1">
{!readOnly && !required && value && ( {!(readOnly || required) && value && (
<Button <Button
variant={'link'} aria-label="Clear date"
type='button' className="text-muted-foreground hover:text-destructive transition cursor-pointer -mr-3"
size={"icon-sm"} onClick={onClear}
onClick={onClear} size={"icon-sm"}
aria-label="Clear date" type="button"
className="text-muted-foreground hover:text-destructive transition cursor-pointer -mr-3" variant={"link"}
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
</Button> </Button>
)} )}
{readOnly ? ( {readOnly ? (
<LockIcon className="size-4" /> <LockIcon className="size-4" />
) : ( ) : (
triggerButton ?? <CalendarIcon className="size-4" aria-hidden="true" /> (triggerButton ?? <CalendarIcon aria-hidden="true" className="size-4" />)
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@ -1,205 +1,231 @@
import { import {
Button, Button,
Calendar, type Calendar,
Field, Field,
FieldDescription, FieldDescription,
FieldError, FieldError,
FieldLabel,
Popover, Popover,
PopoverTrigger PopoverTrigger,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { CalendarIcon } 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 { useCallback, useEffect, useMemo, useState } from "react"; import { CalendarIcon } from "lucide-react";
import { ControllerFieldState, ControllerRenderProps, FieldValues, Path, UseFormStateReturn } from "react-hook-form"; import * as React from "react";
import type {
ControllerFieldState,
ControllerRenderProps,
FieldValues,
Path,
} from "react-hook-form";
import { useTranslation } from "../../../locales/i18n.ts"; import { useTranslation } from "../../../locales/i18n.ts";
import { DateInputField } from './date-input-field.tsx'; import { FormFieldLabel } from "../form-field-label.tsx";
import { DatePopoverCalendar } from './date-popover-calendar.tsx';
import { DateInputField } from "./date-input-field.tsx";
import { DatePopoverCalendar } from "./date-popover-calendar.tsx";
export type SUICalendarProps = Omit<React.ComponentProps< export type SUICalendarProps = Omit<
typeof Calendar>, "select" | "onSelect"> React.ComponentProps<typeof Calendar>,
"mode" | "selected" | "onSelect"
>;
type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = SUICalendarProps & { type DatePickerInputCompProps<TFormValues extends FieldValues = FieldValues> = SUICalendarProps & {
field: ControllerRenderProps<TFormValues, Path<TFormValues>>; field: ControllerRenderProps<TFormValues, Path<TFormValues>>;
fieldState: ControllerFieldState; fieldState: ControllerFieldState;
formState: UseFormStateReturn<TFormValues>;
displayDateFormat: string; // e.g. "dd/MM/yyyy" inputId: string;
parseDateFormat: string; // e.g. "yyyy/MM/dd"
label?: string; label?: string;
placeholder?: string;
description?: string; description?: string;
placeholder?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
invalid?: boolean;
orientation?: "vertical" | "horizontal" | "responsive", orientation?: "vertical" | "horizontal" | "responsive";
className?: string; className?: string;
inputClassName?: string;
displayDateFormat: string;
valueDateFormat: string;
}; };
export function DatePickerInputComp<TFormValues extends FieldValues = FieldValues>({ export function DatePickerInputComp<TFormValues extends FieldValues = FieldValues>({
field, field,
fieldState, fieldState,
formState, inputId,
parseDateFormat,
displayDateFormat,
label, label,
placeholder,
description, description,
placeholder,
disabled = false, disabled = false,
required = false, required = false,
readOnly = false, readOnly = false,
invalid = false,
orientation = "vertical", orientation = "vertical",
className, className,
inputClassName,
displayDateFormat,
valueDateFormat,
...calendarProps ...calendarProps
}: DatePickerInputCompProps<TFormValues>) { }: DatePickerInputCompProps<TFormValues>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const parsedDate = useMemo(() => { const [open, setOpen] = React.useState(false);
if (!field.value) return null; const [displayValue, setDisplayValue] = React.useState("");
const d = parse(field.value, parseDateFormat, new Date()); const [localError, setLocalError] = React.useState<string | null>(null);
return isValid(d) ? d : null;
}, [field.value, parseDateFormat]);
useEffect(() => { const descriptionId = description ? `${inputId}-description` : undefined;
const errorId = `${inputId}-error`;
const popoverId = `${inputId}-popover`;
const parsedDate = React.useMemo(() => {
if (!field.value || typeof field.value !== "string") {
return undefined;
}
const parsed = parse(field.value, valueDateFormat, new Date());
return isValid(parsed) ? parsed : undefined;
}, [field.value, valueDateFormat]);
React.useEffect(() => {
setDisplayValue(parsedDate ? format(parsedDate, displayDateFormat) : ""); setDisplayValue(parsedDate ? format(parsedDate, displayDateFormat) : "");
}, [parsedDate, displayDateFormat]); }, [parsedDate, displayDateFormat]);
const handleClear = useCallback(() => { const handleClear = React.useCallback(() => {
field.onChange(""); field.onChange("");
field.onBlur();
setDisplayValue(""); setDisplayValue("");
setLocalError(null); setLocalError(null);
}, [field]); }, [field]);
const validateAndSet = useCallback(() => { const confirmInputValue = React.useCallback(() => {
const trimmed = displayValue.trim(); const trimmedValue = displayValue.trim();
if (!trimmed) {
if (!trimmedValue) {
handleClear(); handleClear();
return; return;
} }
const d = parse(trimmed, displayDateFormat, new Date());
if (isValid(d)) { const parsed = parse(trimmedValue, displayDateFormat, new Date());
field.onChange(format(d, parseDateFormat));
setDisplayValue(format(d, displayDateFormat)); if (!isValid(parsed)) {
setLocalError(null);
} else {
setLocalError(t("components.date_picker_input_field.invalid_date")); setLocalError(t("components.date_picker_input_field.invalid_date"));
field.onBlur();
return;
} }
}, [displayValue, displayDateFormat, parseDateFormat, field, t, handleClear]);
field.onChange(format(parsed, valueDateFormat));
field.onBlur();
setDisplayValue(format(parsed, displayDateFormat));
setLocalError(null);
}, [displayValue, displayDateFormat, field, handleClear, t, valueDateFormat]);
const handleSelectDate = React.useCallback(
(date: Date | undefined) => {
if (!date) {
return;
}
field.onChange(format(date, valueDateFormat));
field.onBlur();
setDisplayValue(format(date, displayDateFormat));
setLocalError(null);
setOpen(false);
},
[displayDateFormat, field, valueDateFormat]
);
const handleSelectToday = React.useCallback(() => {
const today = new Date();
field.onChange(format(today, valueDateFormat));
field.onBlur();
setDisplayValue(format(today, displayDateFormat));
setLocalError(null);
setOpen(false);
}, [displayDateFormat, field, valueDateFormat]);
const hasError = Boolean(localError || fieldState.error); const hasError = Boolean(localError || fieldState.error);
const describedById = description ? `${field.name}-desc` : undefined; const describedBy =
const errorId = hasError ? `${field.name}-err` : undefined; [descriptionId, hasError ? errorId : undefined].filter(Boolean).join(" ") || undefined;
const popoverId = `${field.name}-popover`;
return ( return (
<Field data-invalid={invalid} orientation={orientation} className={cn("gap-1", className)}> <Field className={cn("gap-1", className)} data-invalid={hasError} orientation={orientation}>
<div className="flex justify-between gap-2 overflow-hidden"> {label ? (
<div className="flex items-center gap-2 flex-nowrap"> <FormFieldLabel htmlFor={inputId} required={required}>
<FieldLabel htmlFor={field.name} className={cn("m-0 text-xs text-muted-foreground text-nowrap text-ellipsis", disabled && "text-muted-foreground")}> {label}
{label} </FormFieldLabel>
</FieldLabel> ) : null}
{required && <span className="text-xs text-destructive">{t("common.required")}</span>}
</div>
{fieldState.isDirty && <span className="text-[10px] text-primary text-ellipsis">{t("common.modified")}</span>}
</div>
<div className={cn(inputClassName)}>
<Popover modal={true} open={open} onOpenChange={setOpen}> <DateInputField
<PopoverTrigger asChild> describedBy={describedBy}
disabled={disabled}
<DateInputField hasError={hasError}
id={field.name} id={inputId}
value={displayValue} onBlurConfirm={confirmInputValue}
onChange={(v) => { onChange={(value) => {
setDisplayValue(v); setDisplayValue(value);
setLocalError(null); setLocalError(null);
}} }}
onBlurConfirm={validateAndSet} onClear={handleClear}
onClear={handleClear} placeholder={placeholder}
placeholder={placeholder} readOnly={readOnly}
disabled={disabled} required={required}
readOnly={readOnly} triggerButton={
required={required} disabled || readOnly ? null : (
hasError={hasError} <Popover onOpenChange={setOpen} open={open}>
describedBy={[describedById, errorId].filter(Boolean).join(" ") || undefined}
onOpenRequest={() => setOpen(true)}
triggerButton={
!readOnly && !disabled && (
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant={'link'}
type='button'
size={"icon-sm"}
aria-label={t("components.date_picker_input_field.open_calendar")}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={popoverId} aria-controls={popoverId}
onClick={() => setOpen((o) => !o)} aria-expanded={open}
className="text-muted-foreground transition cursor-pointer -mr-3" aria-haspopup="dialog"
aria-label={t("components.date_picker_input_field.open_calendar")}
className="text-muted-foreground"
size="icon-sm"
type="button"
variant="link"
> >
<CalendarIcon className="size-4" /> <CalendarIcon className="size-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
)
}
/>
</PopoverTrigger> <DatePopoverCalendar
{...calendarProps}
contentId={popoverId}
description={description}
numberOfMonths={2}
onClose={() => setOpen(false)}
onSelect={handleSelectDate}
onToday={handleSelectToday}
selectedDate={parsedDate}
title={label}
/>
</Popover>
)
}
value={displayValue}
/>
</div>
{!disabled && !readOnly && ( {description ? (
<DatePopoverCalendar <FieldDescription id={descriptionId}>{description}</FieldDescription>
contentId={popoverId} ) : (
label={label} <div aria-hidden="true" className="min-h-5" />
description={description}
parsedDate={parsedDate}
onSelect={(date) => {
if (!date) return;
field.onChange(format(date, parseDateFormat));
setDisplayValue(format(date, displayDateFormat));
setOpen(false);
}}
onToday={() => {
const today = new Date();
field.onChange(format(today, parseDateFormat));
setDisplayValue(format(today, displayDateFormat));
setOpen(false);
}}
onClose={() => setOpen(false)}
{...calendarProps}
/>
)}
</Popover>
{false && (
<div className='mt-1 flex items-start justify-between'>
<FieldDescription
id={describedById}
className={cn("text-xs truncate", !description && "invisible")}
>
{description || "\u00A0"}
</FieldDescription>
</div>
)} )}
{localError ? (
<p className="text-destructive text-sm" id={errorId}>
{localError}
</p>
) : null}
<FieldError errors={[fieldState.error]} /> <FieldError
errors={localError ? [] : [fieldState.error]}
id={localError ? undefined : errorId}
/>
</Field> </Field>
); );
} }

View File

@ -1,44 +1,58 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import * as React from "react";
import { Controller, type FieldPath, type FieldValues, useFormContext } from "react-hook-form";
import { Control, Controller, FieldPath, FieldValues } from "react-hook-form"; import { DatePickerInputComp, type 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>;
name: FieldPath<TFormValues>; name: FieldPath<TFormValues>;
label?: string; label?: string;
placeholder?: string;
description?: string; description?: string;
placeholder?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
displayDateFormat?: string; // e.g. "dd-MM-yyyy" displayDateFormat?: string;
parseDateFormat?: string; // e.g. "yyyy-MM-dd" valueDateFormat?: string;
orientation?: "vertical" | "horizontal" | "responsive",
orientation?: "vertical" | "horizontal" | "responsive";
className?: string;
inputClassName?: string;
}; };
export function DatePickerInputField<TFormValues extends FieldValues>({ export function DatePickerInputField<TFormValues extends FieldValues>({
control,
name, name,
displayDateFormat = "dd-MM-yyyy", displayDateFormat = "dd/MM/yyyy",
parseDateFormat = "yyyy-MM-dd", valueDateFormat = "yyyy-MM-dd",
inputClassName,
...props ...props
}: DatePickerInputFieldProps<TFormValues>) { }: DatePickerInputFieldProps<TFormValues>) {
const { control } = useFormContext<TFormValues>();
const inputId = React.useId();
return ( return (
<Controller <Controller
control={control} control={control}
name={name} name={name}
render={({ field, fieldState, formState }) => ( render={({ field, fieldState }) => (
<DatePickerInputComp <DatePickerInputComp
{...props}
displayDateFormat={displayDateFormat}
field={field} field={field}
fieldState={fieldState} fieldState={fieldState}
formState={formState} inputClassName={cn(
"bg-muted/50 font-medium",
displayDateFormat={displayDateFormat} "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
parseDateFormat={parseDateFormat} "focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
{...props} "placeholder:text-muted-foreground/50",
inputClassName
)}
inputId={inputId}
valueDateFormat={valueDateFormat}
/> />
)} )}
/> />

View File

@ -1,76 +1,67 @@
// DatePopoverCalendar.tsx // DatePopoverCalendar.tsx
import { import {
Button, Button,
Calendar, Calendar,
Card, Card,
CardAction, CardAction,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
PopoverContent, PopoverContent,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../../locales/i18n.ts"; import { useTranslation } from "../../../locales/i18n.ts";
export function DatePopoverCalendar({ export function DatePopoverCalendar({
contentId, contentId,
label, label,
description, description,
parsedDate, parsedDate,
onSelect, onSelect,
onToday, onToday,
onClose, onClose,
...calendarProps ...calendarProps
}: { }: {
contentId: string; contentId: string;
label?: string; label?: string;
description?: string; description?: string;
parsedDate: Date | null; parsedDate: Date | null;
onSelect: (date: Date | undefined) => void; onSelect: (date: Date | undefined) => void;
onToday: () => void; onToday: () => void;
onClose: () => void; onClose: () => void;
} & Omit<React.ComponentProps<typeof Calendar>, "mode" | "selected" | "onSelect">) { } & Omit<React.ComponentProps<typeof Calendar>, "mode" | "selected" | "onSelect">) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<PopoverContent id={contentId} className="w-auto p-0"> <PopoverContent className="w-auto p-0" id={contentId}>
<Card className="border-none shadow-none"> <Card className="border-none shadow-none">
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle>{label}</CardTitle> <CardTitle>{label}</CardTitle>
<CardDescription>{description || "\u00A0"}</CardDescription> <CardDescription>{description || "\u00A0"}</CardDescription>
<CardAction> <CardAction>
<Button <Button onClick={onToday} size="sm" variant="outline">
size="sm" {t("components.date_picker_input_field.today")}
variant="outline" </Button>
onClick={onToday} </CardAction>
> </CardHeader>
{t("components.date_picker_input_field.today")}
</Button>
</CardAction>
</CardHeader> <CardContent className="px-0">
<Calendar
defaultMonth={parsedDate ?? undefined}
mode="single"
numberOfMonths={2}
onSelect={onSelect}
selected={parsedDate ?? undefined}
/>
</CardContent>
<CardContent className='px-0'> <CardFooter className="mx-auto">
<Calendar <Button onClick={onClose} size="sm" variant="outline">
selected={parsedDate ?? undefined} {t("components.date_picker_input_field.close")}
defaultMonth={parsedDate ?? undefined} </Button>
onSelect={onSelect} </CardFooter>
mode='single' </Card>
numberOfMonths={2} </PopoverContent>
);
/>
</CardContent>
<CardFooter className='mx-auto'>
<Button
size="sm"
variant="outline"
onClick={onClose}>
{t("components.date_picker_input_field.close")}
</Button>
</CardFooter>
</Card>
</PopoverContent >
);
} }

View File

@ -157,9 +157,11 @@ export const MultiSelectField = <T extends FieldValues>({
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
aria-required={required || undefined} aria-required={required || undefined}
className={cn( className={cn(
"min-h-10 h-auto w-full justify-between bg-muted/50 p-1 text-left font-medium", "min-h-10 h-auto w-full justify-between text-left ",
"hover:border-ring/60", "bg-muted/50 p-1 font-medium",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50",
inputClassName inputClassName
)} )}
disabled={isDisabled} disabled={isDisabled}

View File

@ -89,8 +89,9 @@ export function SelectField<TFormValues extends FieldValues>({
aria-required={required} aria-required={required}
className={cn( className={cn(
"bg-muted/50 font-medium", "bg-muted/50 font-medium",
"hover:border-ring/60", "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50",
inputClassName inputClassName
)} )}
id={triggerId} id={triggerId}

View File

@ -66,8 +66,8 @@ export function TextAreaField<TFormValues extends FieldValues>({
aria-invalid={!!fieldError} aria-invalid={!!fieldError}
className={cn( className={cn(
"bg-muted/50 font-medium", "bg-muted/50 font-medium",
"hover:border-ring/60", "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50", "placeholder:text-muted-foreground/50",
inputClassName inputClassName
)} )}

View File

@ -72,8 +72,9 @@ export const TextField = <TFormValues extends FieldValues>({
<InputGroup <InputGroup
className={cn( className={cn(
"bg-muted/50 font-medium", "bg-muted/50 font-medium",
"hover:border-ring/60", "hover:border-ring hover:ring-ring/20 hover:ring-[3px]",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/60 focus-visible:ring-[3px]",
"placeholder:text-muted-foreground/50",
inputClassName inputClassName
)} )}
> >

View File

@ -16,7 +16,7 @@ export const AppLayout = () => {
> >
<AppSidebar className="bg-sidebar" variant="inset" /> <AppSidebar className="bg-sidebar" variant="inset" />
{/* Aquí está el MAIN */} {/* Aquí está el MAIN */}
<SidebarInset className="app-main bg-muted"> <SidebarInset className="app-main bg-muted overflow-hidden">
<Outlet /> <Outlet />
</SidebarInset> </SidebarInset>
</SidebarProvider> </SidebarProvider>

View File

@ -62,8 +62,10 @@
"date_picker_input_field": { "date_picker_input_field": {
"invalid_date": "Fecha inválida", "invalid_date": "Fecha inválida",
"clear_date": "Limpiar fecha", "clear_date": "Limpiar fecha",
"open_calendar": "Abrir calendario",
"today": "Hoy", "today": "Hoy",
"close": "Cerrar" "close": "Cerrar",
"select_date": "Seleccionar fecha"
} }
} }
} }

View File

@ -133,7 +133,7 @@
--accent-foreground: oklch(0.97 0 0); --accent-foreground: oklch(0.97 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.822 0 0);
--ring: oklch(0.575 0.1533 256.4357); /* oklch(0.708 0 0); */ --ring: oklch(0.575 0.1533 256.4357); /* oklch(0.708 0 0); */
--chart-1: oklch(0.809 0.105 251.813); --chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815); --chart-2: oklch(0.623 0.214 259.815);
@ -149,7 +149,7 @@
--sidebar-border: oklch(0.6427 0.1407 253.94); --sidebar-border: oklch(0.6427 0.1407 253.94);
--sidebar-ring: oklch(1 0 0); --sidebar-ring: oklch(1 0 0);
--radius: 0.325rem; --radius: 0.625rem;
--shadow-x: 0; --shadow-x: 0;
--shadow-y: 1px; --shadow-y: 1px;
--shadow-blur: 3px; --shadow-blur: 3px;
@ -200,7 +200,7 @@
--sidebar-accent-foreground: oklch(0.9851 0 0); --sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(1 0 0); --sidebar-border: oklch(1 0 0);
--sidebar-ring: oklch(0.4915 0.2776 263.8724); --sidebar-ring: oklch(0.4915 0.2776 263.8724);
--radius: 0.325rem; --radius: 0.625rem;
--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);