Crear una proforma

This commit is contained in:
David Arranz 2026-06-29 11:13:10 +02:00
parent 95fe0127a3
commit 3c5fd7c57a
34 changed files with 863 additions and 119 deletions

View File

@ -10,9 +10,9 @@ const ProformasListPage = lazy(() =>
import("./proformas/list").then((m) => ({ default: m.ListProformasPage }))
);
/*const ProformasCreatePage = lazy(() =>
const ProformasCreatePage = lazy(() =>
import("./proformas/create").then((m) => ({ default: m.ProformaCreatePage }))
);*/
);
const ProformaUpdatePage = lazy(() =>
import("./proformas/update").then((m) => ({ default: m.ProformaUpdatePage }))
@ -51,6 +51,19 @@ export const CustomerInvoiceRoutes = (params: ModuleClientParams): RouteObject[]
],
},
{
path: "proformas/create",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: (
<ProformaLayout>
<ProformasCreatePage />
</ProformaLayout>
),
},
{
path: "proformas/:id/edit",
handle: {

View File

@ -0,0 +1,3 @@
export * from "./proforma-create-form.entity";
export * from "./proforma-create-form.schema";
export * from "./proforma-create-form-default.entity";

View File

@ -0,0 +1,37 @@
/**
* ProformaCreateForm representa el shape de datos del formulario de creación de proforma.
* Es decir, los campos que se muestran en el formulario y que el usuario puede editar.
*
* Este shape es específico para la UI y no tiene por qué coincidir
* con el shape del dominio ni con el de la API.
*
* Debe cumplir las siguientes reglas:
* - nombres en camelCase
* - tipos orientados a UI/form
* - sin campos de solo lectura que no se editen
* - sin shape DTO
* - sin detalles impuestos por el widget
*/
import type { ProformaItemForm } from "../../shared/entities";
export interface ProformaCreateForm {
series: string;
invoiceDate: string;
operationDate: string;
customerId: string;
reference: string;
notes: string;
languageCode: string;
currencyCode: string;
globalDiscountPercentage: number;
paymentMethod: string;
items: ProformaItemForm[];
}

View File

@ -0,0 +1,48 @@
import { z } from "zod/v4";
/**
* Este esquema es para validar los datos del formulario de creación de cliente.
* No tiene por qué coincidir con el shape de la entidad ni con el de la API.
* Solo define los campos que se muestran en el formulario y sus validaciones.
*
* Reglas:
* - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase())
* - nombres en camelCase
* - tipos orientados a UI/form
* - sin campos de solo lectura que no se editen
* - sin shape DTO
* - sin detalles impuestos por el widget
*/
export const CustomerCreateFormSchema = z.object({
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(),
tin: z.string(),
defaultTaxes: z.array(z.string()),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
primaryEmail: z.email("Email inválido"),
secondaryEmail: z.email("Email inválido"),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
fax: z.string(),
website: z.url("URL inválida"),
legalRecord: z.string(),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),
});

View File

@ -0,0 +1 @@
export * from "./ui";

View File

@ -0,0 +1 @@
export * from "./pages";

View File

@ -0,0 +1 @@
export * from "./proforma-create-page";

View File

@ -0,0 +1,48 @@
import { PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
export const ProformaCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<UnsavedChangesProvider isDirty={true}>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<AppContent>
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("pages.create.title")}</h2>
<p className="text-muted-foreground">{t("pages.create.description")}</p>
</div>
<div className="flex items-center justify-end mb-4">
<Button className="cursor-pointer" onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-4">hola</div>
</AppContent>
</UnsavedChangesProvider>
);
};

View File

@ -0,0 +1,27 @@
import { PercentageDTOHelper } from "@erp/core";
import type { CreateProformaParams } from "../../shared/api";
import type { ProformaCreateForm } from "../entities";
export const buildCreateProformaParams = (
formData: ProformaCreateForm,
proformaId: string
): CreateProformaParams => {
return {
id: proformaId,
data: {
id: proformaId,
invoice_number: "",
series: formData.series.trim() || null,
invoice_date: formData.invoiceDate,
customer_id: formData.customerId,
language_code: formData.languageCode,
currency_code: formData.currencyCode,
global_discount_percentage: PercentageDTOHelper.fromNumber(0, 2),
payment_method_id: null,
payment_term_id: null,
tax_regime_code: null,
items: [],
},
};
};

View File

@ -0,0 +1 @@
export * from "./build-create-proforma-params";

View File

@ -0,0 +1,2 @@
export * from "./use-create-proforma-controller";
export * from "./use-create-proforma-page-controller";

View File

@ -0,0 +1,158 @@
import { applyValidationErrorCollection } from "@erp/core";
import { useHookForm } from "@erp/core/hooks";
import type { CustomerSelectionOption } from "@erp/customers";
import {
UniqueID,
type ValidationErrorCollection,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import {
focusFirstInputFormError,
showErrorToast,
showSuccessToast,
showWarningToast,
} from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo, useState } from "react";
import type { FieldErrors } from "react-hook-form";
import type { Proforma } from "../../shared";
import { useProformaCreateMutation, useProformasListQuery } from "../../shared/hooks";
import { buildCreateProformaParams } from "../adapters";
import { type ProformaCreateForm, ProformaCreateFormSchema } from "../entities";
import { buildProformaCreateDefault, buildProformaSeriesOptions } from "../utils";
export interface UseCreateProformaControllerOptions {
onCreated?(proforma: Proforma): void;
}
const normalizeSubmitError = (error: unknown): Error | ValidationErrorCollection => {
if (isValidationErrorCollection(error)) {
return error satisfies ValidationErrorCollection;
}
if (error instanceof Error) {
return error satisfies Error;
}
return new Error("Unknown error");
};
export const useCreateProformaController = (options?: UseCreateProformaControllerOptions) => {
const formId = useId();
const [selectedCustomer, setSelectedCustomer] = useState<CustomerSelectionOption | null>(null);
const {
mutateAsync,
isPending: isCreating,
isError: isCreateError,
error: createError,
} = useProformaCreateMutation();
const seriesQuery = useProformasListQuery({
criteria: {
pageNumber: 1,
pageSize: 100,
},
});
const initialValues = useMemo<ProformaCreateForm>(() => buildProformaCreateDefault(), []);
const form = useHookForm<ProformaCreateForm>({
resolverSchema: ProformaCreateFormSchema,
initialValues,
disabled: isCreating,
});
const seriesOptions = useMemo(
() => buildProformaSeriesOptions(seriesQuery.data),
[seriesQuery.data]
);
useEffect(() => {
if (seriesOptions.length !== 1) return;
if (form.getValues("series")) return;
form.setValue("series", seriesOptions[0].value, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: true,
});
}, [form, seriesOptions]);
const setCustomer = (customer: CustomerSelectionOption) => {
setSelectedCustomer(customer);
form.setValue("customerId", customer.id, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
};
const clearCustomer = () => {
setSelectedCustomer(null);
form.setValue("customerId", "", {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
});
};
const submitHandler = form.handleSubmit(
async (formData: ProformaCreateForm) => {
const proformaId = UniqueID.generateNewID().toString();
const params = buildCreateProformaParams(formData, proformaId);
try {
const created = await mutateAsync(params);
form.reset(
{
...buildProformaCreateDefault(),
series: formData.series,
},
{ keepDirty: false }
);
setSelectedCustomer(null);
showSuccessToast("Proforma creada", "Proforma creada como borrador.");
options?.onCreated?.(created);
} catch (error: unknown) {
const normalizedError = normalizeSubmitError(error);
if (isValidationErrorCollection(normalizedError)) {
applyValidationErrorCollection(form, normalizedError);
focusFirstInputFormError(form);
showWarningToast("Revisa los campos", "Hay errores de validación en el formulario.");
return;
}
showErrorToast("No se pudo crear la proforma", normalizedError.message);
}
},
(_errors: FieldErrors<ProformaCreateForm>) => {
focusFirstInputFormError(form);
showWarningToast("Revisa los campos", "Hay errores de validación en el formulario.");
}
);
const onSubmit = (event: React.SubmitEvent<HTMLFormElement>) => {
event.stopPropagation();
submitHandler(event);
};
return {
formId,
form,
onSubmit,
isCreating,
isCreateError,
createError,
selectedCustomer,
setCustomer,
clearCustomer,
seriesOptions,
isLoading: seriesQuery.isLoading,
isLoadError: seriesQuery.isError,
loadError: seriesQuery.error,
};
};

View File

@ -0,0 +1,35 @@
import { useCustomerSelectionFlow } from "@erp/customers/common";
import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom";
import { useCreateProformaController } from "./use-create-proforma-controller";
export const useCreateProformaPageController = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") ?? "/proformas";
const createCtrl = useCreateProformaController({
onCreated: (created) => {
navigate({
pathname: `/proformas/${created.id}/edit`,
search: createSearchParams({
returnTo,
}).toString(),
});
},
});
const selectCustomerCtrl = useCustomerSelectionFlow({
defaultLanguageCode: createCtrl.form.watch("languageCode"),
defaultCurrencyCode: createCtrl.form.watch("currencyCode"),
onCustomerSelected: (customer) => {
createCtrl.setCustomer(customer);
},
});
return {
createCtrl,
selectCustomerCtrl,
returnTo,
};
};

View File

@ -1,3 +1,2 @@
export * from "./proforma-create-form.entity";
export * from "./proforma-create-form.schema";
export * from "./proforma-create-form-default.entity";

View File

@ -1,37 +1,7 @@
/**
* ProformaCreateForm representa el shape de datos del formulario de creación de proforma.
* Es decir, los campos que se muestran en el formulario y que el usuario puede editar.
*
* Este shape es específico para la UI y no tiene por qué coincidir
* con el shape del dominio ni con el de la API.
*
* Debe cumplir las siguientes reglas:
* - nombres en camelCase
* - tipos orientados a UI/form
* - sin campos de solo lectura que no se editen
* - sin shape DTO
* - sin detalles impuestos por el widget
*/
import type { ProformaItemForm } from "../../shared/entities";
export interface ProformaCreateForm {
series: string;
invoiceDate: string;
operationDate: string;
customerId: string;
reference: string;
notes: string;
invoiceDate: string;
series: string;
languageCode: string;
currencyCode: string;
globalDiscountPercentage: number;
paymentMethod: string;
items: ProformaItemForm[];
}

View File

@ -1,48 +1,16 @@
import { z } from "zod/v4";
/**
* Este esquema es para validar los datos del formulario de creación de cliente.
* No tiene por qué coincidir con el shape de la entidad ni con el de la API.
* Solo define los campos que se muestran en el formulario y sus validaciones.
*
* Reglas:
* - no meter transformaciones silenciosas raras en el esquema (ej: .toUpperCase())
* - nombres en camelCase
* - tipos orientados a UI/form
* - sin campos de solo lectura que no se editen
* - sin shape DTO
* - sin detalles impuestos por el widget
*/
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const CustomerCreateFormSchema = z.object({
reference: z.string(),
isCompany: z.boolean(),
name: z.string().min(1, "El nombre es obligatorio"),
tradeName: z.string(),
tin: z.string(),
defaultTaxes: z.array(z.string()),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postalCode: z.string(),
country: z.string().min(1, "El país es obligatorio"),
primaryEmail: z.email("Email inválido"),
secondaryEmail: z.email("Email inválido"),
primaryPhone: z.string(),
secondaryPhone: z.string(),
primaryMobile: z.string(),
secondaryMobile: z.string(),
fax: z.string(),
website: z.url("URL inválida"),
legalRecord: z.string(),
languageCode: z.string().min(1, "El idioma es obligatorio"),
currencyCode: z.string().min(1, "La moneda es obligatoria"),
export const ProformaCreateFormSchema = z.object({
customerId: z.string().min(1, "Selecciona un cliente."),
invoiceDate: z
.string()
.min(1, "Introduce una fecha válida.")
.regex(ISO_DATE_PATTERN, "Introduce una fecha válida."),
series: z.string(),
languageCode: z.string().min(1),
currencyCode: z.string().min(1),
});
export type ProformaCreateFormSchemaType = z.infer<typeof ProformaCreateFormSchema>;

View File

@ -0,0 +1,6 @@
export * from "./proforma-create-auto-applied-info";
export * from "./proforma-create-customer-field";
export * from "./proforma-create-empty-lines-card";
export * from "./proforma-create-form";
export * from "./proforma-create-header";
export * from "./proforma-create-initial-info-card";

View File

@ -0,0 +1,57 @@
import { AlertCircleIcon, BanknoteIcon, CircleDollarSignIcon, ReceiptTextIcon } from "lucide-react";
export const ProformaCreateAutoAppliedInfo = () => {
return (
<section className="rounded-xl border border-blue-200 bg-blue-50/60 p-6 text-slate-800">
<div className="flex items-start gap-3">
<AlertCircleIcon className="mt-0.5 size-5 text-blue-600" />
<div className="space-y-2">
<div>
<h2 className="text-lg font-semibold">Se aplicará automáticamente</h2>
<p className="text-sm text-slate-600">
Los siguientes datos se tomarán del cliente o de la configuración de empresa y
podrás modificarlos después en la edición.
</p>
</div>
<div className="grid gap-4 border-b border-blue-200/80 py-4 md:grid-cols-3">
<div className="flex items-start gap-3">
<CircleDollarSignIcon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Moneda</p>
<p className="text-sm text-slate-600">EUR</p>
</div>
</div>
<div className="flex items-start gap-3">
<BanknoteIcon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Método de pago</p>
<p className="text-sm text-slate-600">Según cliente</p>
</div>
</div>
<div className="flex items-start gap-3">
<ReceiptTextIcon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Condiciones de pago</p>
<p className="text-sm text-slate-600">Según cliente</p>
</div>
</div>
</div>
<div className="flex items-start gap-3 pt-1">
<AlertCircleIcon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Fiscalidad inicial</p>
<p className="text-sm text-slate-600">
La fiscalidad se tomará del cliente o de la configuración de empresa y podrás
modificarla después en la edición.
</p>
</div>
</div>
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,73 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { Button, Field, FieldError } from "@repo/shadcn-ui/components";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ChevronsUpDownIcon, XIcon } from "lucide-react";
import { Controller, useFormContext } from "react-hook-form";
import type { ProformaCreateForm } from "../../entities";
interface ProformaCreateCustomerFieldProps {
disabled?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
}
export const ProformaCreateCustomerField = ({
disabled = false,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
}: ProformaCreateCustomerFieldProps) => {
const triggerId = "proforma-create-customer";
const { control, formState } = useFormContext<ProformaCreateForm>();
const isDisabled = disabled || formState.isSubmitting;
return (
<Controller
control={control}
name="customerId"
render={({ fieldState }) => (
<Field className="gap-1" data-invalid={fieldState.invalid}>
<label className="text-sm font-medium leading-none" htmlFor={triggerId}>
Cliente <span className="text-destructive">*</span>
</label>
<div className="relative">
<Button
className={cn(
"h-10 w-full justify-between border bg-muted/50 px-3 font-normal text-left text-foreground hover:bg-muted/50",
!selectedCustomer && "text-muted-foreground"
)}
disabled={isDisabled}
id={triggerId}
onClick={onSelectCustomer}
type="button"
variant="outline"
>
<span className="truncate">
{selectedCustomer?.name || selectedCustomer?.tradeName || "Buscar o seleccionar cliente..."}
</span>
<ChevronsUpDownIcon className="ml-2 size-4 shrink-0 opacity-60" />
</Button>
{selectedCustomer ? (
<Button
className="absolute right-9 top-1/2 size-7 -translate-y-1/2"
disabled={isDisabled}
onClick={onClearCustomer}
size="icon"
type="button"
variant="ghost"
>
<XIcon className="size-4" />
</Button>
) : null}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
);
};

View File

@ -0,0 +1,31 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@repo/shadcn-ui/components";
import { ListIcon } from "lucide-react";
export const ProformaCreateEmptyLinesCard = () => {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<ListIcon className="size-5 text-primary" />
Líneas de detalle
</CardTitle>
<CardDescription className="text-base">
La proforma se creará sin líneas. Podrás añadir productos o servicios en la siguiente
pantalla.
</CardDescription>
</CardHeader>
<CardContent className="flex min-h-64 flex-col items-center justify-center gap-4 text-center">
<div className="flex size-20 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ListIcon className="size-9" />
</div>
<div className="space-y-1">
<p className="text-2xl font-semibold tracking-tight">Sin líneas de detalle</p>
<p className="text-muted-foreground">
Añade productos o servicios después de crear el borrador.
</p>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,49 @@
import type { CustomerSelectionOption } from "@erp/customers";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { ProformaCreateAutoAppliedInfo } from "./proforma-create-auto-applied-info";
import { ProformaCreateEmptyLinesCard } from "./proforma-create-empty-lines-card";
import { ProformaCreateInitialInfoCard } from "./proforma-create-initial-info-card";
interface ProformaCreateFormProps {
formId: string;
isSubmitting: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
seriesOptions: { value: string; label: string }[];
className?: string;
}
export const ProformaCreateForm = ({
formId,
isSubmitting,
onSubmit,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
seriesOptions,
className,
}: ProformaCreateFormProps) => {
return (
<div className={cn("p-6", className)}>
<form id={formId} noValidate onKeyDown={preventEnterKeySubmitForm} onSubmit={onSubmit}>
<div className="space-y-6">
<ProformaCreateInitialInfoCard
disabled={isSubmitting}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
selectedCustomer={selectedCustomer}
seriesOptions={seriesOptions}
/>
<ProformaCreateAutoAppliedInfo />
<ProformaCreateEmptyLinesCard />
</div>
</form>
</div>
);
};

View File

@ -0,0 +1,55 @@
import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components";
import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components";
import type { ReactNode } from "react";
export interface ProformaCreateHeaderProps {
formId: string;
isSaving: boolean;
onCancel: () => void;
children?: ReactNode;
}
export const ProformaCreateHeader = ({
formId,
isSaving,
onCancel,
children,
}: ProformaCreateHeaderProps) => {
return (
<PageFormHeader
actions={
<FormActionsBar align="end" reverseOnMobile={false}>
<PageKeyboardShortcutsButton
className="hidden sm:flex"
label="Ver atajos de teclado"
shortcuts={[
{ keys: "Ctrl+S", label: "Crear y continuar" },
{ keys: "Esc", label: "Cancelar" },
]}
/>
<CancelActionButton
className="hidden sm:flex"
disabled={isSaving}
label="Cancelar"
onCancel={onCancel}
variant="outline"
/>
<RhfSubmitActionButton
busyLabel="Creando borrador..."
disabled={isSaving}
formId={formId}
isBusy={isSaving}
label="Crear y continuar"
/>
</FormActionsBar>
}
backLabel="Volver"
onBack={onCancel}
title="Nueva proforma"
>
{children}
</PageFormHeader>
);
};

View File

@ -0,0 +1,56 @@
import { DatePickerField, FormSectionCard, FormSectionGrid, SelectField } from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import type { CustomerSelectionOption } from "@erp/customers";
import { type ProformaCreateForm } from "../../entities";
import { ProformaCreateCustomerField } from "./proforma-create-customer-field";
interface ProformaCreateInitialInfoCardProps {
disabled?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
seriesOptions: { value: string; label: string }[];
}
export const ProformaCreateInitialInfoCard = ({
disabled = false,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
seriesOptions,
}: ProformaCreateInitialInfoCardProps) => {
return (
<FormSectionCard icon={<FileTextIcon className="size-5" />} title="Información inicial">
<FormSectionGrid>
<div className="md:col-span-5">
<ProformaCreateCustomerField
disabled={disabled}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
selectedCustomer={selectedCustomer}
/>
</div>
<DatePickerField<ProformaCreateForm>
className="md:col-span-3"
disabled={disabled}
label="Fecha"
name="invoiceDate"
placeholder="Selecciona una fecha"
required
/>
<SelectField<ProformaCreateForm>
className="md:col-span-4"
disabled={disabled}
items={seriesOptions}
label="Serie"
name="series"
placeholder={seriesOptions.length > 0 ? "Selecciona una serie" : "Sin serie"}
/>
</FormSectionGrid>
</FormSectionCard>
);
};

View File

@ -0,0 +1 @@
export * from "./proforma-create-skeleton";

View File

@ -0,0 +1,16 @@
import { Skeleton } from "@repo/shadcn-ui/components";
export const ProformaCreateSkeleton = () => {
return (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<div className="border-b px-6 py-4">
<Skeleton className="h-10 w-full max-w-md" />
</div>
<div className="flex-1 space-y-6 overflow-y-auto p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
);
};

View File

@ -1,48 +1,85 @@
import { PageHeader } from "@erp/core/components";
import { UnsavedChangesProvider } from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { ErrorAlert } from "@erp/core/components";
import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks";
import { SelectCustomerDialog } from "@erp/customers";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "../../../../i18n";
import { ProformaInfoAlert } from "../../../update/ui/components";
import { useCreateProformaPageController } from "../../controllers";
import { ProformaCreateForm, ProformaCreateHeader } from "../blocks";
import { ProformaCreateSkeleton } from "../components";
export const ProformaCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { createCtrl, selectCustomerCtrl, returnTo } = useCreateProformaPageController();
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
if (createCtrl.isLoading) {
return <ProformaCreateSkeleton />;
}
if (createCtrl.isLoadError) {
return (
<AppContent>
<ErrorAlert
message={
createCtrl.loadError instanceof Error
? createCtrl.loadError.message
: "Inténtalo de nuevo más tarde."
}
title="No se pudo preparar la creación de la proforma"
/>
<div className="flex items-center justify-end">
<BackHistoryButton onClick={() => navigateBack()} />
</div>
</AppContent>
);
}
return (
<UnsavedChangesProvider isDirty={true}>
<AppHeader>
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
<div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...createCtrl.form}>
<UnsavedChangesProvider isDirty={createCtrl.form.formState.isDirty}>
<ProformaCreateHeader
formId={createCtrl.formId}
isSaving={createCtrl.isCreating}
onCancel={navigateBack}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
</AppHeader>
<ProformaInfoAlert>
<span className="font-semibold">Importante: </span>Las proformas no tienen validez
fiscal. Puedes convertirlas en facturas cuando sean aceptadas por el cliente.
</ProformaInfoAlert>
</ProformaCreateHeader>
<AppContent>
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("pages.create.title")}</h2>
<p className="text-muted-foreground">{t("pages.create.description")}</p>
{createCtrl.isCreateError && (
<ErrorAlert
message={
createCtrl.createError instanceof Error
? createCtrl.createError.message
: "Revisa los datos e inténtalo de nuevo."
}
title="No se pudo crear la proforma"
/>
)}
<div className="flex-1 overflow-y-auto">
<ProformaCreateForm
formId={createCtrl.formId}
isSubmitting={createCtrl.isCreating}
onClearCustomer={createCtrl.clearCustomer}
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}
onSubmit={createCtrl.onSubmit}
selectedCustomer={createCtrl.selectedCustomer}
seriesOptions={createCtrl.seriesOptions}
/>
</div>
<div className="flex items-center justify-end mb-4">
<Button className="cursor-pointer" onClick={() => navigate("/customer-invoices/list")}>
{t("pages.create.back_to_list")}
</Button>
</div>
</div>
<div className="flex flex-1 flex-col gap-4 p-4">hola</div>
</AppContent>
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
</UnsavedChangesProvider>
</FormProvider>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { DateHelper } from "@repo/rdx-utils";
import type { ProformaCreateForm } from "../entities";
export const buildProformaCreateDefault = (): ProformaCreateForm => {
return {
customerId: "",
invoiceDate: DateHelper.buildTodayIsoDate(),
series: "",
languageCode: "es",
currencyCode: "EUR",
};
};

View File

@ -0,0 +1,17 @@
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import type { ProformaList } from "../../shared";
export const buildProformaSeriesOptions = (proformas?: ProformaList): SelectFieldItem[] => {
const uniqueSeries = new Set<string>();
for (const item of proformas?.items ?? []) {
const value = item.series?.trim();
if (!value) continue;
uniqueSeries.add(value);
}
return [...uniqueSeries]
.sort((left, right) => left.localeCompare(right))
.map((value) => ({ value, label: value }));
};

View File

@ -0,0 +1,2 @@
export * from "./build-proforma-create-default";
export * from "./build-proforma-series-options";

View File

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

View File

@ -167,7 +167,14 @@ export const ListProformasPage = () => {
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
onClick={() =>
navigate({
pathname: "/proformas/create",
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
})
}
size={"default"}
>
<PlusIcon aria-hidden className="mr-2 size-4" />

View File

@ -53,6 +53,17 @@ function normalizeToDate(input: DateInput): Date | null {
return null;
}
const buildTodayIsoDate = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
export const DateHelper = {
format: formatDate,
normalizeToDate,
buildTodayIsoDate,
};