Crear una proforma
This commit is contained in:
parent
95fe0127a3
commit
3c5fd7c57a
@ -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: {
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./proforma-create-form.entity";
|
||||
export * from "./proforma-create-form.schema";
|
||||
export * from "./proforma-create-form-default.entity";
|
||||
@ -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[];
|
||||
}
|
||||
@ -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"),
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export * from "./ui";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-create-page";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./build-create-proforma-params";
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./use-create-proforma-controller";
|
||||
export * from "./use-create-proforma-page-controller";
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./proforma-create-form.entity";
|
||||
export * from "./proforma-create-form.schema";
|
||||
export * from "./proforma-create-form-default.entity";
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./proforma-create-skeleton";
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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")}
|
||||
>
|
||||
<PlusIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("pages.proformas.create.title")}
|
||||
</Button>
|
||||
}
|
||||
title={t("pages.proformas.list.title")}
|
||||
/>
|
||||
</AppHeader>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
</UnsavedChangesProvider>
|
||||
|
||||
<SelectCustomerDialog ctrl={selectCustomerCtrl.selectCtrl} />
|
||||
</UnsavedChangesProvider>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
};
|
||||
};
|
||||
@ -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 }));
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./build-proforma-create-default";
|
||||
export * from "./build-proforma-series-options";
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./update";
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user