diff --git a/.cursor/skills/pagination/SKILL.md b/.cursor/skills/pagination/SKILL.md index 8cf57842..fbddd2c1 100644 --- a/.cursor/skills/pagination/SKILL.md +++ b/.cursor/skills/pagination/SKILL.md @@ -1,6 +1,6 @@ --- name: pagination -description: Implements and maintains server-driven React pagination using this project's Search page and Report Contactos tab patterns. Use when adding pagination, changing page navigation, synchronizing search pages with URL parameters, resetting pagination after filters, or working with paginated API results. +description: Implements and maintains server-driven React pagination and the shared paginated list-row card layout (ContactCard, PaymentMethodCard, PartyReportCard, AssociationCard). Use when adding pagination, new report-tab lists, changing page navigation, synchronizing search pages with URL parameters, resetting pagination after filters, or working with paginated API results. --- # Frontend Pagination @@ -88,6 +88,91 @@ totalPages: number; onPageChange: (page: number) => void; ``` +## Paginated list row cards + +Profile tabs that paginate entities (Contactos, Métodos de pago, Reportes, +Colaboradores/Empresas, etc.) render **one card per row** inside a scrollable +list, then `PaginationNav` below. Match the layout already used in: + +- `src/presentation/pages/report/components/ContactCard.tsx` +- `src/presentation/pages/report/components/PaymentMethodCard.tsx` +- `src/presentation/shared/components/PartyReportCard.tsx` +- `src/presentation/pages/report/components/AssociationCard.tsx` (same two-row + rhythm; optional uppercase kind label on row 1) + +Do not introduce a third card layout for paginated report-tab lists. Reuse an +existing card or extend it with props; copy structure and Tailwind classes from +the references above. + +### List container (inside the tab section) + +```tsx +
+ {items.map((item) => ( + + ))} +
+``` + +Mirror `ContactsTab`, `ReportsTab`, `PaymentMethodsTab`, and `AssociationsTab`. + +### Card shell + +- Wrap each row in `
`. +- Interactive surface: ``, ``, or `
); diff --git a/src/presentation/pages/report/components/AssociationsTab.tsx b/src/presentation/pages/report/components/AssociationsTab.tsx index 268e8605..92cd690a 100644 --- a/src/presentation/pages/report/components/AssociationsTab.tsx +++ b/src/presentation/pages/report/components/AssociationsTab.tsx @@ -16,23 +16,27 @@ interface AssociationsTabProps { const copyByPartyType = { scammer: { title: "Empresas:", - description: "Organizaciones a las que pertenece este estafador.", + addButton: "Agregar Empresa +", + description: "Empresas a las que pertenece este estafador.", loading: "Cargando empresas...", empty: "No se encontraron empresas.", error: "No pudimos cargar las empresas. Revisa tu conexión e inténtalo de nuevo.", pagination: "Paginación de empresas", associationType: "organization", + associationKindLabel: "Empresa", }, organization: { - title: "Estafadores:", - description: "Estafadores asociados a esta empresa.", + title: "Colaboradores:", + addButton: "Agregar Colaborador +", + description: "Colaboradores asociados a esta empresa.", loading: "Cargando estafadores...", empty: "No se encontraron estafadores.", error: "No pudimos cargar los estafadores. Revisa tu conexión e inténtalo de nuevo.", pagination: "Paginación de estafadores", associationType: "scammer", + associationKindLabel: "Colaborador", }, } as const; @@ -239,7 +243,7 @@ function AssociationsTab({ partyId, partyType }: AssociationsTabProps) { }} className="cursor-pointer rounded-md bg-red-600 px-4 py-2 text-sm font-extrabold text-white hover:bg-red-700" > - Agregar + + {copy.addButton}

{copy.description}

@@ -256,6 +260,7 @@ function AssociationsTab({ partyId, partyType }: AssociationsTabProps) { key={association.id} association={association} associationType={copy.associationType} + kindLabel={copy.associationKindLabel} /> ))} @@ -266,6 +271,7 @@ function AssociationsTab({ partyId, partyType }: AssociationsTabProps) { key={association.id} association={association} associationType={copy.associationType} + kindLabel={copy.associationKindLabel} /> ))} @@ -312,6 +318,7 @@ function AssociationsTab({ partyId, partyType }: AssociationsTabProps) { ) : null} {isAddAssociationOpen && partyType === "scammer" ? ( + + + ); +} + +type ReportDetailPlaceholderProps = { + onBack: () => void; +}; + +function ReportDetailPlaceholder({ onBack }: ReportDetailPlaceholderProps) { + return ( +
+ +

+ This is the report content +

+
+ ); +} + +export default ReportDetailPlaceholder; diff --git a/src/presentation/pages/report/components/ReportHero.tsx b/src/presentation/pages/report/components/ReportHero.tsx index 03fe652d..74cad609 100644 --- a/src/presentation/pages/report/components/ReportHero.tsx +++ b/src/presentation/pages/report/components/ReportHero.tsx @@ -316,7 +316,7 @@ function ReportHero({ ref={fileInputRef} id={fileInputId} type="file" - accept="image/jpeg,image/png" + accept="image/jpeg,image/png,image/webp" tabIndex={-1} disabled={isUploading} aria-label="Cambiar foto de perfil" diff --git a/src/presentation/pages/report/components/ReportTabNavigation.tsx b/src/presentation/pages/report/components/ReportTabNavigation.tsx index ca531410..dcd41c7b 100644 --- a/src/presentation/pages/report/components/ReportTabNavigation.tsx +++ b/src/presentation/pages/report/components/ReportTabNavigation.tsx @@ -17,7 +17,7 @@ const tabIcons: Record = { Contactos: reportIcons.contactos, "Métodos de pago": reportIcons.paymentMethods, Empresas: reportIcons.asociaciones, - Estafadores: reportIcons.asociaciones, + Colaboradores: reportIcons.asociaciones, Mapa: reportIcons.mapa, Soporte: reportIcons.soporte, }; diff --git a/src/presentation/pages/report/components/ReportsTab.test.tsx b/src/presentation/pages/report/components/ReportsTab.test.tsx index d63f149d..c497e59e 100644 --- a/src/presentation/pages/report/components/ReportsTab.test.tsx +++ b/src/presentation/pages/report/components/ReportsTab.test.tsx @@ -9,7 +9,7 @@ function createReport( title: string, description: string, ): PartyReportEntity { - return new PartyReportEntity(id, title, description); + return new PartyReportEntity(id, title, description, "2026-11-01"); } function createPage(page: number) { @@ -51,7 +51,7 @@ describe("ReportsTab", () => { expect(execute).toHaveBeenCalledWith("20", "scammer", 1); expect(screen.getByText("Historial de Reportes:")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Agregar +" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Agregar Reporte +" })).toBeInTheDocument(); expect(screen.getByText("#1 - Título del reporte 1-0")).toBeInTheDocument(); expect( screen.getByRole("link", { name: /Título del reporte 1-0/ }), @@ -82,7 +82,7 @@ describe("ReportsTab", () => { expect(screen.getByRole("button", { name: "Página 2" })).toBeInTheDocument(); }); - expect(screen.getByRole("button", { name: "Agregar +" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Agregar Reporte +" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Página 2" })); @@ -159,4 +159,64 @@ describe("ReportsTab", () => { expect(execute).toHaveBeenCalledTimes(2); }); + + it("calls onSelectReport when a report card is clicked", async () => { + const execute = vi.fn().mockResolvedValue(createPage(1)); + const onSelectReport = vi.fn(); + const user = userEvent.setup(); + + renderWithProviders( + , + { + overrides: { + findReportsByPartyUseCase: { + execute, + cancel: vi.fn(), + }, + }, + }, + ); + + await waitFor(() => { + expect(screen.getAllByRole("article")).toHaveLength(10); + }); + + await user.click( + screen.getByRole("button", { name: /Ver reporte Título del reporte 1-0/ }), + ); + + expect(onSelectReport).toHaveBeenCalledWith("1"); + }); + + it("shows placeholder content when a report is selected", () => { + const execute = vi.fn().mockResolvedValue(createPage(1)); + + renderWithProviders( + , + { + overrides: { + findReportsByPartyUseCase: { + execute, + cancel: vi.fn(), + }, + }, + }, + ); + + expect(screen.getByText("This is the report content")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Volver al listado/ }), + ).toBeInTheDocument(); + expect(screen.queryByText("Historial de Reportes:")).not.toBeInTheDocument(); + }); }); diff --git a/src/presentation/pages/report/components/ReportsTab.tsx b/src/presentation/pages/report/components/ReportsTab.tsx index 614e8901..838f5868 100644 --- a/src/presentation/pages/report/components/ReportsTab.tsx +++ b/src/presentation/pages/report/components/ReportsTab.tsx @@ -5,6 +5,7 @@ import { useDependencies } from "@/presentation/providers/useDependencies"; import AddReportModal, { type AddReportDraft, } from "@presentation/pages/report/components/AddReportModal"; +import ReportDetailPlaceholder from "@presentation/pages/report/components/ReportDetailPlaceholder"; import PartyReportCard from "@/presentation/shared/components/PartyReportCard"; import { isCanceledError } from "@/common/utils/http-error.util"; import PaginationNav from "@/presentation/shared/components/PaginationNav"; @@ -12,6 +13,9 @@ import PaginationNav from "@/presentation/shared/components/PaginationNav"; interface ReportsTabProps { partyId: string; partyType: "scammer" | "organization"; + selectedReportId?: string | null; + onSelectReport?: (reportId: string) => void; + onBackFromReport?: () => void; } function toAttachedReportRequest( @@ -26,7 +30,13 @@ function toAttachedReportRequest( }; } -function ReportsTab({ partyId, partyType }: ReportsTabProps) { +function ReportsTab({ + partyId, + partyType, + selectedReportId = null, + onSelectReport, + onBackFromReport, +}: ReportsTabProps) { const { findReportsByPartyUseCase, createReportForScammerUseCase, @@ -50,6 +60,10 @@ function ReportsTab({ partyId, partyType }: ReportsTabProps) { const isLoading = loadedRequestKey !== requestKey; useEffect(() => { + if (selectedReportId) { + return; + } + let ignore = false; findReportsByPartyUseCase @@ -91,6 +105,7 @@ function ReportsTab({ partyId, partyType }: ReportsTabProps) { partyType, requestKey, requestVersion, + selectedReportId, ]); useEffect(() => { @@ -154,6 +169,14 @@ function ReportsTab({ partyId, partyType }: ReportsTabProps) { } } + if (selectedReportId) { + return ( + onBackFromReport?.()} + /> + ); + } + return (
@@ -165,7 +188,7 @@ function ReportsTab({ partyId, partyType }: ReportsTabProps) { onClick={() => setIsAddReportOpen(true)} className="cursor-pointer rounded-md bg-red-600 px-4 py-2 text-sm font-extrabold text-white hover:bg-red-700" > - Agregar + + Agregar Reporte +
@@ -179,7 +202,15 @@ function ReportsTab({ partyId, partyType }: ReportsTabProps) { {!isLoading && !errorMessage && reports.map((report) => ( - + onSelectReport(selected.id) + : undefined + } + /> ))} {!isLoading && errorMessage && ( diff --git a/src/presentation/pages/report/components/types.ts b/src/presentation/pages/report/components/types.ts index abbf2a7a..06ffe9c2 100644 --- a/src/presentation/pages/report/components/types.ts +++ b/src/presentation/pages/report/components/types.ts @@ -44,7 +44,7 @@ const reportTabsAfterAssociations = ["Mapa", "Soporte"] as const; export const associationTabByParty = { scammer: "Empresas", - organization: "Estafadores", + organization: "Colaboradores", } as const; export type ReportTab = diff --git a/src/presentation/shared/components/AddOrganizationModal.tsx b/src/presentation/shared/components/AddOrganizationModal.tsx index aef4a734..e386be4f 100644 --- a/src/presentation/shared/components/AddOrganizationModal.tsx +++ b/src/presentation/shared/components/AddOrganizationModal.tsx @@ -8,6 +8,7 @@ type AddOrganizationModalProps = { ) => void | Promise; errorMessage?: string | null; isSubmitting?: boolean; + title?: string; }; function AddOrganizationModal({ @@ -15,10 +16,11 @@ function AddOrganizationModal({ onCreate, errorMessage = null, isSubmitting = false, + title = "Agregar Empresa", }: AddOrganizationModalProps) { return ( + + + + ); +} function ExternalLinkIcon() { return ( @@ -28,29 +56,56 @@ function ExternalLinkIcon() { ); } -function PartyReportCard({ report }: { report: PartyReportEntity }) { - return ( -
- -
-

- #{report.id} - {report.title} -

- - - -
+interface PartyReportCardProps { + report: PartyReportEntity; + onSelect?: (report: PartyReportEntity) => void; +} -

+function PartyReportCard({ report, onSelect }: PartyReportCardProps) { + const formattedDate = formatContactDate(report.createdAt); + + const cardBody = ( + <> +

+

+ #{report.id} - {report.title} +

+ + + +
+ +
+

{report.description}

- +
+ + +
+
+ + ); + + const cardClassName = + "block w-full cursor-pointer rounded-md border border-gray-200 px-4 py-3 text-left hover:bg-gray-50"; + + return ( +
+ {onSelect ? ( + + ) : ( + + {cardBody} + + )}
); } diff --git a/src/presentation/shared/utils/profile-picture-file.util.test.ts b/src/presentation/shared/utils/profile-picture-file.util.test.ts index 43609480..396fad90 100644 --- a/src/presentation/shared/utils/profile-picture-file.util.test.ts +++ b/src/presentation/shared/utils/profile-picture-file.util.test.ts @@ -4,16 +4,18 @@ import { } from "@/presentation/shared/utils/profile-picture-file.util"; describe("validateProfilePicture", () => { - it("accepts jpeg and png files within the size limit", () => { - const file = new File(["x"], "photo.jpg", { type: "image/jpeg" }); + it("accepts jpeg, png, and webp files within the size limit", () => { + const jpeg = new File(["x"], "photo.jpg", { type: "image/jpeg" }); + const webp = new File(["x"], "photo.webp", { type: "image/webp" }); - expect(validateProfilePicture(file)).toBeNull(); + expect(validateProfilePicture(jpeg)).toBeNull(); + expect(validateProfilePicture(webp)).toBeNull(); }); it("rejects unsupported formats", () => { const file = new File(["x"], "photo.gif", { type: "image/gif" }); - expect(validateProfilePicture(file)).toBe("Usa una imagen JPG o PNG."); + expect(validateProfilePicture(file)).toBe("Usa una imagen JPG, PNG o WEBP."); }); it("rejects files larger than 5 MB", () => { diff --git a/src/presentation/shared/utils/profile-picture-file.util.ts b/src/presentation/shared/utils/profile-picture-file.util.ts index 3ce16179..8a96c63c 100644 --- a/src/presentation/shared/utils/profile-picture-file.util.ts +++ b/src/presentation/shared/utils/profile-picture-file.util.ts @@ -2,17 +2,21 @@ import { getHttpStatus } from "@/common/utils/http-error.util"; export const MAX_PROFILE_PICTURE_BYTES = 5 * 1024 * 1024; -function isJpegOrPng(file: File) { - if (file.type === "image/jpeg" || file.type === "image/png") { +function isAllowedProfilePictureFormat(file: File) { + if ( + file.type === "image/jpeg" || + file.type === "image/png" || + file.type === "image/webp" + ) { return true; } - return /\.(jpe?g|png)$/i.test(file.name); + return /\.(jpe?g|png|webp)$/i.test(file.name); } export function validateProfilePicture(file: File) { - if (!isJpegOrPng(file)) { - return "Usa una imagen JPG o PNG."; + if (!isAllowedProfilePictureFormat(file)) { + return "Usa una imagen JPG, PNG o WEBP."; } if (file.size > MAX_PROFILE_PICTURE_BYTES) { @@ -24,7 +28,7 @@ export function validateProfilePicture(file: File) { export function profilePictureUploadErrorMessage(error: unknown) { if (getHttpStatus(error) === 422) { - return "Usa una imagen JPG o PNG de hasta 5 MB."; + return "Usa una imagen JPG, PNG o WEBP de hasta 5 MB."; } return "No se pudo subir la foto. Inténtalo de nuevo.";