This commit is contained in:
David Arranz 2026-07-08 11:58:58 +02:00
parent 05f4b1337b
commit a464f9b14e
25 changed files with 218 additions and 88 deletions

View File

@ -1,8 +1,9 @@
import { type StartParams } from "@erp/core/api";
import type { StartParams } from "@erp/core/api";
import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import type { InvoiceSeriesInternalDeps } from "../di/invoice-series.di";
import { ListInvoiceSeriesController } from "./controllers";
export const invoiceSeriesRouter = (params: StartParams) => {
@ -20,5 +21,5 @@ export const invoiceSeriesRouter = (params: StartParams) => {
return controller.execute(req, res, next);
});
app.use(`${config.server.apiBasePath}/invoice-series`, router);
app.use(`${config.server.apiBasePath}/catalogs/invoice-series`, router);
};

View File

@ -1,7 +1,4 @@
import {
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import { SequelizeRepository, translateSequelizeError } from "@erp/core/api";
import type { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils";
import type { Sequelize, Transaction } from "sequelize";
@ -11,7 +8,7 @@ import {
InvoiceSeriesConcurrencyError,
InvoiceSeriesLockRequiredError,
InvoiceSeriesTransactionRequiredError,
} from "../../../../../application/invoice-series";
} from "../../../../../application";
import type { InvoiceSeries, InvoiceSeriesCode } from "../../../../../domain";
import { CustomerInvoiceSeriesModel } from "../../../../common";
import type { InvoiceSeriesDomainMapper } from "../mappers";
@ -27,10 +24,7 @@ export class SequelizeInvoiceSeriesRepository
super({ database });
}
public async findActiveByCompany(params: {
companyId: UniqueID;
transaction?: unknown;
}) {
public async findActiveByCompany(params: { companyId: UniqueID; transaction?: unknown }) {
try {
const rows = await CustomerInvoiceSeriesModel.findAll({
where: {

View File

@ -16,10 +16,14 @@ 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 {
buildInvoiceSeriesSelectItems,
useInvoiceSeriesQuery,
useProformaCreateMutation,
} from "../../shared";
import { buildCreateProformaParams } from "../adapters";
import { type ProformaCreateForm, ProformaCreateFormSchema } from "../entities";
import { buildProformaCreateDefault, buildProformaSeriesOptions } from "../utils";
import { buildProformaCreateDefault } from "../utils";
export interface UseCreateProformaControllerOptions {
onCreated?(proforma: Proforma): void;
@ -48,12 +52,7 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
error: createError,
} = useProformaCreateMutation();
const seriesQuery = useProformasListQuery({
criteria: {
pageNumber: 1,
pageSize: 100,
},
});
const seriesQuery = useInvoiceSeriesQuery();
const initialValues = useMemo<ProformaCreateForm>(() => buildProformaCreateDefault(), []);
@ -64,20 +63,28 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
});
const seriesOptions = useMemo(
() => buildProformaSeriesOptions(seriesQuery.data),
() => buildInvoiceSeriesSelectItems(seriesQuery.data ?? []),
[seriesQuery.data]
);
useEffect(() => {
if (seriesOptions.length !== 1) return;
if (form.getValues("series")) return;
const defaultSeries = useMemo(() => {
const invoiceSeries = seriesQuery.data ?? [];
form.setValue("series", seriesOptions[0].value, {
return (
invoiceSeries.find((series) => series.isDefault)?.code ?? invoiceSeries[0]?.code ?? ""
);
}, [seriesQuery.data]);
useEffect(() => {
if (form.getValues("series")) return;
if (!defaultSeries) return;
form.setValue("series", defaultSeries, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: true,
});
}, [form, seriesOptions]);
}, [defaultSeries, form]);
const setCustomer = (customer: CustomerSelectionOption) => {
setSelectedCustomer(customer);
@ -163,8 +170,8 @@ export const useCreateProformaController = (options?: UseCreateProformaControlle
setCustomer,
clearCustomer,
seriesOptions,
isLoading: seriesQuery.isLoading,
isLoadError: seriesQuery.isError,
loadError: seriesQuery.error,
isSeriesLoading: seriesQuery.isLoading,
isSeriesLoadError: seriesQuery.isError,
seriesLoadError: seriesQuery.error,
};
};

View File

@ -9,6 +9,7 @@ import { ProformaCreateInitialInfoCard } from "./proforma-create-initial-info-ca
interface ProformaCreateFormProps {
formId: string;
isSubmitting: boolean;
isSeriesLoading?: boolean;
onSubmit: React.SubmitEventHandler<HTMLFormElement>;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
@ -20,6 +21,7 @@ interface ProformaCreateFormProps {
export const ProformaCreateForm = ({
formId,
isSubmitting,
isSeriesLoading = false,
onSubmit,
selectedCustomer,
onSelectCustomer,
@ -33,6 +35,7 @@ export const ProformaCreateForm = ({
<div className="space-y-6">
<ProformaCreateInitialInfoCard
disabled={isSubmitting}
isSeriesLoading={isSeriesLoading}
onClearCustomer={onClearCustomer}
onSelectCustomer={onSelectCustomer}
selectedCustomer={selectedCustomer}

View File

@ -1,4 +1,10 @@
import { DatePickerField, FormSectionCard, FormSectionGrid, SelectField } from "@repo/rdx-ui/components";
import {
DatePickerField,
FormSectionCard,
FormSectionGrid,
SelectField,
type SelectFieldItem,
} from "@repo/rdx-ui/components";
import { FileTextIcon } from "lucide-react";
import type { CustomerSelectionOption } from "@erp/customers";
@ -8,14 +14,16 @@ import { ProformaCreateCustomerField } from "./proforma-create-customer-field";
interface ProformaCreateInitialInfoCardProps {
disabled?: boolean;
isSeriesLoading?: boolean;
selectedCustomer?: CustomerSelectionOption | null;
onSelectCustomer: () => void;
onClearCustomer: () => void;
seriesOptions: { value: string; label: string }[];
seriesOptions: SelectFieldItem[];
}
export const ProformaCreateInitialInfoCard = ({
disabled = false,
isSeriesLoading = false,
selectedCustomer,
onSelectCustomer,
onClearCustomer,
@ -44,11 +52,17 @@ export const ProformaCreateInitialInfoCard = ({
<SelectField<ProformaCreateForm>
className="md:col-span-4"
disabled={disabled}
disabled={disabled || isSeriesLoading}
items={seriesOptions}
label="Serie"
name="series"
placeholder={seriesOptions.length > 0 ? "Selecciona una serie" : "Sin serie"}
placeholder={
isSeriesLoading
? "Cargando series..."
: seriesOptions.length > 0
? "Selecciona una serie"
: "Sin serie"
}
/>
</FormSectionGrid>
</FormSectionCard>

View File

@ -1,13 +1,11 @@
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 { ProformaInfoAlert } from "../../../update/ui/components";
import { useCreateProformaPageController } from "../../controllers";
import { ProformaCreateForm, ProformaCreateHeader } from "../blocks";
import { ProformaCreateSkeleton } from "../components";
export const ProformaCreatePage = () => {
const { createCtrl, selectCustomerCtrl, returnTo } = useCreateProformaPageController();
@ -16,29 +14,6 @@ export const ProformaCreatePage = () => {
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 (
<div className="fixed inset-0 flex flex-col overflow-hidden">
<FormProvider {...createCtrl.form}>
@ -65,9 +40,21 @@ export const ProformaCreatePage = () => {
/>
)}
{createCtrl.isSeriesLoadError && (
<ErrorAlert
message={
createCtrl.seriesLoadError instanceof Error
? createCtrl.seriesLoadError.message
: "Inténtalo de nuevo más tarde."
}
title="No se pudieron cargar las series de facturación"
/>
)}
<div className="flex-1 overflow-y-auto">
<ProformaCreateForm
formId={createCtrl.formId}
isSeriesLoading={createCtrl.isSeriesLoading}
isSubmitting={createCtrl.isCreating}
onClearCustomer={createCtrl.clearCustomer}
onSelectCustomer={selectCustomerCtrl.selectCtrl.openDialog}

View File

@ -6,7 +6,7 @@ export const buildProformaCreateDefault = (): ProformaCreateForm => {
return {
customerId: "",
invoiceDate: DateHelper.buildTodayIsoDate(),
series: "F26",
series: "",
languageCode: "es",
currencyCode: "EUR",
};

View File

@ -1,17 +0,0 @@
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

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

View File

@ -1,3 +1,4 @@
export * from "./get-proforma-by-id.adapter";
export * from "./list-invoice-series.adapter";
export * from "./list-proformas.adapter";
export * from "./proforma-to-list-row-patch.adapter";

View File

@ -0,0 +1,18 @@
import type { ListInvoiceSeriesResponseDTO } from "../../../../common";
import type { InvoiceSeriesSummary } from "../entities";
export const mapInvoiceSeriesSummaryDTOToInvoiceSeriesSummary = (
dto: ListInvoiceSeriesResponseDTO["items"][number]
): InvoiceSeriesSummary => ({
id: dto.id,
code: dto.code,
nextNumber: dto.next_number,
paddingLength: dto.padding_length,
isDefault: dto.is_default,
});
export const ListInvoiceSeriesAdapter = {
fromDto(dto: ListInvoiceSeriesResponseDTO): InvoiceSeriesSummary[] {
return dto.items.map(mapInvoiceSeriesSummaryDTOToInvoiceSeriesSummary);
},
};

View File

@ -3,5 +3,6 @@ export * from "./create-proforma.api";
export * from "./delete-proforma-by-id.api";
export * from "./get-proforma-by-id.api";
export * from "./issue-proforma-by-id.api";
export * from "./list-invoice-series.api";
export * from "./list-proformas-by-criteria.api";
export * from "./update-proforma-by-id.api";

View File

@ -0,0 +1,12 @@
import type { IDataSource } from "@erp/core/client";
import type { ListInvoiceSeriesResponseDTO } from "../../../../common";
export const listInvoiceSeries = (
dataSource: IDataSource,
signal?: AbortSignal
): Promise<ListInvoiceSeriesResponseDTO> => {
return dataSource.getList<ListInvoiceSeriesResponseDTO>("catalogs/invoice-series", {
signal,
});
};

View File

@ -1,4 +1,5 @@
export * from "./forms/proforma-item-form.entity";
export * from "./invoice-series-summary.entity";
export * from "./proforma.entity";
export * from "./proforma-item.entity";
export * from "./proforma-list.entity";

View File

@ -0,0 +1,7 @@
export interface InvoiceSeriesSummary {
id: string;
code: string;
nextNumber: number;
paddingLength: number;
isDefault: boolean;
}

View File

@ -1,4 +1,5 @@
export * from "./keys";
export * from "./use-invoice-series-query";
export * from "./use-proforma-change-status-mutation";
export * from "./use-proforma-create-mutation";
export * from "./use-proforma-delete-mutation";

View File

@ -6,6 +6,7 @@ import type { ProformasListRequestDTO } from "../../../../common";
* Prefijo base para listados
*/
export const LIST_PROFORMAS_QUERY_KEY_PREFIX = ["proformas"] as const;
export const INVOICE_SERIES_QUERY_KEY = ["customer-invoices", "invoice-series"] as const;
/**
* Query key para listado de proformas

View File

@ -0,0 +1,20 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, type UseQueryResult, useQuery } from "@tanstack/react-query";
import { ListInvoiceSeriesAdapter } from "../adapters";
import { listInvoiceSeries } from "../api";
import type { InvoiceSeriesSummary } from "../entities";
import { INVOICE_SERIES_QUERY_KEY } from "./keys";
export const useInvoiceSeriesQuery = (): UseQueryResult<InvoiceSeriesSummary[], DefaultError> => {
const dataSource = useDataSource();
return useQuery<InvoiceSeriesSummary[], DefaultError>({
queryKey: INVOICE_SERIES_QUERY_KEY,
queryFn: async ({ signal }) => {
const dto = await listInvoiceSeries(dataSource, signal);
return ListInvoiceSeriesAdapter.fromDto(dto);
},
});
};

View File

@ -0,0 +1,12 @@
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import type { InvoiceSeriesSummary } from "../entities";
export const buildInvoiceSeriesSelectItems = (
invoiceSeries: InvoiceSeriesSummary[]
): SelectFieldItem[] => {
return invoiceSeries.map((series) => ({
value: series.code,
label: series.code,
}));
};

View File

@ -0,0 +1,19 @@
import type { SelectFieldItem } from "@repo/rdx-ui/components";
export const ensureCurrentValueInSelectItems = (
items: SelectFieldItem[],
currentValue?: string | null
): SelectFieldItem[] => {
if (!currentValue) return items;
const exists = items.some((item) => item.value === currentValue);
if (exists) return items;
return [
{
value: currentValue,
label: currentValue,
},
...items,
];
};

View File

@ -1,2 +1,4 @@
export * from "./build-invoice-series-select-items";
export * from "./calculate-proforma-due-dates";
export * from "./ensure-current-value-in-select-items";
export * from "./proforma-fiscal-options.utils";

View File

@ -15,7 +15,13 @@ import type { FieldErrors } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { UpdateProformaByIdParams } from "../../shared";
import type { Proforma } from "../../shared/entities";
import { useProformaGetQuery, useProformaUpdateMutation } from "../../shared/hooks";
import {
buildInvoiceSeriesSelectItems,
ensureCurrentValueInSelectItems,
useInvoiceSeriesQuery,
useProformaGetQuery,
useProformaUpdateMutation,
} from "../../shared";
import { mapProformaToProformaUpdateForm, mapProformaToSelectedCustomer } from "../adapters";
import { type ProformaUpdateForm, ProformaUpdateFormSchema } from "../entities";
import {
@ -72,6 +78,8 @@ export const useUpdateProformaController = (
error: updateError,
} = useProformaUpdateMutation();
const invoiceSeriesQuery = useInvoiceSeriesQuery();
const initialValues = useMemo<ProformaUpdateForm>(() => {
if (!proformaData) return buildProformaUpdateDefault();
@ -221,7 +229,7 @@ export const useUpdateProformaController = (
options?.onError?.(normalizedError, params);
}
},
(errors: FieldErrors<ProformaUpdateForm>) => {
(_errors: FieldErrors<ProformaUpdateForm>) => {
focusFirstInputFormError(form);
showWarningToast(
@ -239,6 +247,12 @@ export const useUpdateProformaController = (
const currencyCode = form.watch("currencyCode");
const languageCode = form.watch("languageCode");
const currentSeries = form.watch("series");
const seriesOptions = useMemo(() => {
const activeSeriesItems = buildInvoiceSeriesSelectItems(invoiceSeriesQuery.data ?? []);
return ensureCurrentValueInSelectItems(activeSeriesItems, currentSeries);
}, [currentSeries, invoiceSeriesQuery.data]);
const taxCtrl = useUpdateProformaTaxController({
form,
@ -263,6 +277,10 @@ export const useUpdateProformaController = (
taxCtrl,
totalsCtrl,
paymentCtrl,
seriesOptions,
isSeriesLoading: invoiceSeriesQuery.isLoading,
isSeriesLoadError: invoiceSeriesQuery.isError,
seriesLoadError: invoiceSeriesQuery.error,
//
currencyCode,

View File

@ -1,6 +1,7 @@
// modules/customer-invoices/src/web/proformas/update/ui/blocks/proforma-update-editor.tsx
import type { CustomerSelectionOption } from "@erp/customers";
import type { SelectFieldItem } from "@repo/rdx-ui/components";
import { preventEnterKeySubmitForm } from "@repo/rdx-ui/helpers";
import { cn } from "@repo/shadcn-ui/lib/utils";
@ -27,6 +28,7 @@ type ProformaUpdateEditorProps = {
selectedCustomer?: CustomerSelectionOption | null;
onChangeCustomerClick: () => void;
onCreateCustomerClick: () => void;
seriesOptions: SelectFieldItem[];
itemsCtrl: UseUpdateProformaItemsControllerResult;
taxCtrl: UseUpdateProformaTaxControllerResult;
@ -44,6 +46,7 @@ export const ProformaUpdateEditorForm = ({
selectedCustomer,
onChangeCustomerClick,
onCreateCustomerClick,
seriesOptions,
itemsCtrl,
taxCtrl,
totalsCtrl,
@ -58,7 +61,11 @@ export const ProformaUpdateEditorForm = ({
<div className="grid grid-cols-1 2xl:grid-cols-4 gap-6 2xl:gap-12">
<div className="2xl:col-span-3 space-y-6 gap-6 2xl:gap-12 2xl:space-y-12">
<div className="grid 2xl:grid-cols-3 gap-6 2xl:gap-6 space-y-12 2xl:space-y-12">
<ProformaUpdateHeaderEditor className="2xl:col-span-full" disabled={isSubmitting} />
<ProformaUpdateHeaderEditor
className="2xl:col-span-full"
disabled={isSubmitting}
seriesOptions={seriesOptions}
/>
</div>
<ProformaUpdateItemsEditor

View File

@ -3,6 +3,7 @@ import {
FormSectionCard,
FormSectionGrid,
SelectField,
type SelectFieldItem,
TextAreaField,
TextField,
} from "@repo/rdx-ui/components";
@ -12,6 +13,7 @@ import { FileTextIcon } from "lucide-react";
import { useTranslation } from "../../../../i18n";
interface ProformaUpdateHeaderEditorProps {
seriesOptions: SelectFieldItem[];
disabled?: boolean;
readOnly?: boolean;
@ -19,6 +21,7 @@ interface ProformaUpdateHeaderEditorProps {
}
export const ProformaUpdateHeaderEditor = ({
seriesOptions,
disabled = false,
readOnly = false,
className,
@ -33,15 +36,6 @@ export const ProformaUpdateHeaderEditor = ({
title={t("form_groups.proformas.basic_info.title")}
>
<FormSectionGrid>
<SelectField
className="md:col-span-1"
disabled={disabled}
label={t("form_fields.proformas.series.label")}
name="series"
placeholder={t("form_fields.proformas.series.placeholder")}
readOnly={readOnly}
/>
<TextField
className="md:col-span-2"
disabled={disabled}
@ -66,6 +60,16 @@ export const ProformaUpdateHeaderEditor = ({
readOnly={readOnly}
/>
<SelectField
className="md:col-span-1"
disabled={disabled}
items={seriesOptions}
label={t("form_fields.proformas.series.label")}
name="series"
placeholder={t("form_fields.proformas.series.placeholder")}
readOnly={readOnly}
/>
<DatePickerField
className="md:col-span-2"
disabled={disabled}

View File

@ -99,6 +99,23 @@ export const ProformaUpdatePage = () => {
title={t("pages.proformas.update.error_title", "No se pudo guardar los cambios")}
/>
)}
{updateCtrl.isSeriesLoadError && (
<ErrorAlert
message={
updateCtrl.seriesLoadError instanceof Error
? updateCtrl.seriesLoadError.message
: t(
"pages.proformas.update.series_load_error.message",
"Inténtalo de nuevo más tarde."
)
}
title={t(
"pages.proformas.update.series_load_error.title",
"No se pudieron cargar las series de facturación"
)}
/>
)}
<div className="flex-1 overflow-y-auto">
<ProformaUpdateEditorForm
formId={updateCtrl.formId}
@ -110,6 +127,7 @@ export const ProformaUpdatePage = () => {
onSubmit={updateCtrl.onSubmit}
paymentCtrl={updateCtrl.paymentCtrl}
selectedCustomer={updateCtrl.selectedCustomer}
seriesOptions={updateCtrl.seriesOptions}
taxCtrl={updateCtrl.taxCtrl}
totalsCtrl={updateCtrl.totalsCtrl}
/>