Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ apps/api/uploads/

# Entorno local personal (scripts, seeds, docker extra) — no subir al repo
.local/

# Local git worktrees for parallel agent work — never commit
.worktrees/
17 changes: 9 additions & 8 deletions apps/web/src/app/e/[slug]/donar/ofrecer/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { localizeBackendError } from '@/lib/backend-error-messages';
import { getT } from '@/i18n/server';
import { getCategories } from '@/adapters/get-categories';
import { isMaterialCategory } from '@/domain/supplies/category';
Expand Down Expand Up @@ -141,14 +142,14 @@ export async function submitOffer(
}

if (error !== undefined || data === undefined) {
const msg =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: t.donar.err_submit_failed;
return { status: 'error', message: msg };
const rawMessage =
typeof error === 'object' && error !== null && 'message' in error
? (error as { message: unknown }).message
: undefined;
return {
status: 'error',
message: localizeBackendError(t.backendErrors, rawMessage, t.donar.err_submit_failed),
};
}

return { status: 'success', id: data.id };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type SupplyLineView = components['schemas']['SupplyLineResponseDto'];
export type InventoryState =
| { status: 'idle' }
| { status: 'success' }
| { status: 'error'; message: string };
| { status: 'error'; message: string; invalidRow?: number };

/** Owner/coordinator read of the point's full declared lines (null → notFound). */
export async function fetchMyInventory(
Expand Down Expand Up @@ -63,13 +63,25 @@ export async function saveMyInventory(
);

// allowEmpty: the owner can clear the inventory (empty list is a valid save).
const items = parseSupplyLines(formData.get('items'), {
const parsedItems = parseSupplyLines(formData.get('items'), {
isValidCategory: (c) => validCategories.has(c),
allowEmpty: true,
});
if (items === null) {
return { status: 'error', message: t.account.inventory_invalid_items };
if ('invalidRow' in parsedItems) {
// invalidRow >= 0 means a specific row failed validation (#296): surface
// its 1-based position and let the caller highlight that row instead of
// making the owner hunt through a long inventory for the bad line.
const { invalidRow } = parsedItems;
return {
status: 'error',
message:
invalidRow >= 0
? t.account.inventory_invalid_row.replace('{n}', String(invalidRow + 1))
: t.account.inventory_invalid_items,
...(invalidRow >= 0 ? { invalidRow } : {}),
};
}
const { items } = parsedItems;

const { response } = await api.PUT('/resources/{resourceId}/inventory', {
params: { path: { resourceId } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function InventoryEditForm({
initialLines={initial.map(toLine)}
strict
allowAllCategories
invalidRowIndex={state.status === 'error' ? state.invalidRow : undefined}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit de UX (nice-to-have): invalidRow es del último submit, pero el resaltado se mantiene mientras el usuario edita. Si tras el error añade/elimina una fila por encima de la marcada sin volver a enviar, el índice queda desalineado y el borde rojo pasa a señalar otra fila (o una vacía). Se autocorrige en el siguiente submit, así que impacto bajo; si quisierais evitarlo del todo, limpiar state/el resaltado en el primer onChange tras el error lo resolvería. No para este PR.

Aparte de esto, el patrón { items } | { invalidRow } con el discriminante 'invalidRow' in ... y el paso SupplyLineList → SupplyLineFields (aria-invalid + role="alert") está muy limpio. 👍


Generated by Claude Code

/>

<Button type="submit" disabled={pending} fullWidth>
Expand Down
17 changes: 9 additions & 8 deletions apps/web/src/app/e/[slug]/ofrecer-transporte/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { localizeBackendError } from '@/lib/backend-error-messages';
import { getT } from '@/i18n/server';

type CapacityMode = components['schemas']['PublishCapacityDto']['mode'];
Expand Down Expand Up @@ -142,14 +143,14 @@ export async function submitCapacity(
}

if (error !== undefined || data === undefined) {
const msg =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: tt.err_submit_failed;
return { status: 'error', message: msg };
const rawMessage =
typeof error === 'object' && error !== null && 'message' in error
? (error as { message: unknown }).message
: undefined;
return {
status: 'error',
message: localizeBackendError(t.backendErrors, rawMessage, tt.err_submit_failed),
};
}

return { status: 'success', id: data.id };
Expand Down
22 changes: 12 additions & 10 deletions apps/web/src/app/e/[slug]/peticion/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { parseSupplyLines } from '@/lib/supply-lines';
import { localizeBackendError } from '@/lib/backend-error-messages';
import { getT } from '@/i18n/server';
import { getCategories } from '@/adapters/get-categories';

Expand Down Expand Up @@ -84,16 +85,17 @@ export async function submitPeticion(
// medical_personnel, which getCategories returns alongside material slugs).
const validCategories = new Set((await getCategories(locale)).map((c) => c.slug));

const items = parseSupplyLines(rawItems, {
const parsedItems = parseSupplyLines(rawItems, {
isValidCategory: (c) => validCategories.has(c),
allowEmpty: false,
});
if (items === null) {
if ('invalidRow' in parsedItems) {
return {
status: 'error',
message: t.peticion.err_invalid_items,
};
}
const { items } = parsedItems;

const description =
typeof rawDescription === 'string' && rawDescription.trim() !== ''
Expand Down Expand Up @@ -134,14 +136,14 @@ export async function submitPeticion(
}

if (error !== undefined || data === undefined) {
const msg =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: t.peticion.err_submit_failed;
return { status: 'error', message: msg };
const rawMessage =
typeof error === 'object' && error !== null && 'message' in error
? (error as { message: unknown }).message
: undefined;
return {
status: 'error',
message: localizeBackendError(t.backendErrors, rawMessage, t.peticion.err_submit_failed),
};
}

return { status: 'success', id: data.id };
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/e/[slug]/pre-registro/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,14 @@ export async function submitPreRegistration(
(await getCategories(locale)).filter(isMaterialCategory).map((c) => c.slug),
);

const items = parseSupplyLines(formData.get('items'), {
const parsedItems = parseSupplyLines(formData.get('items'), {
isValidCategory: (c) => validMaterialCategories.has(c),
allowEmpty: true,
});
if (items === null) {
if ('invalidRow' in parsedItems) {
return { status: 'error', message: tp.err_invalid_items };
}
const { items } = parsedItems;
if (items.length < 1) {
return { status: 'error', message: tp.err_items_required };
}
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/app/e/[slug]/recepcion/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { redirect } from 'next/navigation';
import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { getT } from '@/i18n/server';
import { parseSupplyLines } from '@/lib/supply-lines';
Expand Down Expand Up @@ -49,19 +50,20 @@ export async function submitReception(
// Received lines edited at the desk (#129): only sent with `receive`. Reuse
// the shared parser with material-only category validation; an empty/absent
// list means "no edit" → keep the declared lines (items stays undefined).
let items: ReturnType<typeof parseSupplyLines> | undefined;
let items: components['schemas']['SupplyLineDto'][] | undefined;
let adjustmentReason: string | null = null;
if (intent === 'receive') {
const validMaterialCategories = new Set(
(await getCategories(locale)).filter(isMaterialCategory).map((c) => c.slug),
);
items = parseSupplyLines(formData.get('items'), {
const parsedItems = parseSupplyLines(formData.get('items'), {
isValidCategory: (c) => validMaterialCategories.has(c),
allowEmpty: true,
});
if (items === null) {
if ('invalidRow' in parsedItems) {
return { status: 'error', message: tr.err_action_failed };
}
items = parsedItems.items;
const rawReason = formData.get('adjustmentReason');
adjustmentReason =
typeof rawReason === 'string' && rawReason.trim() !== ''
Expand Down
26 changes: 16 additions & 10 deletions apps/web/src/app/e/[slug]/registrar/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { parseSupplyLines } from '@/lib/supply-lines';
import { localizeBackendError } from '@/lib/backend-error-messages';
import { getT } from '@/i18n/server';
import { getCategories } from '@/adapters/get-categories';
import { isMaterialCategory } from '@/domain/supplies/category';
Expand Down Expand Up @@ -84,13 +85,14 @@ export async function registerResource(
(await getCategories(locale)).filter(isMaterialCategory).map((c) => c.slug),
);

const items = parseSupplyLines(formData.get('items'), {
const parsedItems = parseSupplyLines(formData.get('items'), {
isValidCategory: (c) => validMaterialCategories.has(c),
allowEmpty: true,
});
if (items === null) {
if ('invalidRow' in parsedItems) {
return { status: 'error', message: t.registrar.err_invalid_items };
}
const { items } = parsedItems;

const { data, error, response } = await api.POST(
'/emergencies/{emergencyId}/resources',
Expand Down Expand Up @@ -120,14 +122,18 @@ export async function registerResource(
}

if (error !== undefined || data === undefined) {
const msg =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: t.registrar.err_register_failed;
return { status: 'error', message: msg };
const rawMessage =
typeof error === 'object' && error !== null && 'message' in error
? (error as { message: unknown }).message
: undefined;
return {
status: 'error',
message: localizeBackendError(
t.backendErrors,
rawMessage,
t.registrar.err_register_failed,
),
};
}

return { status: 'success', id: data.id };
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/app/e/[slug]/registrar/inventory-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ interface InventoryFieldProps {
* not create.
*/
allowAllCategories?: boolean;
/**
* Index of the row the last submit's server-side validation error points at
* (#296) — surfaced by `strict` surfaces via `parseSupplyLines`' `invalidRow`
* so the offending row is highlighted instead of making the user scan a long
* inventory for it.
*/
invalidRowIndex?: number;
}

/**
Expand All @@ -78,6 +85,7 @@ export function InventoryField({
initialLines,
strict = false,
allowAllCategories = false,
invalidRowIndex,
}: InventoryFieldProps) {
const materialCategories = categories.filter(isMaterialCategory);
const selectableCategories = allowAllCategories ? categories : materialCategories;
Expand Down Expand Up @@ -128,6 +136,7 @@ export function InventoryField({
defaultCategory={defaultCategory}
showExpiry
labels={labels}
invalidIndex={invalidRowIndex}
/>

{/* Hidden input carries serialized items to the server action */}
Expand Down
21 changes: 13 additions & 8 deletions apps/web/src/app/e/[slug]/voluntario/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { api } from '@/lib/api';
import type { components } from '@reliefhub/api-client';
import { requireSession, authHeaders, redirectToLogin } from '@/lib/auth';
import { localizeBackendError } from '@/lib/backend-error-messages';
import { getT } from '@/i18n/server';

type Skill = components['schemas']['RegisterVolunteerDto']['skills'][number];
Expand Down Expand Up @@ -121,14 +122,18 @@ export async function registerVolunteer(
}

if (error !== undefined || data === undefined) {
const msg =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message: unknown }).message === 'string'
? (error as { message: string }).message
: t.voluntario.err_register_failed;
return { status: 'error', message: msg };
const rawMessage =
typeof error === 'object' && error !== null && 'message' in error
? (error as { message: unknown }).message
: undefined;
return {
status: 'error',
message: localizeBackendError(
t.backendErrors,
rawMessage,
t.voluntario.err_register_failed,
),
};
}

return { status: 'success' };
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/molecules/supply-line-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ interface SupplyLineFieldsProps {
labels?: Partial<Messages['supplyLine']>;
showExpiry?: boolean;
hideHeader?: boolean;
/**
* Marks this row as the one a server-side validation error points at (#296,
* e.g. `parseSupplyLines`' `invalidRow`) so the user can find it in a long
* list instead of getting only a generic "something in here is wrong".
*/
invalid?: boolean;
}

export function SupplyLineFields({
idPrefix, rowId, index, required, removable, categories, locale,
value, onChange, onRemove, labels, showExpiry = false, hideHeader = false,
invalid = false,
}: SupplyLineFieldsProps) {
const t = { ...getMessages(locale).supplyLine, ...labels };
const n = String(index + 1);
Expand All @@ -71,7 +78,10 @@ export function SupplyLineFields({
'w-full rounded-lg border-2 border-navy bg-white px-4 py-3 text-base text-ink focus:outline-none focus:ring-2 focus:ring-navy focus:ring-offset-2';

return (
<div className="rounded-lg border-2 border-line p-4">
<div
className={`rounded-lg border-2 p-4 ${invalid ? 'border-danger' : 'border-line'}`}
aria-invalid={invalid || undefined}
>
<div className="flex flex-col gap-3">
{!hideHeader && (
<div className="flex items-center justify-between">
Expand All @@ -91,6 +101,12 @@ export function SupplyLineFields({
</div>
)}

{invalid && (
<p role="alert" className="text-sm font-medium text-danger">
{t.invalidRowHint}
</p>
)}

<div className="flex flex-col gap-1.5">
<label htmlFor={`${idPrefix}-name-${rowId}`} className="text-sm font-medium text-ink-soft">
{t.nameLabel} <span aria-hidden="true">*</span>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/organisms/supply-line-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ interface SupplyLineListProps {
defaultCategory: string;
showExpiry?: boolean;
labels?: Partial<Messages['supplyLine']>;
/** Index of the row a server-side validation error points at (#296). */
invalidIndex?: number;
}

export function SupplyLineList({
value, onChange, categories, locale, idPrefix, required,
defaultCategory, showExpiry = false, labels,
defaultCategory, showExpiry = false, labels, invalidIndex,
}: SupplyLineListProps) {
const t = { ...getMessages(locale).supplyLine, ...labels };

Expand Down Expand Up @@ -56,6 +58,7 @@ export function SupplyLineList({
onRemove={() => remove(index)}
showExpiry={showExpiry}
labels={labels}
invalid={index === invalidIndex}
/>
))
)}
Expand Down
Loading
Loading