From 4f945632cba6f8226381a00a58fef1f77132ad8f Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 03:18:30 -0600 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=85=20feat:=20open=20party=20reports?= =?UTF-8?q?=20inline=20with=20placeholder=20detail=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- src/presentation/pages/report/Report.tsx | 21 ++++-- .../components/ReportDetailPlaceholder.tsx | 42 ++++++++++++ .../report/components/ReportsTab.test.tsx | 64 ++++++++++++++++++- .../pages/report/components/ReportsTab.tsx | 37 ++++++++++- .../shared/components/PartyReportCard.tsx | 48 ++++++++++---- 5 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 src/presentation/pages/report/components/ReportDetailPlaceholder.tsx diff --git a/src/presentation/pages/report/Report.tsx b/src/presentation/pages/report/Report.tsx index 0c53956..0e988cd 100644 --- a/src/presentation/pages/report/Report.tsx +++ b/src/presentation/pages/report/Report.tsx @@ -85,6 +85,7 @@ function Report({ type }: { type: "scammer" | "organization" }) { useDependencies(); const profileKey = `${type}:${id ?? ""}`; const [activeTab, setActiveTab] = useState("General"); + const [viewingReportId, setViewingReportId] = useState(null); const [activeProfileKey, setActiveProfileKey] = useState(profileKey); const [profilePictureOverride, setProfilePictureOverride] = useState(null); @@ -100,8 +101,14 @@ function Report({ type }: { type: "scammer" | "organization" }) { setActiveProfileKey(profileKey); setActiveTab("General"); setProfilePictureOverride(null); + setViewingReportId(null); } + const handleTabChange = (tab: ReportTab) => { + setActiveTab(tab); + setViewingReportId(null); + }; + useLayoutEffect(() => { scrollViewToTop(); }, [profileKey]); @@ -206,7 +213,7 @@ function Report({ type }: { type: "scammer" | "organization" }) { )} @@ -262,11 +269,17 @@ function Report({ type }: { type: "scammer" | "organization" }) { partyId={party.id} partyType={type} reports={party.reports} - onNavigateTab={setActiveTab} + onNavigateTab={handleTabChange} /> )} {activeTab === "Reportes" && ( - + setViewingReportId(null)} + /> )} {activeTab === "Contactos" && ( @@ -274,7 +287,7 @@ function Report({ type }: { type: "scammer" | "organization" }) { {activeTab === "Métodos de pago" && ( )} - {(activeTab === "Empresas" || activeTab === "Estafadores") && ( + {(activeTab === "Empresas" || activeTab === "Colaboradores") && ( + + + ); +} + +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/ReportsTab.test.tsx b/src/presentation/pages/report/components/ReportsTab.test.tsx index d63f149..4a72eb2 100644 --- a/src/presentation/pages/report/components/ReportsTab.test.tsx +++ b/src/presentation/pages/report/components/ReportsTab.test.tsx @@ -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 614e890..838f586 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/shared/components/PartyReportCard.tsx b/src/presentation/shared/components/PartyReportCard.tsx index 43033b1..d7583cb 100644 --- a/src/presentation/shared/components/PartyReportCard.tsx +++ b/src/presentation/shared/components/PartyReportCard.tsx @@ -28,13 +28,14 @@ function ExternalLinkIcon() { ); } -function PartyReportCard({ report }: { report: PartyReportEntity }) { - return ( -
- +interface PartyReportCardProps { + report: PartyReportEntity; + onSelect?: (report: PartyReportEntity) => void; +} + +function PartyReportCard({ report, onSelect }: PartyReportCardProps) { + const cardBody = ( + <>

#{report.id} - {report.title} @@ -44,13 +45,34 @@ function PartyReportCard({ report }: { report: PartyReportEntity }) {

-

+ {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} + + )}
); } From a3c49fe6fc2fec7464d79c4f30e60544ac95041d Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 03:18:33 -0600 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=85=20feat:=20align=20Colaboradores?= =?UTF-8?q?=20and=20Empresa=20copy=20across=20associations=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .../components/CollaboratorFormModal.tsx | 10 +++++----- src/presentation/pages/report/Report.test.tsx | 2 +- .../report/components/AssociationCard.tsx | 18 ++++++++++++++---- .../report/components/AssociationsTab.tsx | 15 +++++++++++---- .../report/components/ReportTabNavigation.tsx | 2 +- .../pages/report/components/types.ts | 2 +- .../shared/components/AddOrganizationModal.tsx | 4 +++- 7 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/presentation/pages/report-form/components/CollaboratorFormModal.tsx b/src/presentation/pages/report-form/components/CollaboratorFormModal.tsx index fe4f28c..2744acf 100644 --- a/src/presentation/pages/report-form/components/CollaboratorFormModal.tsx +++ b/src/presentation/pages/report-form/components/CollaboratorFormModal.tsx @@ -72,14 +72,14 @@ const FORM_COPY: Record< "Los perfiles/números que haya utilizado el colaborador para contactarte", }, organization: { - photoAdd: "Agregar foto de la organización", - photoChange: "Cambiar foto de la organización", - name: "Nombre de la organización", + photoAdd: "Agregar foto de la empresa", + photoChange: "Cambiar foto de la empresa", + name: "Nombre de la empresa", placeholder: "Ej. (Billions Trade Club, Globoshop)", payments: - "Los números de cuenta/bancos/wallets que esté utilizando la organización para captar fondos", + "Los números de cuenta/bancos/wallets que esté utilizando la empresa para captar fondos", contacts: - "Los perfiles/números que haya utilizado la organización para contactarte", + "Los perfiles/números que haya utilizado la empresa para contactarte", }, }; diff --git a/src/presentation/pages/report/Report.test.tsx b/src/presentation/pages/report/Report.test.tsx index 38de5c6..b018693 100644 --- a/src/presentation/pages/report/Report.test.tsx +++ b/src/presentation/pages/report/Report.test.tsx @@ -320,7 +320,7 @@ describe("Report page", () => { , ); - await user.click(await screen.findByRole("tab", { name: "Estafadores" })); + await user.click(await screen.findByRole("tab", { name: "Colaboradores" })); expect( await screen.findByRole("link", { name: /Ada Lovelace/ }), ).toBeInTheDocument(); diff --git a/src/presentation/pages/report/components/AssociationCard.tsx b/src/presentation/pages/report/components/AssociationCard.tsx index a268c47..8080669 100644 --- a/src/presentation/pages/report/components/AssociationCard.tsx +++ b/src/presentation/pages/report/components/AssociationCard.tsx @@ -3,7 +3,7 @@ import { organizationPath, scammerPath } from "@/common/app-routes"; import PartyAssociationEntity from "@/core/domain/association/entities/party-association.entity"; import { formatContactDate } from "@presentation/pages/report/components/contact-date.util"; -function ArrowIcon() { +function ExternalLinkIcon() { return ( + - +

diff --git a/src/presentation/pages/report/components/AssociationsTab.tsx b/src/presentation/pages/report/components/AssociationsTab.tsx index 268e860..92cd690 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" ? ( = { 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/types.ts b/src/presentation/pages/report/components/types.ts index abbf2a7..06ffe9c 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 aef4a73..e386be4 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 ( Date: Sat, 7 Nov 2026 03:18:34 -0600 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=85=20feat:=20accept=20WebP=20for=20p?= =?UTF-8?q?rofile=20photo=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .../report-form/components/PartyPhotoInput.tsx | 16 +++++++++------- .../pages/report/components/ReportHero.tsx | 2 +- .../utils/profile-picture-file.util.test.ts | 10 ++++++---- .../shared/utils/profile-picture-file.util.ts | 16 ++++++++++------ 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/presentation/pages/report-form/components/PartyPhotoInput.tsx b/src/presentation/pages/report-form/components/PartyPhotoInput.tsx index 2b01509..5fe20e7 100644 --- a/src/presentation/pages/report-form/components/PartyPhotoInput.tsx +++ b/src/presentation/pages/report-form/components/PartyPhotoInput.tsx @@ -31,14 +31,15 @@ function PartyPhotoInput({ required = true, }: PartyPhotoInputProps) { const { storeTemporaryProfilePictureUseCase } = useDependencies(); - const [previewUrl, setPreviewUrl] = useState(null); const [pendingFile, setPendingFile] = useState(null); const [isUploading, setIsUploading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const requestId = useRef(0); const isMounted = useRef(true); + const [previewUrl, setPreviewUrl] = useState(null); const previewFile = pendingFile ?? file; + const displayPreviewUrl = previewFile ? previewUrl : null; const label = previewFile ? changeLabel : addLabel; const circleBorder = invalid ? "border-red-600" : "border-gray-400"; const viewLabel = addLabel.replace(/^Agregar /, "Ver "); @@ -56,11 +57,12 @@ function PartyPhotoInput({ useEffect(() => { if (!previewFile) { - setPreviewUrl(null); return; } const nextUrl = URL.createObjectURL(previewFile); + // Blob preview URLs are created in an effect so the prior URL can be revoked. + // eslint-disable-next-line react-hooks/set-state-in-effect -- object URL lifecycle setPreviewUrl(nextUrl); return () => { @@ -138,7 +140,7 @@ function PartyPhotoInput({ return (
- {previewUrl ? ( + {displayPreviewUrl ? (
- {isPreviewOpen && previewUrl ? ( + {isPreviewOpen && displayPreviewUrl ? ( diff --git a/src/presentation/pages/report/components/ReportHero.tsx b/src/presentation/pages/report/components/ReportHero.tsx index 03fe652..74cad60 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/shared/utils/profile-picture-file.util.test.ts b/src/presentation/shared/utils/profile-picture-file.util.test.ts index 4360948..396fad9 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 3ce1617..8a96c63 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."; From fabc7379a06766faa5c84dd58762b77f7dac5c2d Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 03:25:40 -0600 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=85=20feat:=20align=20party=20report?= =?UTF-8?q?=20cards=20with=20shared=20list-row=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .../report/stub/find-reports-by-party.stub.ts | 4 ++ .../entities/party-report.entity.test.ts | 1 + .../report/entities/party-report.entity.ts | 5 ++ src/infrastructure/api/public-api.adapter.ts | 2 + .../report/components/ReportsTab.test.tsx | 2 +- .../shared/components/PartyReportCard.tsx | 61 ++++++++++++++----- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/application/usecases/report/stub/find-reports-by-party.stub.ts b/src/application/usecases/report/stub/find-reports-by-party.stub.ts index 8b54493..2647d8a 100644 --- a/src/application/usecases/report/stub/find-reports-by-party.stub.ts +++ b/src/application/usecases/report/stub/find-reports-by-party.stub.ts @@ -11,6 +11,7 @@ const extraReports = Array.from({ length: 22 }, (_, index) => { id, `Reporte de estafa #${id}`, `Detalle del reporte ${id}: me contactaron ofreciendo un trato que parecía legítimo y luego desaparecieron con el dinero.`, + "2026-11-20", ); }); @@ -19,16 +20,19 @@ const stubReports: PartyReportEntity[] = [ "1354", "¡Me estafó $2,000 MXN y estoy enojada!", "Estoy super enojada, me ofreció un lote de ropa por $2,000 MXN y le deposité en oxxo sin verificar.", + "2026-11-20", ), new PartyReportEntity( "1314", "Estafado", "Hola, quiero reportar a este tipo que me estafó 1000 pesos. Pensé que era real y le creí.", + "2026-11-18", ), new PartyReportEntity( "1287", "Cuidado parece estafa!", "El titular de la cuenta es MARISOL DOMINGUEZ NUEVO y el tipo quiere que pongamos el dinero en su cuenta.", + "2026-11-15", ), ...extraReports, ]; diff --git a/src/core/domain/report/entities/party-report.entity.test.ts b/src/core/domain/report/entities/party-report.entity.test.ts index b95317e..905a6f7 100644 --- a/src/core/domain/report/entities/party-report.entity.test.ts +++ b/src/core/domain/report/entities/party-report.entity.test.ts @@ -7,6 +7,7 @@ describe("PartyReportEntity.success", () => { "1354", "¡Me estafó $2,000 MXN y estoy enojada!", "Estoy super enojada, me ofreció un lote de ropa por $2,000 MXN.", + "2026-10-07", ); } diff --git a/src/core/domain/report/entities/party-report.entity.ts b/src/core/domain/report/entities/party-report.entity.ts index b60f69a..c76d9b2 100644 --- a/src/core/domain/report/entities/party-report.entity.ts +++ b/src/core/domain/report/entities/party-report.entity.ts @@ -3,6 +3,7 @@ class PartyReportEntity { private readonly _id: string, private readonly _title: string, private readonly _description: string, + private readonly _createdAt: string, ) {} public get id(): string { @@ -16,6 +17,10 @@ class PartyReportEntity { public get description(): string { return this._description; } + + public get createdAt(): string { + return this._createdAt; + } } export default PartyReportEntity; diff --git a/src/infrastructure/api/public-api.adapter.ts b/src/infrastructure/api/public-api.adapter.ts index abd7e9d..0822c99 100644 --- a/src/infrastructure/api/public-api.adapter.ts +++ b/src/infrastructure/api/public-api.adapter.ts @@ -237,6 +237,7 @@ class PublicApiAdapter implements PublicApiPort { String(report.id), report.title, report.short_description ?? "", + report.created_at, ), ), total: data.total, @@ -549,6 +550,7 @@ class PublicApiAdapter implements PublicApiPort { String(report.id), report.title, report.short_description, + report.created_at, ), ), total: data.total, diff --git a/src/presentation/pages/report/components/ReportsTab.test.tsx b/src/presentation/pages/report/components/ReportsTab.test.tsx index 4a72eb2..c497e59 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) { diff --git a/src/presentation/shared/components/PartyReportCard.tsx b/src/presentation/shared/components/PartyReportCard.tsx index d7583cb..1a6b157 100644 --- a/src/presentation/shared/components/PartyReportCard.tsx +++ b/src/presentation/shared/components/PartyReportCard.tsx @@ -1,6 +1,34 @@ import { Link } from "react-router-dom"; import { reportDetailPath } from "@/common/app-routes"; import PartyReportEntity from "@/core/domain/report/entities/party-report.entity"; +import { formatContactDate } from "@presentation/pages/report/components/contact-date.util"; + +function CalendarIcon() { + return ( + + + + + ); +} function ExternalLinkIcon() { return ( @@ -34,23 +62,28 @@ interface PartyReportCardProps { } function PartyReportCard({ report, onSelect }: PartyReportCardProps) { + const formattedDate = formatContactDate(report.createdAt); + const cardBody = ( <> -
-

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

- - - -
+
+

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

+ + + +
-

- {report.description} -

+
+

+ {report.description} +

+
+ + +
+
); From 95e9f9b143e24c00e21f483d85599896c1b00942 Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 03:25:41 -0600 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=85=20feat:=20align=20association=20c?= =?UTF-8?q?ards=20with=20shared=20list-row=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .../report/components/AssociationCard.tsx | 52 ++++++++++++++----- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/src/presentation/pages/report/components/AssociationCard.tsx b/src/presentation/pages/report/components/AssociationCard.tsx index 8080669..fde5241 100644 --- a/src/presentation/pages/report/components/AssociationCard.tsx +++ b/src/presentation/pages/report/components/AssociationCard.tsx @@ -3,6 +3,33 @@ import { organizationPath, scammerPath } from "@/common/app-routes"; import PartyAssociationEntity from "@/core/domain/association/entities/party-association.entity"; import { formatContactDate } from "@presentation/pages/report/components/contact-date.util"; +function CalendarIcon() { + return ( + + + + + ); +} + function ExternalLinkIcon() { return (
-
-

- {kindLabel} -

-

- #{isScammer ? "SC" : "OR"} - {association.id} - {association.name} -

-
+

+ {kindLabel} +

-

- -

+
+

+ #{isScammer ? "SC" : "OR"} + {association.id} - {association.name} +

+
+ + +
+
); From 5f29db6e08574bb42b286a9d05a82f2ad726c12f Mon Sep 17 00:00:00 2001 From: Oscar Cedano Date: Sat, 7 Nov 2026 03:25:41 -0600 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9C=85=20chore:=20document=20paginated?= =?UTF-8?q?=20list-row=20card=20layout=20in=20pagination=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Cursor Composer 2.5 --- .cursor/skills/pagination/SKILL.md | 90 +++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/.cursor/skills/pagination/SKILL.md b/.cursor/skills/pagination/SKILL.md index 8cf5784..fbddd2c 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 `