feat: mejorar el componente de ayuda para proformas y ajustar pruebas de actualización

This commit is contained in:
David Arranz 2026-08-28 13:34:03 +02:00
parent a37d6fc96b
commit 1a8d63373d
4 changed files with 432 additions and 420 deletions

View File

@ -10,6 +10,7 @@ import {
Separator,
} from "@repo/shadcn-ui/components";
import { AlertCircleIcon, CheckCircle2Icon, CircleDashedIcon, CircleHelpIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useTranslation } from "../../../../i18n";
import type { ProformaIssuanceReadiness } from "../../../shared";
@ -23,7 +24,7 @@ interface ProformaUpdateHelpPopoverProps {
onRetryReadiness?: () => void;
}
interface HelpTriggerProps {
interface HelpTriggerProps extends ComponentProps<typeof Button> {
ariaLabel: string;
pendingBlockersCount: number;
}
@ -39,9 +40,16 @@ interface HelpReadinessSectionProps {
translate: (key: string, params?: Record<string, string | number | null | undefined>) => string;
}
const HelpTrigger = ({ ariaLabel, pendingBlockersCount }: HelpTriggerProps) => {
const HelpTrigger = ({ ariaLabel, className, pendingBlockersCount, ...props }: HelpTriggerProps) => {
return (
<Button aria-label={ariaLabel} className="relative" size="icon" type="button" variant="outline">
<Button
{...props}
aria-label={ariaLabel}
className={className ? `relative ${className}` : "relative"}
size="icon"
type="button"
variant="outline"
>
<CircleHelpIcon aria-hidden="true" className="size-4" />
{pendingBlockersCount > 0 ? (
<Badge
@ -190,9 +198,9 @@ export const ProformaUpdateHelpPopover = ({
return (
<Popover>
<PopoverTrigger>
<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />
</PopoverTrigger>
<PopoverTrigger
render={<HelpTrigger ariaLabel={triggerAriaLabel} pendingBlockersCount={pendingBlockersCount} />}
/>
<PopoverContent
align="end"

View File

@ -31,7 +31,7 @@ const buildFormData = (value: Partial<ProformaUpdateForm> = {}): ProformaUpdateF
...value,
}) as ProformaUpdateForm;
test("excluye items vacíos no valorados al construir el PUT de update", () => {
test("mantiene los items vacíos y respeta su orden al construir el PUT de update", () => {
const formData = buildFormData({
items: [
{
@ -66,7 +66,17 @@ test("excluye items vacíos no valorados al construir el PUT de update", () => {
formData
);
assert.equal(result.data.items?.length, 1);
assert.equal(result.data.items?.[0]?.description, "Servicio");
assert.equal(result.data.items?.length, 2);
assert.equal(result.data.items?.[0]?.description, "");
assert.equal(result.data.items?.[0]?.quantity, null);
assert.equal(result.data.items?.[0]?.unit_amount, null);
assert.equal(result.data.items?.[0]?.position, 0);
assert.equal(result.data.items?.[1]?.description, "Servicio");
assert.deepEqual(result.data.items?.[1]?.quantity, { value: "100", scale: "2" });
assert.deepEqual(result.data.items?.[1]?.unit_amount, {
value: "1000000",
scale: "4",
currency_code: "EUR",
});
assert.equal(result.data.items?.[1]?.position, 1);
});

View File

@ -109,9 +109,7 @@ export const buildUpdateProformaByIdParams = (
// Si se han tocado los detalles, se envían todos
if (shouldReplaceItems(patch)) {
const context = buildProformaItemUpdateDTOContext(formData);
data.items = formData.items
.filter(isValuedProformaItem)
.map((item, index) => toProformaItemUpdateDTO(item, index, context));
data.items = formData.items.map((item, index) => toProformaItemUpdateDTO(item, index, context));
}
return {
@ -146,10 +144,6 @@ const toProformaItemUpdateDTO = (
};
};
const isValuedProformaItem = (item: ProformaItemUpdateForm): boolean => {
return item.quantity !== null && item.unitAmount !== null;
};
const buildProformaItemUpdateDTOContext = (
formData: ProformaUpdateForm
): ProformaItemUpdateDTOContext => {

View File

@ -1,404 +1,404 @@
import { useDataSource } from "@erp/core/hooks";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { LoginForm } from "../login/entities";
import {
AvailableCompaniesResponseDTOSchema,
activeCompanyStorage,
authTokenStorage,
findAvailableCompanies,
mapAvailableCompany,
mapCurrentSessionToAuthSession,
resolveAuthenticatedRedirectPath,
resolveCompanySession,
useLoginMutation,
useLogoutMutation,
useRefreshSessionMutation,
} from "../shared";
import { getCurrentSession } from "../shared/api";
import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities";
import {
resetIdentityAuthSessionRuntimeHandlers,
setIdentityAuthSessionRuntimeHandlers,
} from "./identity-auth-session.runtime";
export interface IdentityAuthSessionContextValue {
session: AuthSession | null;
account: AuthSession["account"] | null;
companySession: CompanySession;
availableCompanies: ActiveCompany[];
activeCompany: ActiveCompany | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (params: LoginForm) => Promise<AuthSession>;
logout: () => Promise<void>;
restoreSession: () => Promise<AuthSession | null>;
refreshSession: () => Promise<AuthSession | null>;
refreshAccessToken: () => Promise<string | null>;
selectActiveCompany: (companyId: string) => ActiveCompany | null;
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
updateActiveCompany: (company: {
id: string;
legalName: string;
tradeName: string | null;
}) => void;
updateAccount: (account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => void;
getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null;
fallbackPath?: string;
}) => string;
clearSession: () => void;
}
const IdentityAuthSessionContext = createContext<IdentityAuthSessionContextValue | undefined>(
undefined
);
const EMPTY_COMPANY_SESSION: CompanySession = {
availableCompanies: [],
activeCompany: null,
};
export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) {
const dataSource = useDataSource();
const loginMutation = useLoginMutation();
const logoutMutation = useLogoutMutation();
const refreshSessionMutation = useRefreshSessionMutation();
const [session, setSession] = useState<AuthSession | null>(null);
const [companySession, setCompanySession] = useState<CompanySession>(EMPTY_COMPANY_SESSION);
const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken());
const clearSession = useCallback(() => {
authTokenStorage.clear();
activeCompanyStorage.clear();
setSession(null);
setCompanySession(EMPTY_COMPANY_SESSION);
}, []);
const hydrateCompanySession = useCallback(
async (accessToken: string) => {
const response = await findAvailableCompanies(dataSource, accessToken);
const parsedResponse = AvailableCompaniesResponseDTOSchema.safeParse(response);
if (!parsedResponse.success) {
throw new Error("Invalid available companies response");
}
const availableCompanies = parsedResponse.data.companies.map(mapAvailableCompany);
const nextCompanySession = resolveCompanySession({
companies: availableCompanies,
persistedActiveCompanyId: activeCompanyStorage.getActiveCompanyId(),
});
if (nextCompanySession.activeCompany) {
activeCompanyStorage.setActiveCompanyId(nextCompanySession.activeCompany.id);
} else {
activeCompanyStorage.clear();
}
setCompanySession(nextCompanySession);
return nextCompanySession;
},
[dataSource]
);
const restoreSession = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
if (!tokens.accessToken) {
clearSession();
setIsRestoringSession(false);
return null;
}
setIsRestoringSession(true);
try {
const response = await getCurrentSession(dataSource, tokens.accessToken);
const restoredSession = mapCurrentSessionToAuthSession(response, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? "",
});
await hydrateCompanySession(tokens.accessToken);
setSession(restoredSession);
return restoredSession;
} catch {
clearSession();
return null;
} finally {
setIsRestoringSession(false);
}
}, [clearSession, dataSource, hydrateCompanySession]);
const login = useCallback(
async (params: LoginForm) => {
const nextSession = await loginMutation.mutateAsync(params);
authTokenStorage.setTokens({
accessToken: nextSession.accessToken,
refreshToken: nextSession.refreshToken,
});
activeCompanyStorage.clear();
await hydrateCompanySession(nextSession.accessToken);
setSession(nextSession);
return nextSession;
},
[hydrateCompanySession, loginMutation]
);
const logout = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
try {
if (tokens.accessToken) {
await logoutMutation.mutateAsync({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
}
} finally {
clearSession();
}
}, [clearSession, logoutMutation]);
const refreshSession = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
if (!tokens.refreshToken) {
clearSession();
return null;
}
try {
const refreshedTokens = await refreshSessionMutation.mutateAsync({
refreshToken: tokens.refreshToken,
});
authTokenStorage.setTokens(refreshedTokens);
await hydrateCompanySession(refreshedTokens.accessToken);
if (session) {
const refreshedSession: AuthSession = {
...session,
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
};
setSession(refreshedSession);
return refreshedSession;
}
const currentSessionResponse = await getCurrentSession(
dataSource,
refreshedTokens.accessToken
);
const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, {
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
});
setSession(refreshedSession);
return refreshedSession;
} catch {
clearSession();
return null;
}
}, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation, session]);
const refreshAccessToken = useCallback(async () => {
const refreshedSession = await refreshSession();
return refreshedSession?.accessToken ?? null;
}, [refreshSession]);
const selectActiveCompany = useCallback(
(companyId: string) => {
if (companySession.activeCompany?.id === companyId) {
return companySession.activeCompany;
}
const nextActiveCompany =
companySession.availableCompanies.find((company) => company.id === companyId) ?? null;
if (!nextActiveCompany) {
return null;
}
activeCompanyStorage.setActiveCompanyId(nextActiveCompany.id);
setCompanySession((currentSession) => ({
...currentSession,
activeCompany: nextActiveCompany,
}));
return nextActiveCompany;
},
[companySession.activeCompany, companySession.availableCompanies]
);
const changeActiveCompany = useCallback(
async (companyId: string) => {
return selectActiveCompany(companyId);
},
[selectActiveCompany]
);
const updateActiveCompany = useCallback(
(company: { id: string; legalName: string; tradeName: string | null }) => {
setCompanySession((currentSession) => {
const availableCompanies = currentSession.availableCompanies.map((item) =>
item.id === company.id
? {
...item,
legalName: company.legalName,
tradeName: company.tradeName,
}
: item
);
const activeCompany =
currentSession.activeCompany?.id === company.id
? {
...currentSession.activeCompany,
legalName: company.legalName,
tradeName: company.tradeName,
}
: currentSession.activeCompany;
return {
availableCompanies,
activeCompany,
};
});
},
[]
);
const updateAccount = useCallback(
(account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => {
setSession((currentSession) => {
if (!currentSession || currentSession.account.id !== account.id) {
return currentSession;
}
return {
...currentSession,
account: {
...currentSession.account,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
languageCode: account.languageCode,
},
};
});
},
[]
);
const getAuthenticatedRedirectPath = useCallback(
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
return resolveAuthenticatedRedirectPath({
companySession,
fallbackPath: params?.fallbackPath ?? "/proformas",
returnTo: params?.returnTo,
});
},
[companySession]
);
useEffect(() => {
restoreSession();
}, [restoreSession]);
useEffect(() => {
setIdentityAuthSessionRuntimeHandlers({
clearSession,
refreshAccessToken,
});
return () => {
resetIdentityAuthSessionRuntimeHandlers();
};
}, [clearSession, refreshAccessToken]);
const value = useMemo<IdentityAuthSessionContextValue>(
() => ({
session,
account: session?.account ?? null,
companySession,
availableCompanies: companySession.availableCompanies,
activeCompany: companySession.activeCompany,
isAuthenticated: Boolean(session),
isLoading:
isRestoringSession ||
loginMutation.isPending ||
logoutMutation.isPending ||
refreshSessionMutation.isPending,
login,
logout,
restoreSession,
refreshSession,
refreshAccessToken,
selectActiveCompany,
changeActiveCompany,
updateActiveCompany,
updateAccount,
getAuthenticatedRedirectPath,
clearSession,
}),
[
changeActiveCompany,
clearSession,
companySession,
getAuthenticatedRedirectPath,
isRestoringSession,
login,
loginMutation.isPending,
logout,
logoutMutation.isPending,
refreshAccessToken,
refreshSession,
refreshSessionMutation.isPending,
restoreSession,
selectActiveCompany,
updateActiveCompany,
updateAccount,
session,
]
);
return (
<IdentityAuthSessionContext.Provider value={value}>
{children}
</IdentityAuthSessionContext.Provider>
);
}
export function useIdentityAuthSession() {
const context = useContext(IdentityAuthSessionContext);
if (!context) {
throw new Error("useIdentityAuthSession must be used within a IdentityAuthSessionProvider");
}
return context;
}
import { useDataSource } from "@erp/core/hooks";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { LoginForm } from "../login/entities";
import {
AvailableCompaniesResponseDTOSchema,
activeCompanyStorage,
authTokenStorage,
findAvailableCompanies,
mapAvailableCompany,
mapCurrentSessionToAuthSession,
resolveAuthenticatedRedirectPath,
resolveCompanySession,
useLoginMutation,
useLogoutMutation,
useRefreshSessionMutation,
} from "../shared";
import { getCurrentSession } from "../shared/api";
import type { ActiveCompany, AuthSession, CompanySession } from "../shared/entities";
import {
resetIdentityAuthSessionRuntimeHandlers,
setIdentityAuthSessionRuntimeHandlers,
} from "./identity-auth-session.runtime";
export interface IdentityAuthSessionContextValue {
session: AuthSession | null;
account: AuthSession["account"] | null;
companySession: CompanySession;
availableCompanies: ActiveCompany[];
activeCompany: ActiveCompany | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (params: LoginForm) => Promise<AuthSession>;
logout: () => Promise<void>;
restoreSession: () => Promise<AuthSession | null>;
refreshSession: () => Promise<AuthSession | null>;
refreshAccessToken: () => Promise<string | null>;
selectActiveCompany: (companyId: string) => ActiveCompany | null;
changeActiveCompany: (companyId: string) => Promise<ActiveCompany | null>;
updateActiveCompany: (company: {
id: string;
legalName: string;
tradeName: string | null;
}) => void;
updateAccount: (account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => void;
getAuthenticatedRedirectPath: (params?: {
returnTo?: string | null;
fallbackPath?: string;
}) => string;
clearSession: () => void;
}
const IdentityAuthSessionContext = createContext<IdentityAuthSessionContextValue | undefined>(
undefined
);
const EMPTY_COMPANY_SESSION: CompanySession = {
availableCompanies: [],
activeCompany: null,
};
export function IdentityAuthSessionProvider({ children }: { children: React.ReactNode }) {
const dataSource = useDataSource();
const loginMutation = useLoginMutation();
const logoutMutation = useLogoutMutation();
const refreshSessionMutation = useRefreshSessionMutation();
const [session, setSession] = useState<AuthSession | null>(null);
const [companySession, setCompanySession] = useState<CompanySession>(EMPTY_COMPANY_SESSION);
const [isRestoringSession, setIsRestoringSession] = useState(authTokenStorage.hasAccessToken());
const clearSession = useCallback(() => {
authTokenStorage.clear();
activeCompanyStorage.clear();
setSession(null);
setCompanySession(EMPTY_COMPANY_SESSION);
}, []);
const hydrateCompanySession = useCallback(
async (accessToken: string) => {
const response = await findAvailableCompanies(dataSource, accessToken);
const parsedResponse = AvailableCompaniesResponseDTOSchema.safeParse(response);
if (!parsedResponse.success) {
throw new Error("Invalid available companies response");
}
const availableCompanies = parsedResponse.data.companies.map(mapAvailableCompany);
const nextCompanySession = resolveCompanySession({
companies: availableCompanies,
persistedActiveCompanyId: activeCompanyStorage.getActiveCompanyId(),
});
if (nextCompanySession.activeCompany) {
activeCompanyStorage.setActiveCompanyId(nextCompanySession.activeCompany.id);
} else {
activeCompanyStorage.clear();
}
setCompanySession(nextCompanySession);
return nextCompanySession;
},
[dataSource]
);
const restoreSession = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
if (!tokens.accessToken) {
clearSession();
setIsRestoringSession(false);
return null;
}
setIsRestoringSession(true);
try {
const response = await getCurrentSession(dataSource, tokens.accessToken);
const restoredSession = mapCurrentSessionToAuthSession(response, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? "",
});
await hydrateCompanySession(tokens.accessToken);
setSession(restoredSession);
return restoredSession;
} catch {
clearSession();
return null;
} finally {
setIsRestoringSession(false);
}
}, [clearSession, dataSource, hydrateCompanySession]);
const login = useCallback(
async (params: LoginForm) => {
const nextSession = await loginMutation.mutateAsync(params);
authTokenStorage.setTokens({
accessToken: nextSession.accessToken,
refreshToken: nextSession.refreshToken,
});
activeCompanyStorage.clear();
await hydrateCompanySession(nextSession.accessToken);
setSession(nextSession);
return nextSession;
},
[hydrateCompanySession, loginMutation]
);
const logout = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
try {
if (tokens.accessToken) {
await logoutMutation.mutateAsync({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
}
} finally {
clearSession();
}
}, [clearSession, logoutMutation]);
const refreshSession = useCallback(async () => {
const tokens = authTokenStorage.getTokens();
if (!tokens.refreshToken) {
clearSession();
return null;
}
try {
const refreshedTokens = await refreshSessionMutation.mutateAsync({
refreshToken: tokens.refreshToken,
});
authTokenStorage.setTokens(refreshedTokens);
await hydrateCompanySession(refreshedTokens.accessToken);
if (session) {
const refreshedSession: AuthSession = {
...session,
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
};
setSession(refreshedSession);
return refreshedSession;
}
const currentSessionResponse = await getCurrentSession(
dataSource,
refreshedTokens.accessToken
);
const refreshedSession = mapCurrentSessionToAuthSession(currentSessionResponse, {
accessToken: refreshedTokens.accessToken,
refreshToken: refreshedTokens.refreshToken,
});
setSession(refreshedSession);
return refreshedSession;
} catch {
clearSession();
return null;
}
}, [clearSession, dataSource, hydrateCompanySession, refreshSessionMutation, session]);
const refreshAccessToken = useCallback(async () => {
const refreshedSession = await refreshSession();
return refreshedSession?.accessToken ?? null;
}, [refreshSession]);
const selectActiveCompany = useCallback(
(companyId: string) => {
if (companySession.activeCompany?.id === companyId) {
return companySession.activeCompany;
}
const nextActiveCompany =
companySession.availableCompanies.find((company) => company.id === companyId) ?? null;
if (!nextActiveCompany) {
return null;
}
activeCompanyStorage.setActiveCompanyId(nextActiveCompany.id);
setCompanySession((currentSession) => ({
...currentSession,
activeCompany: nextActiveCompany,
}));
return nextActiveCompany;
},
[companySession.activeCompany, companySession.availableCompanies]
);
const changeActiveCompany = useCallback(
async (companyId: string) => {
return selectActiveCompany(companyId);
},
[selectActiveCompany]
);
const updateActiveCompany = useCallback(
(company: { id: string; legalName: string; tradeName: string | null }) => {
setCompanySession((currentSession) => {
const availableCompanies = currentSession.availableCompanies.map((item) =>
item.id === company.id
? {
...item,
legalName: company.legalName,
tradeName: company.tradeName,
}
: item
);
const activeCompany =
currentSession.activeCompany?.id === company.id
? {
...currentSession.activeCompany,
legalName: company.legalName,
tradeName: company.tradeName,
}
: currentSession.activeCompany;
return {
availableCompanies,
activeCompany,
};
});
},
[]
);
const updateAccount = useCallback(
(account: {
id: string;
email: string;
name: string;
avatarUrl: string | null;
languageCode: string;
}) => {
setSession((currentSession) => {
if (!currentSession || currentSession.account.id !== account.id) {
return currentSession;
}
return {
...currentSession,
account: {
...currentSession.account,
email: account.email,
name: account.name,
avatarUrl: account.avatarUrl,
languageCode: account.languageCode,
},
};
});
},
[]
);
const getAuthenticatedRedirectPath = useCallback(
(params?: { returnTo?: string | null; fallbackPath?: string }) => {
return resolveAuthenticatedRedirectPath({
companySession,
fallbackPath: params?.fallbackPath ?? "/proformas",
returnTo: params?.returnTo,
});
},
[companySession]
);
useEffect(() => {
restoreSession();
}, [restoreSession]);
useEffect(() => {
setIdentityAuthSessionRuntimeHandlers({
clearSession,
refreshAccessToken,
});
return () => {
resetIdentityAuthSessionRuntimeHandlers();
};
}, [clearSession, refreshAccessToken]);
const value = useMemo<IdentityAuthSessionContextValue>(
() => ({
session,
account: session?.account ?? null,
companySession,
availableCompanies: companySession.availableCompanies,
activeCompany: companySession.activeCompany,
isAuthenticated: Boolean(session),
isLoading:
isRestoringSession ||
loginMutation.isPending ||
logoutMutation.isPending ||
refreshSessionMutation.isPending,
login,
logout,
restoreSession,
refreshSession,
refreshAccessToken,
selectActiveCompany,
changeActiveCompany,
updateActiveCompany,
updateAccount,
getAuthenticatedRedirectPath,
clearSession,
}),
[
changeActiveCompany,
clearSession,
companySession,
getAuthenticatedRedirectPath,
isRestoringSession,
login,
loginMutation.isPending,
logout,
logoutMutation.isPending,
refreshAccessToken,
refreshSession,
refreshSessionMutation.isPending,
restoreSession,
selectActiveCompany,
updateActiveCompany,
updateAccount,
session,
]
);
return (
<IdentityAuthSessionContext.Provider value={value}>
{children}
</IdentityAuthSessionContext.Provider>
);
}
export function useIdentityAuthSession() {
const context = useContext(IdentityAuthSessionContext);
if (!context) {
throw new Error("useIdentityAuthSession must be used within a IdentityAuthSessionProvider");
}
return context;
}