classDiagram class App{ } class AuthUser { <> +id: string | number +username: string +fullName: string +role: string } class AuthSession { <> +user: AuthUser +accessToken: string +expiresAt: string } class ApiEnvelope~T~ { <> +ok: boolean +data: T } class InvimaDataset { <> +key: string +id: string +name: string +defaultCategory: string } class InvimaCategory { <> +categoria: string +slug: string +total: number } class InvimaFilterOption { <> +value: string +total: number } class InvimaCatalogItem { <> +source_dataset_key: string +source_dataset_id: string +source_dataset_name: string +categoria_linea: string +categoria_canonica: string +categoria_slug: string +expediente?: string +registro_sanitario?: string +producto?: string +titular?: string +estado_registro?: string +fecha_expedicion?: string +fecha_vencimiento?: string +modalidad?: string +grupo?: string +marca?: string +principio_activo?: string +forma_farmaceutica?: string +presentacion_comercial?: string +atc?: string +rol_nombre?: string +rol_tipo?: string +ciudad_titular?: string +pais_titular?: string +fabricante?: string +importador?: string +vigencia?: string +score?: number } class InvimaCatalogResponse { <> +items: InvimaCatalogItem[] +total: number +limit: number +offset: number } class InvimaFiltersResponse { <> +estados: InvimaFilterOption[] +modalidades: InvimaFilterOption[] +grupos: InvimaFilterOption[] } class InvimaSyncSummary { <> +datasets: number +fetched: number +persisted: number +pages: number +failed?: number +requested?: number } class InvimaSyncResponse { <> +ok: boolean +alreadyRunning?: boolean +job?: InvimaSyncJob | null +summary?: InvimaSyncSummary +failed?: #123; datasetKey: string; error: string; #125;[] } class InvimaSyncJobDataset { <> +key: string +datasetKey: string +datasetId: string | null +name: string +status: "pending" | "running" | "completed" | "failed" +fetched: number +persisted: number +pages: number +warnings: string[] +error: string | null +startedAt: string | null +updatedAt: string +finishedAt: string | null } class InvimaSyncJob { <> +id: string +status: "running" | "completed" | "failed" +requested: number +completed: number +failed: number +fetched: number +persisted: number +pages: number +concurrency?: number +pageSize?: number +datasets: InvimaSyncJobDataset[] +summary?: InvimaSyncSummary | null +error?: string | null +startedAt: string +updatedAt: string +finishedAt: string | null } class InvimaSyncDatasetStats { <> +key: string +id: string +name: string +localRows: number +selected: boolean } class InvimaSyncStats { <> +at: string +totals: #123; catalogRows: number; rawRows: number; categories: number; datasetsWithData: number; vigentes: number; vencidos: number; maxUpdatedAt: string | null; #125; +datasets: InvimaSyncDatasetStats[] } AuthSession -- AuthUser InvimaCatalogResponse -- "0..*" InvimaCatalogItem InvimaFiltersResponse -- "0..*" InvimaFilterOption InvimaFiltersResponse -- "0..*" InvimaFilterOption InvimaFiltersResponse -- "0..*" InvimaFilterOption InvimaSyncResponse -- InvimaSyncJob InvimaSyncResponse -- InvimaSyncSummary InvimaSyncJob -- "0..*" InvimaSyncJobDataset InvimaSyncJob -- InvimaSyncSummary InvimaSyncStats -- "0..*" InvimaSyncDatasetStats class AuthService{ -apiBase: "/api/auth" -_token: WritableSignal~string | null~ -_user: WritableSignal~AuthUser | null~ +user: Signal~AuthUser | null~ +isAuthenticated: Signal~boolean~ -http: HttpClient -hydrate() void -setSession() void +clearSession() void +getAccessToken() string | null +login() Observable~AuthSession~ +me() Observable~AuthUser~ +logout() Observable~boolean~ } class InvimaService{ -apiBase: "/api/invima" -http: HttpClient -buildExportParams() HttpParams -buildFallbackFilename() string -extractFilename() string +getDatasets() Observable~#123; items: InvimaDataset[]; #125;~ +getCategories() Observable~#123; items: InvimaCategory[]; #125;~ +getFilterOptions() Observable~InvimaFiltersResponse~ +searchCatalog() Observable~InvimaCatalogResponse~ +sync() Observable~InvimaSyncResponse~ +getSyncStats() Observable~InvimaSyncStats~ +getSyncJob() Observable~#123; ok: boolean; job: InvimaSyncJob | null; #125;~ +exportCatalog() Observable~InvimaExportFile~ +buildExportUrl() string } class CatalogQuery { <> +q?: string +categorias?: string[] +datasets?: string[] +estados?: string[] +modalidades?: string[] +grupos?: string[] +fechaVencDesde?: string +fechaVencHasta?: string +fechaExpDesde?: string +fechaExpHasta?: string +onlyVigentes?: boolean +limit?: number +offset?: number } class InvimaExportFile { <> +blob: Blob +filename: string +contentType: string } class LoginPageComponent{ -auth: AuthService -router: Router -route: ActivatedRoute +loading: WritableSignal~boolean~ +error: WritableSignal~string~ +identifier: string +password: string +submit() void } LoginPageComponent -- AuthService class DashboardPageComponent{ -auth: AuthService +fullName: Signal~string~ } DashboardPageComponent -- AuthService class CatalogPageComponent{ -invima: InvimaService +loading: WritableSignal~boolean~ +error: WritableSignal~string~ +exporting: WritableSignal~"csv" | "xlsx" | null~ +exportMessage: WritableSignal~string~ +exportError: WritableSignal~string~ +items: WritableSignal~InvimaCatalogItem[]~ +total: WritableSignal~number~ +categories: WritableSignal~InvimaCategory[]~ +datasets: WritableSignal~InvimaDataset[]~ +estados: WritableSignal~InvimaFilterOption[]~ +modalidades: WritableSignal~InvimaFilterOption[]~ +grupos: WritableSignal~InvimaFilterOption[]~ +selectedCategories: WritableSignal~string[]~ +selectedDatasets: WritableSignal~string[]~ +limit: WritableSignal~number~ +offset: WritableSignal~number~ +page: Signal~number~ +hasMore: Signal~boolean~ +qText: string +estado: string +modalidad: string +grupo: string +fechaVencDesde: string +fechaVencHasta: string +fechaExpDesde: string +fechaExpHasta: string +onlyVigentes: boolean -buildQuery() CatalogQuery -loadMetadata() void -loadData() void +applyFilters() void +clearFilters() void +toggleCategory() void +isCategorySelected() boolean +toggleDataset() void +isDatasetSelected() boolean +changeLimit() void +nextPage() void +prevPage() void +download() void +formatDate() string +isVigente() boolean +isVencido() boolean } CatalogPageComponent -- InvimaService class SyncPageComponent{ -invima: InvimaService -refreshMs: 10000 -refreshHandle: number | null -announcedFinalJobId: string | null +loading: WritableSignal~boolean~ +message: WritableSignal~string~ +statsError: WritableSignal~string~ +jobError: WritableSignal~string~ +datasets: WritableSignal~InvimaDataset[]~ +selected: WritableSignal~string[]~ +stats: WritableSignal~InvimaSyncStats | null~ +job: WritableSignal~InvimaSyncJob | null~ +baselineTotal: WritableSignal~number | null~ +baselineByDataset: WritableSignal~Record~string, number~~ +liveAdded: Signal~number~ +pageSize: number +concurrency: number +ngOnDestroy() void -loadStats() void -loadJob() void -ensureBaseline() void -refreshLiveMessage() void -buildFinalMessage() string -captureBaselineFromCurrentStats() void +datasetDelta() number +formatNumber() string +formatDateTime() string +toggle() void +isSelected() boolean +selectAll() void +sync() void } OnDestroy<|..SyncPageComponent SyncPageComponent -- InvimaService class MainLayoutComponent{ +auth: AuthService -router: Router +menu: MenuItem[] +initials: Signal~string~ +logout() void } class MenuItem { <> +label: string +path: string +hint: string } MainLayoutComponent -- AuthService MainLayoutComponent -- "0..*" MenuItem