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

View File

@ -31,7 +31,7 @@ const buildFormData = (value: Partial<ProformaUpdateForm> = {}): ProformaUpdateF
...value, ...value,
}) as ProformaUpdateForm; }) 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({ const formData = buildFormData({
items: [ items: [
{ {
@ -66,7 +66,17 @@ test("excluye items vacíos no valorados al construir el PUT de update", () => {
formData formData
); );
assert.equal(result.data.items?.length, 1); assert.equal(result.data.items?.length, 2);
assert.equal(result.data.items?.[0]?.description, "Servicio"); 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?.[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 // Si se han tocado los detalles, se envían todos
if (shouldReplaceItems(patch)) { if (shouldReplaceItems(patch)) {
const context = buildProformaItemUpdateDTOContext(formData); const context = buildProformaItemUpdateDTOContext(formData);
data.items = formData.items data.items = formData.items.map((item, index) => toProformaItemUpdateDTO(item, index, context));
.filter(isValuedProformaItem)
.map((item, index) => toProformaItemUpdateDTO(item, index, context));
} }
return { return {
@ -146,10 +144,6 @@ const toProformaItemUpdateDTO = (
}; };
}; };
const isValuedProformaItem = (item: ProformaItemUpdateForm): boolean => {
return item.quantity !== null && item.unitAmount !== null;
};
const buildProformaItemUpdateDTOContext = ( const buildProformaItemUpdateDTOContext = (
formData: ProformaUpdateForm formData: ProformaUpdateForm
): ProformaItemUpdateDTOContext => { ): ProformaItemUpdateDTOContext => {

View File

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