From 3c42d7e237f6230249fea858f2dffaba4dbc5e1e Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sat, 30 May 2026 08:35:55 +0300 Subject: [PATCH 01/10] Create vertex-template-coupling-audit.md --- .../vertex-template-coupling-audit.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/vertex/app/_docs/internal/vertex-template-coupling-audit.md diff --git a/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md b/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md new file mode 100644 index 0000000000..df3f2136ca --- /dev/null +++ b/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md @@ -0,0 +1,137 @@ +# Vertex Template Coupling Audit + +## Purpose + +This audit maps the current Vertex app coupling that must be addressed before extracting a reusable `vertex-template` and publishing `create-vertex-app`. + +The current app is a Next.js App Router project. Routes live in `app/`, feature UI in `components/features/`, layout in `components/layout/`, API services in `core/apis/`, React Query hooks in `core/hooks/`, auth/proxy routes in `app/api/`, and shared state/config in `core/`, `context`, and `lib`. + +## Summary + +V1 should not rewrite Vertex. It should isolate existing behavior behind configuration and adapters. + +Primary coupling areas: + +- AirQo branding in metadata, layout, login, errors, download, footer, topbar, title bar, app launcher, and support links. +- AirQo API access through `NEXT_PUBLIC_API_URL`, proxy routes, `secureApiProxyClient`, and `core/apis/*`. +- AirQo group/network assumptions in auth, Redux user state, permissions, hooks, and device/site filtering. +- Hardcoded public measurement endpoint examples. +- Map defaults and Mapbox-only assumptions. +- Optional AirQo/ops features that should be feature-flagged or excluded from v1 template defaults. + +V1 adapter support should be `mock` and `airqo` only. Generic REST remains v2. + +## Priority Legend + +- `P0`: Required before template extraction. +- `P1`: Required before public v1 release, can follow adapter foundation. +- `P2`: Can remain in AirQo mode or be deferred if hidden by feature flags. +- `Keep`: Intentional dependency or acceptable package/library usage. + +## Coupling Inventory + +| Area | Files | Current coupling | Replacement | Priority | +|---|---|---|---|---| +| App metadata | `app/layout.tsx` | Hardcoded `AirQo Vertex`, AirQo description, `vertex.airqo.net`, AirQo image path | Read org/app metadata from `vertexConfig.org` and template defaults | P0 | +| Page title provider | `context/page-title-context.tsx` | `APP_TITLE = "AirQo Vertex"` | Use configured app/org title | P0 | +| Login branding | `app/login/page.tsx` | AirQo logo and copy about AirQo open data channels | Use configured logo, org name, support/value copy | P0 | +| Auth error page | `app/auth-error/page.tsx` | AirQo logo, `support@airqo.net`, AirQo copyright | Use config support email, logo, org name | P0 | +| Topbar | `components/layout/topbar.tsx` | AirQo logo, account labels | Use configured logo and neutral account labels | P0 | +| Primary sidebar | `components/layout/primary-sidebar.tsx` | AirQo logo, Vertex label, AirQo admin role names | Logo from config; admin visibility from config and permission mode | P1 | +| Desktop titlebar | `components/layout/desktop-titlebar.tsx` | AirQo logo fallback and `AirQo Vertex` text | Use config branding and generic fallback | P1 | +| Footer | `components/layout/Footer.tsx` | AirQo copyright and platform name | Config org name and app name | P1 | +| App launcher | `components/layout/AppDropdown.tsx` | AirQo ecosystem links, AirQo app store copy | Hide by default or make AirQo-mode-only feature | P2 | +| Download page | `app/download/page.tsx`, `components/features/download/*`, `core/constants/app-downloads.ts` | AirQo Vertex Desktop release URL/copy | Feature-flag desktop download; config-driven URL if enabled | P2 | +| Cookie banner | `components/features/auth/cookie-info-banner.tsx`, `lib/envConstants.ts` | AirQo cookie copy and default AirQo policy URL | Configurable policy URL and org name | P1 | +| Feedback | `components/features/feedback/*` | Event key `airqo:feedback:open`, "Send feedback to AirQo" | Configurable org name; event key can be generic | P1 | +| Public support links | `app/not-found.tsx`, `components/ui/permission-tooltip.tsx` | AirQo support/docs links | Configurable support and docs URLs | P1 | +| Theme color | `app/globals.css`, `tailwind.config.ts` | Primary blue is hardcoded in CSS variables | Inject/configure `org.primaryColor`; keep Tailwind variable pattern | P0 | +| API base URLs | `lib/envConstants.ts`, `core/urls.tsx`, `core/config/proxyConfig.ts` | Requires `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_API_TOKEN`; Analytics default is AirQo | Move API base URL/token requirements behind adapter mode | P0 | +| Axios/proxy client | `core/utils/secureApiProxyClient.ts`, `core/utils/proxyClient.ts`, `core/apis/axiosConfig.ts` | AirQo proxy behavior and auth headers | Keep for AirQo adapter; mock adapter bypasses it | P0 | +| Dynamic proxy route | `app/api/[...path]/route.ts` | Proxies to configured AirQo-style backend path | Keep AirQo-mode only; avoid requiring in mock mode | P1 | +| Devices service | `core/apis/devices.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter; do not rewrite endpoints in v1 | P0 | +| Sites service | `core/apis/sites.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter | P0 | +| Cohorts service | `core/apis/cohorts.ts` | Direct AirQo endpoint paths | Wrap needed methods via adapter | P0 | +| Networks service | `core/apis/networks.ts`, `core/services/network-service.ts` | AirQo users/networks endpoints and `ADMIN_SECRET` flows | AirQo adapter or AirQo-mode-only admin feature | P1 | +| Grids service | `core/apis/grids.ts` | Direct backend paths | Include only if grid feature remains enabled; otherwise flag | P2 | +| Users/auth service | `core/apis/users.ts`, `app/api/auth/[...nextauth]/*` | AirQo login/profile/session shape | Keep for AirQo mode; mock/no-auth path needed for template evaluation | P0 | +| Cloudinary | `core/apis/cloudinary.ts`, `app/api/cloudinary/upload/route.ts` | Requires Cloudinary env vars | Feature-flag uploads or make optional config | P2 | +| Slack logging | `app/api/log-to-slack/route.ts`, `lib/logger.ts` | Slack webhook route | Keep optional; disabled when env missing | P2 | +| Mapbox | `components/features/mini-map/mini-map.tsx`, `components/features/location-autocomplete/LocationAutocomplete.tsx` | Mapbox token, Mapbox geocoding, Kampala default center | Config map provider, default center/zoom, optional Mapbox token | P1 | +| Measurement examples | `components/features/devices/device-measurements-api-card.tsx`, `components/features/sites/site-measurements-api-card.tsx`, `components/features/cohorts/cohort-measurements-api-card.tsx`, `components/features/grids/grid-measurements-api-card.tsx` | Hardcoded `https://api.airqo.net/api/v2/...` examples | Build URLs from config/API docs base; hide in mock mode if misleading | P1 | +| Auth default org | `core/auth/authProvider.tsx` | Prefers group named `airqo` as default organization | Adapter/config-defined system group or AirQo-mode-only logic | P0 | +| Redux user context | `core/redux/slices/userSlice.ts` | Treats `airqo` group as personal/elevated context | Abstract into configured system group or mock local context | P0 | +| Permissions | `core/permissions/*`, `core/hooks/usePermissions.ts` | AirQo RBAC names and AirQo group fallback | Keep AirQo mode; add simple mock/no-auth permissions for v1 evaluation | P0 | +| Device hooks | `core/hooks/useDevices.ts` | Imports `devices` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Site hooks | `core/hooks/useSites.ts` | Imports `sites` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Network hooks | `core/hooks/useNetworks.ts` | Sorts `airqo` network first; imports APIs directly | Adapter-backed data and config-defined preferred network | P1 | +| User context hook | `core/hooks/useUserContext.ts` | AirQo-specific personal/org scope comments and assumptions | Configurable auth/permission mode | P1 | +| Device claim/import | `components/features/claim/*`, `components/features/devices/import-device-modal.tsx` | Claim AirQo Device copy, reserved `airqo` cohort, AirQo device placeholders | Configurable device vocabulary; reserved names AirQo-mode only | P1 | +| Device deploy | `components/features/devices/deploy-device-component.tsx` | Direct fetch to `/api/v2/devices/my-devices?claim_status=claimed`; default network `airqo` | Move data call into hook/adapter; default network from config/current context | P0 | +| Device create/admin | `components/features/devices/create-device-modal.tsx`, `app/(authenticated)/admin/networks/[id]/page.tsx` | "Add AirQo Device" copy and AirQo network branch | Configurable copy; AirQo branch only in AirQo mode | P1 | +| Public visibility copy | `components/features/home/network-visibility-card.tsx`, `components/features/cohorts/cohort-detail-card.tsx` | "AirQo Map" copy | Configurable public map/product name | P1 | +| Empty states | `components/features/home/HomeEmptyState.tsx`, `components/features/cohorts/cohorts-empty-state.tsx` | AirQo device copy | Configurable device terminology | P1 | +| Shipping labels | `components/features/shipping/*` | AirQo Air Quality Monitor label text | AirQo-mode only or config-driven label brand | P2 | +| Network request flows | `components/features/networks/*`, `app/api/network/route.ts`, `app/api/devices/network-creation-requests/*` | AirQo backend admin-secret network creation | AirQo-mode admin feature; not required for mock default | P2 | +| Query/cache keys | `core/providers/query-provider.tsx`, `core/utils/clientCache.ts` | `airqo:vertex:*` storage keys | Generic `vertex:*` or config namespace to avoid cross-instance collisions | P1 | +| Internal docs/changelog | `app/changelog.md`, `app/_docs/internal/*`, `app/_docs/deprecated/*` | AirQo internal history and architecture | Exclude or heavily sanitize from `vertex-template` | P2 | + +## Direct API Call Notes + +Most backend access is already centralized, which is good for adapter extraction: + +- `core/apis/devices.ts` +- `core/apis/sites.ts` +- `core/apis/cohorts.ts` +- `core/apis/grids.ts` +- `core/apis/networks.ts` +- `core/apis/users.ts` +- `core/apis/roles.ts` +- `core/apis/permissions.ts` +- `core/apis/organizations.ts` + +The main component-level exceptions found are: + +- `components/features/devices/deploy-device-component.tsx`: direct `fetch("/api/v2/devices/my-devices?claim_status=claimed")` +- `components/features/networks/create-network-form.tsx`: posts to `/api/network` +- `components/features/networks/network-request-dialog.tsx`: posts to `/api/devices/network-creation-requests` +- `components/features/mini-map/mini-map.tsx`: fetches Mapbox reverse geocoding +- `components/features/location-autocomplete/LocationAutocomplete.tsx`: fetches Mapbox suggestions +- Measurement API card components hardcode public AirQo API examples. + +## Recommended Migration Order + +1. Add typed config and defaults. +2. Add adapter interface and mock adapter. +3. Add AirQo adapter wrapping existing `core/apis`. +4. Refactor `core/hooks` to use adapter-backed access. +5. Remove component-level direct backend calls or move them behind hooks/adapters. +6. Config-drive branding, metadata, theme, map defaults, support links, and feature flags. +7. Hide or AirQo-mode-gate admin/network/shipping/download/app-launcher features. +8. Sanitize docs and extract template. + +## Out of Scope For V1 + +- Generic REST adapter. +- Production deploy wizard. +- Plugin system. +- Managed hosting. +- Template upgrade command. +- Admin UI for config. +- Replacing `@airqo/icons-react` solely because of package name; icon imports can stay unless brand/legal review requires removal. + +## Open Questions For Maintainers + +- Should `@airqo/icons-react` remain a public dependency in the generic template? +- Should desktop download and shipping workflows ship disabled by default or be excluded from template v1? +- What is the approved default mock organization name, logo, and primary color? +- Should mock mode use `auth.provider = "none"` with a synthetic local user, or keep NextAuth with seeded credentials? +- Should public measurement API cards appear in mock mode, or only in AirQo mode? + +## Contributor Guidance + +This audit is documentation only. Follow-up implementation issues should avoid broad file ownership conflicts: + +- Core maintainers should own config, adapter contracts, auth, and hook refactors. +- Open-source contributors can safely work on branding copy, measurement cards, docs, fixture data, and feature-flagged UI after the config contract lands. +- PRs should touch narrow areas and avoid project-wide formatting. From 0f3af61ee97f26eeb5c7f09ecb4b5b415c1eed67 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sat, 30 May 2026 08:43:06 +0300 Subject: [PATCH 02/10] Add NEXT_PUBLIC_API_BASE_URL to .env.example and update README.md with configuration details --- src/vertex/.env.example | 1 + src/vertex/README.md | 14 ++ .../vertex-template-config-contract.md | 47 ++++++ src/vertex/core/config/vertex-config.ts | 158 ++++++++++++++++++ src/vertex/vertex.config.example.ts | 54 ++++++ src/vertex/vertex.config.ts | 60 +++++++ 6 files changed, 334 insertions(+) create mode 100644 src/vertex/app/_docs/internal/vertex-template-config-contract.md create mode 100644 src/vertex/core/config/vertex-config.ts create mode 100644 src/vertex/vertex.config.example.ts create mode 100644 src/vertex/vertex.config.ts diff --git a/src/vertex/.env.example b/src/vertex/.env.example index fb0189198a..225d394923 100644 --- a/src/vertex/.env.example +++ b/src/vertex/.env.example @@ -7,6 +7,7 @@ SLACK_WEBHOOK_URL= NEXT_PUBLIC_API_TOKEN= NEXT_PUBLIC_API_URL=https://staging-analytics.airqo.net/api/v2/ +NEXT_PUBLIC_API_BASE_URL=https://api.airqo.net NEXT_PUBLIC_ENV=development NEXT_PUBLIC_ANALYTICS_URL=https://staging-analytics.airqo.net NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL= diff --git a/src/vertex/README.md b/src/vertex/README.md index b93c13aea4..7da82419fa 100644 --- a/src/vertex/README.md +++ b/src/vertex/README.md @@ -41,6 +41,7 @@ npm run dev Use `src/vertex/.env.example` as the base. Common variables include: - `NEXT_PUBLIC_API_URL`: Backend API base URL. +- `NEXT_PUBLIC_API_BASE_URL`: Public API origin used for measurement URL examples. - `NEXT_PUBLIC_ANALYTICS_URL`: Analytics platform URL. - `NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL`: Optional Windows installer URL for Vertex Desktop downloads. - `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`: Mapbox token for map features. @@ -53,6 +54,19 @@ Use `src/vertex/.env.example` as the base. Common variables include: - `SLACK_WEBHOOK_URL`: Slack webhook for server-side notifications. - `NEXT_PUBLIC_SLACK_BOT_TOKEN`, `NEXT_PUBLIC_SLACK_CHANNEL`: Slack client configuration. +## Vertex configuration + +Vertex has a typed app configuration surface in `vertex.config.ts`. This is the file that the future `create-vertex-app` CLI will generate or update for each scaffolded instance. + +V1 supports two data adapters: + +- `mock`: runs locally without API credentials and is the default for generated templates. +- `airqo`: uses the existing AirQo API, auth, and proxy behavior. + +Generic REST backends are intentionally out of scope for v1. + +Use `vertex.config.example.ts` as the template-facing reference. Keep validation and shared types in `core/config/vertex-config.ts` so contributors can add config-driven behavior without inventing new config shapes. + ## Authentication and SSO For normal app-local authentication, set: diff --git a/src/vertex/app/_docs/internal/vertex-template-config-contract.md b/src/vertex/app/_docs/internal/vertex-template-config-contract.md new file mode 100644 index 0000000000..8a7c2fe7a2 --- /dev/null +++ b/src/vertex/app/_docs/internal/vertex-template-config-contract.md @@ -0,0 +1,47 @@ +# Vertex Template Config Contract + +## Purpose + +`vertex.config.ts` is the single deployer-owned configuration file for a Vertex instance. The future `create-vertex-app` CLI should generate this file from wizard answers. + +Shared validation, defaults, and TypeScript types live in `core/config/vertex-config.ts`. + +## V1 Defaults + +V1 supports only: + +- `api.adapter: "mock"` for local evaluation without credentials. +- `api.adapter: "airqo"` for the current AirQo API-backed app. +- `auth.provider: "none"` for mock/local template evaluation. +- `auth.provider: "airqo"` for the existing AirQo auth/session flow. + +Generic REST adapters, plugin systems, deploy wizards, and config admin screens are v2 work. + +## Required Config Groups + +- `org`: visible organization identity, logo, primary color, support email, website, and slug. +- `api`: adapter choice, AirQo API base URL when applicable, and public measurement URL base. +- `auth`: auth provider and system group slug used by AirQo-mode permission logic. +- `features`: v1 feature flags for maps, bulk deploy, sites, CSV export, readings, user management, desktop download, app launcher, shipping, and network requests. +- `map`: default center, zoom, and tile provider. +- `links`: docs, privacy, cookie policy, analytics, and desktop download URLs. + +## Validation Rules + +- Organization name, short name, slug, logo, primary color, and support email must be valid. +- `org.primaryColor` must be a hex color. +- `api.adapter` must be `mock` or `airqo`. +- `auth.provider` must be `none` or `airqo`. +- `api.baseUrl` is required when `api.adapter` is `airqo`. +- `auth.provider: "airqo"` requires `api.adapter: "airqo"`. +- Map latitude must be between -90 and 90. +- Map longitude must be between -180 and 180. +- Map zoom must be between 0 and 22. + +## Contributor Guidance + +- Do not add new top-level config groups without maintainer approval. +- Prefer adding feature-specific options under an existing group. +- Keep `vertex.config.example.ts` mock-first. +- Keep `vertex.config.ts` AirQo-compatible until template extraction is complete. +- Do not implement the generic REST adapter in v1. diff --git a/src/vertex/core/config/vertex-config.ts b/src/vertex/core/config/vertex-config.ts new file mode 100644 index 0000000000..54afbe113b --- /dev/null +++ b/src/vertex/core/config/vertex-config.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; + +const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/; + +const adapterSchema = z.enum(["mock", "airqo"]); +const authProviderSchema = z.enum(["none", "airqo"]); +const mapTileProviderSchema = z.enum(["openstreetmap", "mapbox"]); + +const nonEmptyString = (fieldName: string) => + z.string().trim().min(1, `${fieldName} is required`); + +const optionalUrl = z.string().url().optional().or(z.literal("")); + +const vertexConfigSchema = z + .object({ + org: z.object({ + name: nonEmptyString("Organization name"), + shortName: nonEmptyString("Organization short name"), + slug: z + .string() + .trim() + .regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + "Organization slug must use lowercase letters, numbers, and hyphens", + ), + logo: nonEmptyString("Organization logo"), + primaryColor: z + .string() + .trim() + .regex(HEX_COLOR_PATTERN, "Primary color must be a valid hex color"), + supportEmail: z.string().trim().email("Support email must be valid"), + websiteUrl: optionalUrl, + }), + api: z.object({ + adapter: adapterSchema, + baseUrl: optionalUrl, + publicMeasurementsBaseUrl: optionalUrl, + }), + auth: z.object({ + provider: authProviderSchema, + systemGroupSlug: z.string().trim().min(1).optional(), + }), + features: z.object({ + deviceMap: z.boolean(), + bulkDeploy: z.boolean(), + siteManagement: z.boolean(), + exportCSV: z.boolean(), + readingHistory: z.boolean(), + userManagement: z.boolean(), + desktopDownload: z.boolean(), + appLauncher: z.boolean(), + shipping: z.boolean(), + networkRequests: z.boolean(), + }), + map: z.object({ + defaultCenter: z + .tuple([z.number(), z.number()]) + .refine( + ([latitude]) => latitude >= -90 && latitude <= 90, + "Map latitude must be between -90 and 90", + ) + .refine( + ([, longitude]) => longitude >= -180 && longitude <= 180, + "Map longitude must be between -180 and 180", + ), + defaultZoom: z.number().min(0).max(22), + tileProvider: mapTileProviderSchema, + }), + links: z.object({ + docsUrl: optionalUrl, + privacyPolicyUrl: optionalUrl, + cookiePolicyUrl: optionalUrl, + analyticsUrl: optionalUrl, + desktopDownloadUrl: optionalUrl, + }), + }) + .superRefine((config, ctx) => { + if (config.api.adapter === "airqo" && !config.api.baseUrl?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["api", "baseUrl"], + message: "api.baseUrl is required when api.adapter is airqo", + }); + } + + if (config.auth.provider === "airqo" && config.api.adapter !== "airqo") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["auth", "provider"], + message: "auth.provider airqo requires api.adapter airqo", + }); + } + }); + +export type VertexApiAdapter = z.infer; +export type VertexAuthProvider = z.infer; +export type VertexMapTileProvider = z.infer; +export type VertexConfig = z.infer; +export type VertexConfigInput = z.input; + +export const defaultVertexConfig: VertexConfigInput = { + org: { + name: "Vertex Demo", + shortName: "Vertex", + slug: "vertex-demo", + logo: "/images/airqo_logo.svg", + primaryColor: "#145FFF", + supportEmail: "support@example.org", + websiteUrl: "", + }, + api: { + adapter: "mock", + baseUrl: "", + publicMeasurementsBaseUrl: "", + }, + auth: { + provider: "none", + systemGroupSlug: "system", + }, + features: { + deviceMap: true, + bulkDeploy: true, + siteManagement: true, + exportCSV: true, + readingHistory: false, + userManagement: false, + desktopDownload: false, + appLauncher: false, + shipping: false, + networkRequests: false, + }, + map: { + defaultCenter: [0.3476, 32.5825], + defaultZoom: 11, + tileProvider: "openstreetmap", + }, + links: { + docsUrl: "", + privacyPolicyUrl: "", + cookiePolicyUrl: "", + analyticsUrl: "", + desktopDownloadUrl: "", + }, +}; + +export function validateVertexConfig(config: VertexConfigInput): VertexConfig { + const result = vertexConfigSchema.safeParse(config); + + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`) + .join("; "); + + throw new Error(`Invalid Vertex configuration. ${details}`); + } + + return result.data; +} diff --git a/src/vertex/vertex.config.example.ts b/src/vertex/vertex.config.example.ts new file mode 100644 index 0000000000..8cc1cc1ebd --- /dev/null +++ b/src/vertex/vertex.config.example.ts @@ -0,0 +1,54 @@ +import { + defaultVertexConfig, + validateVertexConfig, + type VertexConfigInput, +} from "./core/config/vertex-config"; + +const config: VertexConfigInput = { + ...defaultVertexConfig, + org: { + name: "KCCA Air Quality", + shortName: "KCCA", + slug: "kcca-air", + logo: "/logo.png", + primaryColor: "#00A86B", + supportEmail: "support@kcca.go.ug", + websiteUrl: "https://www.kcca.go.ug", + }, + api: { + adapter: "mock", + baseUrl: "", + publicMeasurementsBaseUrl: "", + }, + auth: { + provider: "none", + systemGroupSlug: "system", + }, + features: { + deviceMap: true, + bulkDeploy: true, + siteManagement: true, + exportCSV: true, + readingHistory: false, + userManagement: false, + desktopDownload: false, + appLauncher: false, + shipping: false, + networkRequests: false, + }, + map: { + defaultCenter: [0.3476, 32.5825], + defaultZoom: 11, + tileProvider: "openstreetmap", + }, + links: { + docsUrl: "", + privacyPolicyUrl: "", + cookiePolicyUrl: "", + analyticsUrl: "", + desktopDownloadUrl: "", + }, +}; + +export const vertexConfig = validateVertexConfig(config); +export default vertexConfig; diff --git a/src/vertex/vertex.config.ts b/src/vertex/vertex.config.ts new file mode 100644 index 0000000000..b427a63c67 --- /dev/null +++ b/src/vertex/vertex.config.ts @@ -0,0 +1,60 @@ +import { + defaultVertexConfig, + validateVertexConfig, + type VertexConfigInput, +} from "./core/config/vertex-config"; + +const config: VertexConfigInput = { + ...defaultVertexConfig, + org: { + name: "AirQo Vertex", + shortName: "AirQo", + slug: "airqo", + logo: "/images/airqo_logo.svg", + primaryColor: "#145FFF", + supportEmail: "support@airqo.net", + websiteUrl: "https://airqo.net", + }, + api: { + adapter: "airqo", + baseUrl: process.env.NEXT_PUBLIC_API_URL, + publicMeasurementsBaseUrl: + process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.airqo.net", + }, + auth: { + provider: "airqo", + systemGroupSlug: "airqo", + }, + features: { + deviceMap: true, + bulkDeploy: true, + siteManagement: true, + exportCSV: true, + readingHistory: false, + userManagement: false, + desktopDownload: true, + appLauncher: true, + shipping: true, + networkRequests: true, + }, + map: { + defaultCenter: [0.3476, 32.5825], + defaultZoom: 11, + tileProvider: "mapbox", + }, + links: { + docsUrl: "https://docs.airqo.net", + privacyPolicyUrl: "https://airqo.net/legal/privacy", + cookiePolicyUrl: + process.env.NEXT_PUBLIC_COOKIE_POLICY_URL || + "https://airqo.net/legal/cookies", + analyticsUrl: + process.env.NEXT_PUBLIC_ANALYTICS_URL || + "https://staging-analytics.airqo.net", + desktopDownloadUrl: + process.env.NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL || "", + }, +}; + +export const vertexConfig = validateVertexConfig(config); +export default vertexConfig; From 10761607b9d82b5a54b1fd1bb8ebdaa5cd1dd412 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sat, 30 May 2026 08:55:53 +0300 Subject: [PATCH 03/10] Add Vertex config and adapter foundation --- .../vertex-template-adapter-foundation.md | 43 ++ src/vertex/core/adapters/airqo.ts | 44 ++ src/vertex/core/adapters/index.ts | 24 + src/vertex/core/adapters/mock-fixtures.ts | 471 +++++++++++++++ src/vertex/core/adapters/mock.ts | 568 ++++++++++++++++++ src/vertex/core/adapters/types.ts | 208 +++++++ 6 files changed, 1358 insertions(+) create mode 100644 src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md create mode 100644 src/vertex/core/adapters/airqo.ts create mode 100644 src/vertex/core/adapters/index.ts create mode 100644 src/vertex/core/adapters/mock-fixtures.ts create mode 100644 src/vertex/core/adapters/mock.ts create mode 100644 src/vertex/core/adapters/types.ts diff --git a/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md b/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md new file mode 100644 index 0000000000..562847ee00 --- /dev/null +++ b/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md @@ -0,0 +1,43 @@ +# Vertex Adapter Foundation + +## Purpose + +Issue 3 introduces the adapter foundation for `create-vertex-app` v1. It defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials. + +## Files + +- `core/adapters/types.ts`: v1 adapter contract. +- `core/adapters/index.ts`: adapter factory selected by `vertex.config.ts`. +- `core/adapters/mock.ts`: mock adapter implementation. +- `core/adapters/mock-fixtures.ts`: typed fixture data. +- `core/adapters/airqo.ts`: Issue 4 placeholder for the AirQo wrapper. + +## V1 Adapter Choices + +- `mock`: implemented now and intended as the generated template default. +- `airqo`: factory-supported, but methods intentionally throw until Issue 4 maps existing `core/apis/*` services into the contract. + +Generic REST remains out of scope for v1. + +## Integration Boundary + +This foundation does not refactor existing React Query hooks yet. Issue 5 should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components. + +## Mock Adapter Coverage + +The mock adapter includes: + +- Seeded user, group, role, and network data. +- Four devices covering online, offline, private, deployed, and undeployed states. +- Three sites with coordinates and status variety. +- Two cohorts with device membership. +- Device and site activity events. +- Latest and historical reading data. +- Successful no-op mutations for claim, deploy, recall, create, update, maintenance, and group assignment flows. + +## Contributor Guidance + +- Keep fixture data realistic but small. +- Add adapter methods only when current v1 screens need them. +- Keep AirQo endpoint mapping out of `mock.ts`; Issue 4 owns `airqo.ts`. +- Do not add REST adapter behavior in v1. diff --git a/src/vertex/core/adapters/airqo.ts b/src/vertex/core/adapters/airqo.ts new file mode 100644 index 0000000000..7e23471aa3 --- /dev/null +++ b/src/vertex/core/adapters/airqo.ts @@ -0,0 +1,44 @@ +import type { VertexAdapter } from "./types"; + +function issue4Method(methodName: string) { + return async () => { + throw new Error( + `AirQo adapter method "${methodName}" will be implemented in Issue 4.`, + ); + }; +} + +export function createAirQoAdapter(): VertexAdapter { + return { + getCurrentUser: issue4Method("getCurrentUser"), + getDevices: issue4Method("getDevices"), + getDevicesByCohorts: issue4Method("getDevicesByCohorts"), + getDevicesByStatus: issue4Method("getDevicesByStatus"), + getDevice: issue4Method("getDevice"), + getMyDevices: issue4Method("getMyDevices"), + getDeviceCount: issue4Method("getDeviceCount"), + checkDeviceAvailability: issue4Method("checkDeviceAvailability"), + claimDevice: issue4Method("claimDevice"), + deployDevice: issue4Method("deployDevice"), + recallDevice: issue4Method("recallDevice"), + createDevice: issue4Method("createDevice"), + updateDevice: issue4Method("updateDevice"), + addMaintenanceLog: issue4Method("addMaintenanceLog"), + updateDeviceGroup: issue4Method("updateDeviceGroup"), + getDeviceActivities: issue4Method("getDeviceActivities"), + getSites: issue4Method("getSites"), + getSitesByCohorts: issue4Method("getSitesByCohorts"), + getSitesByStatus: issue4Method("getSitesByStatus"), + getSite: issue4Method("getSite"), + createSite: issue4Method("createSite"), + getSitesSummaryCount: issue4Method("getSitesSummaryCount"), + getSiteActivities: issue4Method("getSiteActivities"), + getCohorts: issue4Method("getCohorts"), + getCohort: issue4Method("getCohort"), + getGroupCohorts: issue4Method("getGroupCohorts"), + getOriginalCohort: issue4Method("getOriginalCohort"), + getNetworks: issue4Method("getNetworks"), + getLatestReadings: issue4Method("getLatestReadings"), + getReadingHistory: issue4Method("getReadingHistory"), + } as VertexAdapter; +} diff --git a/src/vertex/core/adapters/index.ts b/src/vertex/core/adapters/index.ts new file mode 100644 index 0000000000..81cd3cf346 --- /dev/null +++ b/src/vertex/core/adapters/index.ts @@ -0,0 +1,24 @@ +import { vertexConfig } from "@/vertex.config"; +import type { VertexApiAdapter } from "@/core/config/vertex-config"; +import type { VertexAdapter } from "./types"; +import { createAirQoAdapter } from "./airqo"; +import { createMockAdapter } from "./mock"; + +export function createAdapter( + adapterName: VertexApiAdapter = vertexConfig.api.adapter, +): VertexAdapter { + switch (adapterName) { + case "mock": + return createMockAdapter(); + case "airqo": + return createAirQoAdapter(); + default: { + const exhaustiveCheck: never = adapterName; + throw new Error(`Unsupported Vertex adapter: ${exhaustiveCheck}`); + } + } +} + +export const adapter = createAdapter(); + +export type { VertexAdapter } from "./types"; diff --git a/src/vertex/core/adapters/mock-fixtures.ts b/src/vertex/core/adapters/mock-fixtures.ts new file mode 100644 index 0000000000..2d84cb748e --- /dev/null +++ b/src/vertex/core/adapters/mock-fixtures.ts @@ -0,0 +1,471 @@ +import type { Cohort } from "@/app/types/cohorts"; +import type { Device, PaginationMeta } from "@/app/types/devices"; +import type { Site } from "@/app/types/sites"; +import type { UserDetails } from "@/app/types/users"; +import type { Network } from "@/core/apis/networks"; +import type { DeviceActivity } from "@/core/apis/devices"; +import type { Reading } from "./types"; + +const now = "2026-05-30T09:00:00.000Z"; +const yesterday = "2026-05-29T09:00:00.000Z"; + +export const mockPaginationMeta: PaginationMeta = { + total: 0, + totalResults: 0, + limit: 100, + skip: 0, + page: 1, + totalPages: 1, + detailLevel: "summary", + usedCache: false, +}; + +export const mockRole = { + _id: "role-demo-admin", + role_name: "DEMO_ADMIN", + role_permissions: [ + { _id: "perm-device-view", permission: "DEVICE.VIEW" }, + { _id: "perm-device-deploy", permission: "DEVICE.DEPLOY" }, + { _id: "perm-site-view", permission: "SITE.VIEW" }, + { _id: "perm-site-create", permission: "SITE.CREATE" }, + ], +}; + +export const mockNetworks: Network[] = [ + { + _id: "network-demo", + net_status: "active", + net_acronym: "DEMO", + net_name: "demo-network", + net_email: "support@example.org", + net_website: "https://example.org", + net_phoneNumber: "+256700000000", + net_category: "research", + net_description: "Demo sensor network for local Vertex evaluation.", + net_profile_picture: "", + createdAt: "2026-01-01T00:00:00.000Z", + net_manager: { + _id: "user-demo-admin", + email: "admin@example.org", + firstName: "Demo", + lastName: "Admin", + group_roles: [], + isActive: true, + lastLogin: now, + verified: true, + description: "Demo administrator", + jobTitle: "Platform Manager", + website: "https://example.org", + loginCount: 1, + analyticsVersion: 4, + preferredTokenStrategy: "jwt", + }, + net_users: [], + net_permissions: [], + net_roles: [], + net_departments: [], + }, +]; + +export const mockUser: UserDetails = { + _id: "user-demo-admin", + firstName: "Demo", + lastName: "Admin", + lastLogin: now, + isActive: true, + loginCount: 1, + userName: "demo.admin", + email: "admin@example.org", + verified: true, + analyticsVersion: 4, + country: "Uganda", + privilege: "admin", + website: "https://example.org", + category: "research", + organization: "Vertex Demo", + long_organization: "Vertex Demo", + rateLimit: null, + jobTitle: "Platform Manager", + description: "Seeded mock user for local Vertex evaluation.", + timezone: "Africa/Kampala", + profilePicture: null, + phoneNumber: null, + updatedAt: now, + networks: [ + { + _id: "network-demo", + net_name: "demo-network", + role: mockRole, + userType: "admin", + createdAt: "2026-01-01T00:00:00.000Z", + status: "active", + }, + ], + clients: [], + groups: [ + { + _id: "group-demo", + grp_title: "system", + createdAt: "2026-01-01T00:00:00.000Z", + status: "active", + role: mockRole, + userType: "admin", + }, + ], + permissions: mockRole.role_permissions, + createdAt: "2026-01-01T00:00:00.000Z", + my_networks: ["network-demo"], + my_groups: ["group-demo"], + group_ids: ["group-demo"], + cohort_ids: ["cohort-central", "cohort-schools"], +}; + +export const mockSites: Site[] = [ + { + _id: "site-central", + name: "central-library", + formatted_name: "Central Library", + location_name: "Central Library, Kampala", + search_name: "Central Library Kampala", + city: "Kampala", + district: "Kampala", + region: "Central", + country: "Uganda", + latitude: 0.3136, + longitude: 32.5811, + network: "demo-network", + groups: ["system"], + site_tags: ["urban", "roadside"], + isOnline: true, + rawOnlineStatus: true, + createdAt: "2026-01-10T00:00:00.000Z", + lastActive: now, + description: "Urban monitoring site near the central library.", + devices: [], + }, + { + _id: "site-school", + name: "green-hill-school", + formatted_name: "Green Hill School", + location_name: "Green Hill School, Kampala", + search_name: "Green Hill School Kampala", + city: "Kampala", + district: "Kampala", + region: "Central", + country: "Uganda", + latitude: 0.3368, + longitude: 32.6015, + network: "demo-network", + groups: ["system"], + site_tags: ["school", "community"], + isOnline: false, + rawOnlineStatus: false, + createdAt: "2026-01-12T00:00:00.000Z", + lastActive: yesterday, + description: "Community monitoring site at a school campus.", + devices: [], + }, + { + _id: "site-market", + name: "nakawa-market", + formatted_name: "Nakawa Market", + location_name: "Nakawa Market, Kampala", + search_name: "Nakawa Market Kampala", + city: "Kampala", + district: "Kampala", + region: "Central", + country: "Uganda", + latitude: 0.3322, + longitude: 32.6163, + network: "demo-network", + groups: ["system"], + site_tags: ["market", "traffic"], + isOnline: true, + rawOnlineStatus: true, + createdAt: "2026-01-15T00:00:00.000Z", + lastActive: now, + description: "Busy market monitoring site.", + devices: [], + }, +]; + +export const mockDevices: Device[] = [ + { + _id: "device-kla-001", + id: "device-kla-001", + name: "demo_g5241", + long_name: "Demo Monitor G5241", + alias: "Central Library Monitor", + network: "demo-network", + groups: ["system"], + serial_number: "DM-G5241", + authRequired: true, + latitude: 0.3136, + longitude: 32.5811, + createdAt: "2026-01-20T00:00:00.000Z", + visibility: true, + description: "Demo deployed monitor with recent readings.", + isPrimaryInLocation: true, + nextMaintenance: "2026-07-01T00:00:00.000Z", + deployment_date: "2026-02-01T00:00:00.000Z", + mountType: "pole", + isActive: true, + isOnline: true, + site_id: "site-central", + height: 3, + device_codes: ["DM-G5241"], + category: "lowcost", + cohorts: ["cohort-central"], + device_number: 5241, + generation_version: 2, + site_name: "Central Library", + status: "deployed", + maintenance_status: "good", + powerType: "solar", + claim_status: "claimed", + claimed_at: "2026-01-21T00:00:00.000Z", + site: { _id: "site-central", name: "Central Library" }, + lastActive: now, + rawOnlineStatus: true, + tags: ["urban", "roadside"], + }, + { + _id: "device-kla-002", + id: "device-kla-002", + name: "demo_g5242", + long_name: "Demo Monitor G5242", + alias: "School Monitor", + network: "demo-network", + groups: ["system"], + serial_number: "DM-G5242", + authRequired: true, + latitude: 0.3368, + longitude: 32.6015, + createdAt: "2026-01-22T00:00:00.000Z", + visibility: true, + description: "Demo monitor that is currently offline.", + isPrimaryInLocation: true, + nextMaintenance: "2026-06-10T00:00:00.000Z", + deployment_date: "2026-02-05T00:00:00.000Z", + mountType: "wall", + isActive: true, + isOnline: false, + site_id: "site-school", + height: 2.5, + device_codes: ["DM-G5242"], + category: "lowcost", + cohorts: ["cohort-schools"], + device_number: 5242, + generation_version: 2, + site_name: "Green Hill School", + status: "offline", + maintenance_status: "due", + powerType: "mains", + claim_status: "claimed", + claimed_at: "2026-01-23T00:00:00.000Z", + site: { _id: "site-school", name: "Green Hill School" }, + lastActive: yesterday, + rawOnlineStatus: false, + tags: ["school"], + }, + { + _id: "device-kla-003", + id: "device-kla-003", + name: "demo_g5243", + long_name: "Demo Monitor G5243", + alias: "Market Monitor", + network: "demo-network", + groups: ["system"], + serial_number: "DM-G5243", + authRequired: true, + latitude: 0.3322, + longitude: 32.6163, + createdAt: "2026-01-25T00:00:00.000Z", + visibility: false, + description: "Demo monitor in private evaluation mode.", + isPrimaryInLocation: true, + nextMaintenance: "2026-05-20T00:00:00.000Z", + deployment_date: "2026-02-08T00:00:00.000Z", + mountType: "pole", + isActive: true, + isOnline: true, + site_id: "site-market", + height: 3.5, + device_codes: ["DM-G5243"], + category: "lowcost", + cohorts: ["cohort-central"], + device_number: 5243, + generation_version: 2, + site_name: "Nakawa Market", + status: "online", + maintenance_status: "overdue", + powerType: "solar", + claim_status: "claimed", + claimed_at: "2026-01-26T00:00:00.000Z", + site: { _id: "site-market", name: "Nakawa Market" }, + lastActive: now, + rawOnlineStatus: true, + tags: ["market", "traffic"], + }, + { + _id: "device-kla-004", + id: "device-kla-004", + name: "demo_g5244", + long_name: "Demo Monitor G5244", + network: "demo-network", + groups: ["system"], + serial_number: "DM-G5244", + authRequired: true, + createdAt: "2026-01-28T00:00:00.000Z", + visibility: false, + description: "Claimed but not yet deployed demo monitor.", + isActive: true, + isOnline: false, + device_codes: ["DM-G5244"], + category: "lowcost", + cohorts: [], + device_number: 5244, + generation_version: 2, + status: "not deployed", + maintenance_status: -1, + claim_status: "claimed", + claimed_at: "2026-01-29T00:00:00.000Z", + lastActive: yesterday, + rawOnlineStatus: false, + tags: ["inventory"], + }, +]; + +export const mockCohorts: Cohort[] = [ + { + _id: "cohort-central", + visibility: true, + cohort_tags: ["urban", "traffic"], + cohort_codes: ["central"], + name: "Central City Devices", + network: "demo-network", + groups: ["system"], + numberOfDevices: 2, + devices: mockDevices + .filter((device) => device.cohorts.includes("cohort-central")) + .map((device) => ({ + _id: device._id || device.name, + name: device.name, + network: device.network, + groups: device.groups || [], + authRequired: !!device.authRequired, + serial_number: device.serial_number, + api_code: device.api_code || "", + long_name: device.long_name || device.name, + })), + createdAt: "2026-02-01T00:00:00.000Z", + }, + { + _id: "cohort-schools", + visibility: false, + cohort_tags: ["school", "community"], + cohort_codes: ["schools"], + name: "School Monitoring Devices", + network: "demo-network", + groups: ["system"], + numberOfDevices: 1, + devices: mockDevices + .filter((device) => device.cohorts.includes("cohort-schools")) + .map((device) => ({ + _id: device._id || device.name, + name: device.name, + network: device.network, + groups: device.groups || [], + authRequired: !!device.authRequired, + serial_number: device.serial_number, + api_code: device.api_code || "", + long_name: device.long_name || device.name, + })), + createdAt: "2026-02-02T00:00:00.000Z", + }, +]; + +export const mockDeviceActivities: DeviceActivity[] = [ + { + _id: "activity-001", + device: "demo_g5241", + date: now, + description: "Device transmitted latest measurements.", + activityType: "online", + activity_by: { + user_id: "user-demo-admin", + name: "Demo Admin", + email: "admin@example.org", + }, + site_id: "site-central", + network: "demo-network", + createdAt: now, + }, + { + _id: "activity-002", + device: "demo_g5242", + date: yesterday, + description: "Device stopped transmitting.", + activityType: "offline", + activity_by: { + user_id: "user-demo-admin", + name: "Demo Admin", + email: "admin@example.org", + }, + site_id: "site-school", + network: "demo-network", + createdAt: yesterday, + }, + { + _id: "activity-003", + device: "demo_g5243", + date: now, + description: "Maintenance is overdue.", + activityType: "maintenance", + activity_by: { + user_id: "user-demo-admin", + name: "Demo Admin", + email: "admin@example.org", + }, + site_id: "site-market", + network: "demo-network", + nextMaintenance: "2026-05-20T00:00:00.000Z", + createdAt: now, + }, +]; + +export const mockReadings: Reading[] = [ + { + _id: "reading-001", + device_id: "device-kla-001", + device_name: "demo_g5241", + site_id: "site-central", + time: "2026-05-30T08:45:00.000Z", + pm2_5: 18.4, + pm10: 34.2, + latitude: 0.3136, + longitude: 32.5811, + }, + { + _id: "reading-002", + device_id: "device-kla-002", + device_name: "demo_g5242", + site_id: "site-school", + time: "2026-05-29T08:45:00.000Z", + pm2_5: 27.1, + pm10: 49.6, + latitude: 0.3368, + longitude: 32.6015, + }, + { + _id: "reading-003", + device_id: "device-kla-003", + device_name: "demo_g5243", + site_id: "site-market", + time: "2026-05-30T08:50:00.000Z", + pm2_5: 31.7, + pm10: 58.2, + latitude: 0.3322, + longitude: 32.6163, + }, +]; diff --git a/src/vertex/core/adapters/mock.ts b/src/vertex/core/adapters/mock.ts new file mode 100644 index 0000000000..a03d36797c --- /dev/null +++ b/src/vertex/core/adapters/mock.ts @@ -0,0 +1,568 @@ +import type { + Device, + DeviceClaimRequest, + MaintenanceLogData, + PaginationMeta, +} from "@/app/types/devices"; +import type { Site } from "@/app/types/sites"; +import type { Cohort } from "@/app/types/cohorts"; +import type { GetDevicesSummaryParams } from "@/core/apis/devices"; +import type { GetSitesSummaryParams } from "@/core/apis/sites"; +import type { GetCohortsSummaryParams } from "@/core/apis/cohorts"; +import { + mockCohorts, + mockDeviceActivities, + mockDevices, + mockNetworks, + mockPaginationMeta, + mockReadings, + mockSites, + mockUser, +} from "./mock-fixtures"; +import type { + CreateDeviceInput, + CreateSiteInput, + DateRange, + DeviceDeployInput, + DeviceRecallInput, + Reading, + VertexAdapter, +} from "./types"; + +const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +function assertNotAborted(signal?: AbortSignal) { + if (signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } +} + +function pageFromSkip(skip = 0, limit = 100) { + return Math.floor(skip / Math.max(1, limit)) + 1; +} + +function createMeta(total: number, limit = 100, skip = 0): PaginationMeta { + const safeLimit = Math.max(1, limit); + return { + ...mockPaginationMeta, + total, + totalResults: total, + limit: safeLimit, + skip, + page: pageFromSkip(skip, safeLimit), + totalPages: Math.max(1, Math.ceil(total / safeLimit)), + }; +} + +function paginate(items: T[], limit = 100, skip = 0) { + return items.slice(skip, skip + Math.max(1, limit)); +} + +function matchesSearch(search: string | undefined, values: Array) { + if (!search?.trim()) return true; + const needle = search.trim().toLowerCase(); + return values.some((value) => value?.toLowerCase().includes(needle)); +} + +function filterDevices( + params: Partial & GetDevicesSummaryParams = {}, +): Device[] { + const search = params.search; + const network = typeof params.network === "string" ? params.network : undefined; + const status = typeof params.status === "string" ? params.status : undefined; + + return mockDevices.filter((device) => { + if (network && device.network !== network) return false; + if (status && device.status !== status) return false; + return matchesSearch(search, [ + device.name, + device.long_name, + device.alias, + device.serial_number, + device.site_name, + ]); + }); +} + +function filterSites(params: Partial & Partial): Site[] { + const { network, search } = params; + + return mockSites.filter((site) => { + if (network && site.network !== network) return false; + return matchesSearch(search, [ + site.name, + site.formatted_name, + site.location_name, + site.search_name, + site.district, + site.region, + ]); + }); +} + +function filterCohorts(params: GetCohortsSummaryParams = {}): Cohort[] { + const { network, search, cohort_id } = params; + + return mockCohorts.filter((cohort) => { + if (network && cohort.network !== network) return false; + if (cohort_id?.length && !cohort_id.includes(cohort._id)) return false; + return matchesSearch(search, [cohort.name, cohort.network]); + }); +} + +function readingIsInRange(reading: Reading, range: DateRange) { + const readingTime = new Date(reading.time).getTime(); + const startTime = new Date(range.startDate).getTime(); + const endTime = new Date(range.endDate).getTime(); + return readingTime >= startTime && readingTime <= endTime; +} + +export function createMockAdapter(): VertexAdapter { + return { + async getCurrentUser() { + return { + success: true, + message: "Mock user loaded successfully", + users: [clone(mockUser)], + }; + }, + + async getDevices(params = {}, signal) { + assertNotAborted(signal); + const devices = filterDevices(params); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock devices loaded successfully", + devices: clone(paginate(devices, limit, skip)), + meta: createMeta(devices.length, limit, skip), + cache_generated_at: new Date().toISOString(), + }; + }, + + async getDevicesByCohorts(params, signal) { + assertNotAborted(signal); + const devices = filterDevices(params).filter((device) => + params.cohort_ids.some((cohortId) => device.cohorts.includes(cohortId)), + ); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock cohort devices loaded successfully", + devices: clone(paginate(devices, limit, skip)), + meta: createMeta(devices.length, limit, skip), + cache_generated_at: new Date().toISOString(), + }; + }, + + async getDevicesByStatus(params) { + const status = params.status.replace(/-/g, " ") as Device["status"]; + const devices = filterDevices({ ...params, status }); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock status devices loaded successfully", + devices: clone(paginate(devices, limit, skip)), + meta: createMeta(devices.length, limit, skip), + }; + }, + + async getDevice(id) { + const device = mockDevices.find( + (item) => item._id === id || item.id === id || item.name === id, + ); + + if (!device) { + throw new Error(`Mock device not found: ${id}`); + } + + return { + message: "Mock device loaded successfully", + data: clone(device), + }; + }, + + async getMyDevices(userId, groupIds = [], cohortIds = []) { + const devices = mockDevices.filter((device) => { + const groupMatch = + groupIds.length === 0 || + groupIds.some((groupId) => device.groups?.includes(groupId)); + const cohortMatch = + cohortIds.length === 0 || + cohortIds.some((cohortId) => device.cohorts.includes(cohortId)); + return groupMatch || cohortMatch || userId === mockUser._id; + }); + + return { + success: true, + message: "Mock personal devices loaded successfully", + devices: clone(devices), + total_devices: devices.length, + deployed_devices: devices.filter((device) => device.status === "deployed") + .length, + }; + }, + + async getDeviceCount(params = {}) { + const devices = filterDevices({ network: params.network }); + const scopedDevices = params.cohort_id?.length + ? devices.filter((device) => + params.cohort_id?.some((cohortId) => + device.cohorts.includes(cohortId), + ), + ) + : devices; + + return { + success: true, + message: "Mock device count loaded successfully", + summary: { + total_monitors: scopedDevices.length, + operational: scopedDevices.filter((device) => device.isActive).length, + transmitting: scopedDevices.filter((device) => device.isOnline).length, + not_transmitting: scopedDevices.filter((device) => !device.isOnline) + .length, + data_available: scopedDevices.filter((device) => device.lastActive) + .length, + }, + }; + }, + + async checkDeviceAvailability(deviceName) { + const existingDevice = mockDevices.find( + (device) => device.name.toLowerCase() === deviceName.toLowerCase(), + ); + + return { + success: true, + message: existingDevice + ? "Mock device already exists" + : "Mock device is available", + data: { + available: !existingDevice, + status: existingDevice?.claim_status || "unclaimed", + }, + }; + }, + + async claimDevice(data: DeviceClaimRequest) { + return { + success: true, + message: "Mock device claimed successfully", + device: { + name: data.device_name, + long_name: data.device_name, + status: "claimed", + claim_status: "claimed", + claimed_at: new Date().toISOString(), + }, + }; + }, + + async deployDevice(data: DeviceDeployInput) { + return { + success: true, + message: `[mock] ${data.deviceName} deployed successfully`, + }; + }, + + async recallDevice(deviceName: string, recallData: DeviceRecallInput) { + return { + success: true, + message: `[mock] ${deviceName} recalled using ${recallData.recallType}`, + }; + }, + + async createDevice(data: CreateDeviceInput) { + const createdDevice: Device = { + _id: `mock-${Date.now()}`, + id: `mock-${Date.now()}`, + name: data.long_name.toLowerCase().replace(/\s+/g, "_"), + long_name: data.long_name, + network: data.network, + groups: ["system"], + serial_number: `MOCK-${Date.now()}`, + createdAt: new Date().toISOString(), + visibility: false, + description: data.description, + isActive: true, + isOnline: false, + device_codes: [], + category: data.category, + cohorts: [], + status: "not deployed", + claim_status: "claimed", + tags: data.tags, + }; + + return { + success: true, + message: "Mock device created successfully", + created_device: createdDevice, + }; + }, + + async updateDevice(deviceId, deviceData) { + const existingDevice = + mockDevices.find( + (device) => + device._id === deviceId || device.id === deviceId || device.name === deviceId, + ) || mockDevices[0]; + + const updatedDevice = { ...existingDevice, ...deviceData }; + + return { + success: true, + message: "Mock device updated successfully", + updated_device: clone(updatedDevice), + }; + }, + + async addMaintenanceLog(deviceName, logData: MaintenanceLogData) { + return { + success: true, + message: `[mock] Maintenance log added for ${deviceName}`, + data: clone(logData), + }; + }, + + async updateDeviceGroup(deviceId, groupName) { + const device = mockDevices.find( + (item) => item._id === deviceId || item.id === deviceId, + ); + + return { + success: true, + message: `[mock] Device group updated to ${groupName}`, + updated_device: device ? clone(device) : undefined, + }; + }, + + async getDeviceActivities(deviceName, params = {}) { + const activities = mockDeviceActivities.filter( + (activity) => activity.device === deviceName, + ); + const limit = params.limit ?? 10; + const page = params.page ?? 1; + const skip = (page - 1) * limit; + + return { + success: true, + message: "Mock device activities loaded successfully", + site_activities: clone(paginate(activities, limit, skip)), + meta: { + total: activities.length, + limit, + skip, + page, + totalPages: Math.max(1, Math.ceil(activities.length / limit)), + }, + }; + }, + + async getSites(params, signal) { + assertNotAborted(signal); + const sites = filterSites(params); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock sites loaded successfully", + sites: clone(paginate(sites, limit, skip)), + meta: createMeta(sites.length, limit, skip), + cache_generated_at: new Date().toISOString(), + }; + }, + + async getSitesByCohorts(params, signal) { + assertNotAborted(signal); + const devicesInCohorts = mockDevices.filter((device) => + params.cohort_ids.some((cohortId) => device.cohorts.includes(cohortId)), + ); + const siteIds = new Set(devicesInCohorts.map((device) => device.site_id)); + const sites = mockSites.filter((site) => site._id && siteIds.has(site._id)); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock cohort sites loaded successfully", + sites: clone(paginate(sites, limit, skip)), + meta: createMeta(sites.length, limit, skip), + cache_generated_at: new Date().toISOString(), + }; + }, + + async getSitesByStatus(params) { + const sites = filterSites(params).filter((site) => { + if (params.status === "online") return site.isOnline; + if (params.status === "offline") return !site.isOnline; + return true; + }); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock status sites loaded successfully", + sites: clone(paginate(sites, limit, skip)), + meta: createMeta(sites.length, limit, skip), + }; + }, + + async getSite(id) { + const site = mockSites.find((item) => item._id === id || item.name === id); + + if (!site) { + throw new Error(`Mock site not found: ${id}`); + } + + return { + message: "Mock site loaded successfully", + data: clone(site), + }; + }, + + async createSite(data: CreateSiteInput) { + return { + success: true, + message: "Mock site created successfully", + site: { + _id: `mock-site-${Date.now()}`, + name: data.name, + formatted_name: data.name, + location_name: data.name, + search_name: data.name, + latitude: Number(data.latitude), + longitude: Number(data.longitude), + network: data.network, + groups: ["system"], + isOnline: false, + createdAt: new Date().toISOString(), + }, + }; + }, + + async getSitesSummaryCount(params = {}) { + const sites = filterSites({ network: params.network || "" }); + + return { + message: "Mock site count loaded successfully", + summary: { + total_sites: sites.length, + operational: sites.length, + transmitting: sites.filter((site) => site.isOnline).length, + not_transmitting: sites.filter((site) => !site.isOnline).length, + data_available: sites.filter((site) => site.lastActive).length, + }, + }; + }, + + async getSiteActivities(siteId, params = {}) { + const activities = mockDeviceActivities.filter( + (activity) => activity.site_id === siteId, + ); + const limit = params.limit ?? 10; + const page = params.page ?? 1; + const skip = (page - 1) * limit; + + return { + success: true, + message: "Mock site activities loaded successfully", + site_activities: clone(paginate(activities, limit, skip)), + meta: { + total: activities.length, + limit, + skip, + page, + totalPages: Math.max(1, Math.ceil(activities.length / limit)), + }, + }; + }, + + async getCohorts(params = {}, signal) { + assertNotAborted(signal); + const cohorts = filterCohorts(params); + const limit = params.limit ?? 100; + const skip = params.skip ?? 0; + + return { + success: true, + message: "Mock cohorts loaded successfully", + cohorts: clone(paginate(cohorts, limit, skip)), + meta: { + total: cohorts.length, + limit, + skip, + page: pageFromSkip(skip, limit), + totalPages: Math.max(1, Math.ceil(cohorts.length / limit)), + }, + }; + }, + + async getCohort(id) { + const cohort = mockCohorts.find((item) => item._id === id || item.name === id); + + if (!cohort) { + throw new Error(`Mock cohort not found: ${id}`); + } + + return { + success: true, + message: "Mock cohort loaded successfully", + cohort: clone(cohort), + }; + }, + + async getGroupCohorts() { + return { + success: true, + message: "Mock group cohorts loaded successfully", + data: mockCohorts.map((cohort) => cohort._id), + }; + }, + + async getOriginalCohort(cohortId) { + const cohort = + mockCohorts.find((item) => item._id === cohortId) || mockCohorts[0]; + + return { + success: true, + message: "Mock original cohort loaded successfully", + original_cohort: clone(cohort), + }; + }, + + async getNetworks() { + return clone(mockNetworks); + }, + + async getLatestReadings(deviceIds) { + const requestedIds = new Set(deviceIds); + return clone( + mockReadings.filter( + (reading) => + requestedIds.has(reading.device_id) || + requestedIds.has(reading.device_name), + ), + ); + }, + + async getReadingHistory(deviceId, range) { + return clone( + mockReadings.filter( + (reading) => + (reading.device_id === deviceId || reading.device_name === deviceId) && + readingIsInRange(reading, range), + ), + ); + }, + }; +} diff --git a/src/vertex/core/adapters/types.ts b/src/vertex/core/adapters/types.ts new file mode 100644 index 0000000000..6359f8b1bd --- /dev/null +++ b/src/vertex/core/adapters/types.ts @@ -0,0 +1,208 @@ +import type { + Device, + DeviceAvailabilityResponse, + DeviceClaimRequest, + DeviceClaimResponse, + DeviceCreationResponse, + DevicesSummaryResponse, + DeviceUpdateGroupResponse, + MaintenanceLogData, + MyDevicesResponse, +} from "@/app/types/devices"; +import type { + Cohort, + CohortsSummaryResponse, + GroupCohortsResponse, + OriginalCohortResponse, +} from "@/app/types/cohorts"; +import type { Site } from "@/app/types/sites"; +import type { UserDetailsResponse } from "@/app/types/users"; +import type { Network } from "@/core/apis/networks"; +import type { + DeviceActivitiesResponse, + DeviceCountResponse, + DeviceDetailsResponse, + GetDevicesSummaryParams, +} from "@/core/apis/devices"; +import type { + CreateSiteResponse, + GetSitesSummaryParams, + SitesSummaryCountResponse, + SitesSummaryResponse, +} from "@/core/apis/sites"; +import type { GetCohortsSummaryParams } from "@/core/apis/cohorts"; + +export interface DateRange { + startDate: string; + endDate: string; +} + +export interface Reading { + _id: string; + device_id: string; + device_name: string; + site_id?: string; + time: string; + pm2_5: number; + pm10: number; + latitude?: number; + longitude?: number; +} + +export interface DeviceDeployInput { + deviceName: string; + height: string; + mountType: string; + powerType: string; + isPrimaryInLocation: boolean; + latitude: string; + longitude: string; + site_name?: string; + site_id?: string; + network: string; + user_id: string; + deployment_date: string | undefined; + firstName?: string; + lastName?: string; + email?: string; + userName?: string; +} + +export interface DeviceRecallInput { + recallType: string; + user_id: string; + date: string; + firstName?: string; + lastName?: string; + email?: string; + userName?: string; +} + +export interface CreateDeviceInput { + long_name: string; + category: string; + description?: string; + network: string; + tags?: string[]; +} + +export interface CreateSiteInput { + name: string; + latitude: string; + longitude: string; + network: string; +} + +export interface VertexAdapter { + getCurrentUser(userId?: string): Promise; + + getDevices( + params?: GetDevicesSummaryParams, + signal?: AbortSignal, + ): Promise; + getDevicesByCohorts( + params: Partial & { + cohort_ids: string[]; + limit?: number; + skip?: number; + search?: string; + sortBy?: string; + order?: "asc" | "desc"; + }, + signal?: AbortSignal, + ): Promise; + getDevicesByStatus(params: { + status: string; + cohort_id?: string[]; + limit?: number; + skip?: number; + search?: string; + sortBy?: string; + order?: "asc" | "desc"; + network?: string; + }): Promise; + getDevice(id: string): Promise; + getMyDevices( + userId: string, + groupIds?: string[], + cohortIds?: string[], + ): Promise; + getDeviceCount(params?: { + cohort_id?: string[]; + network?: string; + }): Promise; + checkDeviceAvailability( + deviceName: string, + ): Promise; + claimDevice(data: DeviceClaimRequest): Promise; + deployDevice(data: DeviceDeployInput): Promise<{ success: boolean; message: string }>; + recallDevice( + deviceName: string, + recallData: DeviceRecallInput, + ): Promise<{ success: boolean; message: string }>; + createDevice(data: CreateDeviceInput): Promise; + updateDevice( + deviceId: string, + deviceData: Partial, + ): Promise<{ success: boolean; message: string; updated_device: Device }>; + addMaintenanceLog( + deviceName: string, + logData: MaintenanceLogData, + ): Promise<{ success: boolean; message: string; data: MaintenanceLogData }>; + updateDeviceGroup( + deviceId: string, + groupName: string, + ): Promise; + getDeviceActivities( + deviceName: string, + params?: { page?: number; limit?: number }, + ): Promise; + + getSites( + params: GetSitesSummaryParams, + signal?: AbortSignal, + ): Promise; + getSitesByCohorts( + params: { + cohort_ids: string[]; + limit?: number; + skip?: number; + search?: string; + sortBy?: string; + order?: "asc" | "desc"; + network?: string; + }, + signal?: AbortSignal, + ): Promise; + getSitesByStatus(params: { + status: string; + limit?: number; + skip?: number; + search?: string; + sortBy?: string; + order?: "asc" | "desc"; + network?: string; + group?: string; + }): Promise; + getSite(id: string): Promise<{ message: string; data: Site }>; + createSite(data: CreateSiteInput): Promise; + getSitesSummaryCount(params?: { + network?: string; + }): Promise; + getSiteActivities( + siteId: string, + params?: { page?: number; limit?: number }, + ): Promise; + + getCohorts( + params?: GetCohortsSummaryParams, + signal?: AbortSignal, + ): Promise; + getCohort(id: string): Promise<{ success: boolean; message: string; cohort: Cohort }>; + getGroupCohorts(groupId: string): Promise; + getOriginalCohort(cohortId: string): Promise; + + getNetworks(): Promise; + getLatestReadings(deviceIds: string[]): Promise; + getReadingHistory(deviceId: string, range: DateRange): Promise; +} From a9b0c1ca011397d92dcda09932b99fef23061d8f Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sat, 30 May 2026 08:59:57 +0300 Subject: [PATCH 04/10] remove opaque issue numbers --- .../internal/vertex-template-adapter-foundation.md | 10 +++++----- .../_docs/internal/vertex-template-coupling-audit.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md b/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md index 562847ee00..5384e7e88c 100644 --- a/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md +++ b/src/vertex/app/_docs/internal/vertex-template-adapter-foundation.md @@ -2,7 +2,7 @@ ## Purpose -Issue 3 introduces the adapter foundation for `create-vertex-app` v1. It defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials. +This document describes the adapter foundation for `create-vertex-app` v1. The foundation defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials. ## Files @@ -10,18 +10,18 @@ Issue 3 introduces the adapter foundation for `create-vertex-app` v1. It defines - `core/adapters/index.ts`: adapter factory selected by `vertex.config.ts`. - `core/adapters/mock.ts`: mock adapter implementation. - `core/adapters/mock-fixtures.ts`: typed fixture data. -- `core/adapters/airqo.ts`: Issue 4 placeholder for the AirQo wrapper. +- `core/adapters/airqo.ts`: AirQo wrapper around the existing service layer. ## V1 Adapter Choices - `mock`: implemented now and intended as the generated template default. -- `airqo`: factory-supported, but methods intentionally throw until Issue 4 maps existing `core/apis/*` services into the contract. +- `airqo`: wraps existing `core/apis/*` services without rewriting endpoint behavior. Generic REST remains out of scope for v1. ## Integration Boundary -This foundation does not refactor existing React Query hooks yet. Issue 5 should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components. +This foundation does not refactor existing React Query hooks yet. The next integration step should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components. ## Mock Adapter Coverage @@ -39,5 +39,5 @@ The mock adapter includes: - Keep fixture data realistic but small. - Add adapter methods only when current v1 screens need them. -- Keep AirQo endpoint mapping out of `mock.ts`; Issue 4 owns `airqo.ts`. +- Keep AirQo endpoint mapping out of `mock.ts`; `airqo.ts` owns live API service mapping. - Do not add REST adapter behavior in v1. diff --git a/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md b/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md index df3f2136ca..dfeac094d5 100644 --- a/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md +++ b/src/vertex/app/_docs/internal/vertex-template-coupling-audit.md @@ -130,7 +130,7 @@ The main component-level exceptions found are: ## Contributor Guidance -This audit is documentation only. Follow-up implementation issues should avoid broad file ownership conflicts: +This audit is documentation only. Follow-up implementation tasks should avoid broad file ownership conflicts: - Core maintainers should own config, adapter contracts, auth, and hook refactors. - Open-source contributors can safely work on branding copy, measurement cards, docs, fixture data, and feature-flagged UI after the config contract lands. From 3cbd6dc2c8a94e155f448a316fce1dae8ec78be7 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 16:27:36 +0300 Subject: [PATCH 05/10] Wire AirQo adapter to APIs; update mock & types Replace placeholder AirQo adapter stubs with real API bindings (devices, sites, users, networks), including getCurrentUser, login, device/site/network operations and maintenance hooks. Update mock adapter to add a login endpoint, simplify device/site handling, adjust responses and pagination, and remove several cohort/site/readings-specific mock helpers. Align VertexAdapter types with the implemented surface: add LoginCredentials/login, simplify getDeviceCount params, move deploy/recall signatures, and remove several deprecated cohort/site/reading methods to match the concrete implementations. --- src/vertex/core/adapters/airqo.ts | 69 ++++---- src/vertex/core/adapters/mock.ts | 272 ++++-------------------------- src/vertex/core/adapters/types.ts | 70 +------- 3 files changed, 70 insertions(+), 341 deletions(-) diff --git a/src/vertex/core/adapters/airqo.ts b/src/vertex/core/adapters/airqo.ts index 7e23471aa3..61971999c0 100644 --- a/src/vertex/core/adapters/airqo.ts +++ b/src/vertex/core/adapters/airqo.ts @@ -1,44 +1,35 @@ import type { VertexAdapter } from "./types"; - -function issue4Method(methodName: string) { - return async () => { - throw new Error( - `AirQo adapter method "${methodName}" will be implemented in Issue 4.`, - ); - }; -} +import { devices } from "../apis/devices"; +import { sites } from "../apis/sites"; +import { users } from "../apis/users"; +import { networks } from "../apis/networks"; export function createAirQoAdapter(): VertexAdapter { return { - getCurrentUser: issue4Method("getCurrentUser"), - getDevices: issue4Method("getDevices"), - getDevicesByCohorts: issue4Method("getDevicesByCohorts"), - getDevicesByStatus: issue4Method("getDevicesByStatus"), - getDevice: issue4Method("getDevice"), - getMyDevices: issue4Method("getMyDevices"), - getDeviceCount: issue4Method("getDeviceCount"), - checkDeviceAvailability: issue4Method("checkDeviceAvailability"), - claimDevice: issue4Method("claimDevice"), - deployDevice: issue4Method("deployDevice"), - recallDevice: issue4Method("recallDevice"), - createDevice: issue4Method("createDevice"), - updateDevice: issue4Method("updateDevice"), - addMaintenanceLog: issue4Method("addMaintenanceLog"), - updateDeviceGroup: issue4Method("updateDeviceGroup"), - getDeviceActivities: issue4Method("getDeviceActivities"), - getSites: issue4Method("getSites"), - getSitesByCohorts: issue4Method("getSitesByCohorts"), - getSitesByStatus: issue4Method("getSitesByStatus"), - getSite: issue4Method("getSite"), - createSite: issue4Method("createSite"), - getSitesSummaryCount: issue4Method("getSitesSummaryCount"), - getSiteActivities: issue4Method("getSiteActivities"), - getCohorts: issue4Method("getCohorts"), - getCohort: issue4Method("getCohort"), - getGroupCohorts: issue4Method("getGroupCohorts"), - getOriginalCohort: issue4Method("getOriginalCohort"), - getNetworks: issue4Method("getNetworks"), - getLatestReadings: issue4Method("getLatestReadings"), - getReadingHistory: issue4Method("getReadingHistory"), - } as VertexAdapter; + getCurrentUser: async (userId?: string) => { + if (!userId) { + throw new Error("userId is required for AirQo adapter getCurrentUser"); + } + return users.getUserDetails(userId); + }, + login: users.loginWithDetails, + + getDevices: devices.getDevicesSummaryApi, + getDevicesByStatus: devices.getDevicesByStatusApi, + getDeviceCount: devices.getDeviceCountApi, + getDevice: devices.getDeviceDetails, + createDevice: devices.createDevice, + updateDevice: devices.updateDeviceLocal, + deployDevice: devices.deployDevice, + recallDevice: devices.recallDevice, + addMaintenanceLog: devices.addMaintenanceLog, + getDeviceActivities: devices.getDeviceActivities, + + getSites: sites.getSitesSummary, + getSitesByStatus: sites.getSitesByStatusApi, + getSite: sites.getSiteDetails, + getSiteActivities: sites.getSiteActivities, + + getNetworks: networks.getNetworksApi, + }; } diff --git a/src/vertex/core/adapters/mock.ts b/src/vertex/core/adapters/mock.ts index a03d36797c..51f573e168 100644 --- a/src/vertex/core/adapters/mock.ts +++ b/src/vertex/core/adapters/mock.ts @@ -126,33 +126,24 @@ export function createMockAdapter(): VertexAdapter { users: [clone(mockUser)], }; }, - - async getDevices(params = {}, signal) { - assertNotAborted(signal); - const devices = filterDevices(params); - const limit = params.limit ?? 100; - const skip = params.skip ?? 0; - + async login(credentials) { return { success: true, - message: "Mock devices loaded successfully", - devices: clone(paginate(devices, limit, skip)), - meta: createMeta(devices.length, limit, skip), - cache_generated_at: new Date().toISOString(), + message: "Mock login successful", + token: "mock-jwt-token", + user: clone(mockUser), }; }, - async getDevicesByCohorts(params, signal) { + async getDevices(params = {}, signal) { assertNotAborted(signal); - const devices = filterDevices(params).filter((device) => - params.cohort_ids.some((cohortId) => device.cohorts.includes(cohortId)), - ); + const devices = filterDevices(params); const limit = params.limit ?? 100; const skip = params.skip ?? 0; return { success: true, - message: "Mock cohort devices loaded successfully", + message: "Mock devices loaded successfully", devices: clone(paginate(devices, limit, skip)), meta: createMeta(devices.length, limit, skip), cache_generated_at: new Date().toISOString(), @@ -173,109 +164,34 @@ export function createMockAdapter(): VertexAdapter { }; }, - async getDevice(id) { - const device = mockDevices.find( - (item) => item._id === id || item.id === id || item.name === id, - ); - - if (!device) { - throw new Error(`Mock device not found: ${id}`); - } - - return { - message: "Mock device loaded successfully", - data: clone(device), - }; - }, - - async getMyDevices(userId, groupIds = [], cohortIds = []) { - const devices = mockDevices.filter((device) => { - const groupMatch = - groupIds.length === 0 || - groupIds.some((groupId) => device.groups?.includes(groupId)); - const cohortMatch = - cohortIds.length === 0 || - cohortIds.some((cohortId) => device.cohorts.includes(cohortId)); - return groupMatch || cohortMatch || userId === mockUser._id; - }); - - return { - success: true, - message: "Mock personal devices loaded successfully", - devices: clone(devices), - total_devices: devices.length, - deployed_devices: devices.filter((device) => device.status === "deployed") - .length, - }; - }, - async getDeviceCount(params = {}) { const devices = filterDevices({ network: params.network }); - const scopedDevices = params.cohort_id?.length - ? devices.filter((device) => - params.cohort_id?.some((cohortId) => - device.cohorts.includes(cohortId), - ), - ) - : devices; return { success: true, message: "Mock device count loaded successfully", summary: { - total_monitors: scopedDevices.length, - operational: scopedDevices.filter((device) => device.isActive).length, - transmitting: scopedDevices.filter((device) => device.isOnline).length, - not_transmitting: scopedDevices.filter((device) => !device.isOnline) - .length, - data_available: scopedDevices.filter((device) => device.lastActive) - .length, + total_monitors: devices.length, + operational: devices.filter((device) => device.isActive).length, + transmitting: devices.filter((device) => device.isOnline).length, + not_transmitting: devices.filter((device) => !device.isOnline).length, + data_available: devices.filter((device) => device.lastActive).length, }, }; }, - async checkDeviceAvailability(deviceName) { - const existingDevice = mockDevices.find( - (device) => device.name.toLowerCase() === deviceName.toLowerCase(), + async getDevice(id) { + const device = mockDevices.find( + (item) => item._id === id || item.id === id || item.name === id, ); - return { - success: true, - message: existingDevice - ? "Mock device already exists" - : "Mock device is available", - data: { - available: !existingDevice, - status: existingDevice?.claim_status || "unclaimed", - }, - }; - }, - - async claimDevice(data: DeviceClaimRequest) { - return { - success: true, - message: "Mock device claimed successfully", - device: { - name: data.device_name, - long_name: data.device_name, - status: "claimed", - claim_status: "claimed", - claimed_at: new Date().toISOString(), - }, - }; - }, - - async deployDevice(data: DeviceDeployInput) { - return { - success: true, - message: `[mock] ${data.deviceName} deployed successfully`, - }; - }, + if (!device) { + throw new Error(`Mock device not found: ${id}`); + } - async recallDevice(deviceName: string, recallData: DeviceRecallInput) { return { - success: true, - message: `[mock] ${deviceName} recalled using ${recallData.recallType}`, + message: "Mock device loaded successfully", + data: clone(device), }; }, @@ -324,23 +240,25 @@ export function createMockAdapter(): VertexAdapter { }; }, - async addMaintenanceLog(deviceName, logData: MaintenanceLogData) { + async deployDevice(data: DeviceDeployInput) { return { success: true, - message: `[mock] Maintenance log added for ${deviceName}`, - data: clone(logData), + message: `[mock] ${data.deviceName} deployed successfully`, }; }, - async updateDeviceGroup(deviceId, groupName) { - const device = mockDevices.find( - (item) => item._id === deviceId || item.id === deviceId, - ); + async recallDevice(deviceName: string, recallData: DeviceRecallInput) { + return { + success: true, + message: `[mock] ${deviceName} recalled using ${recallData.recallType}`, + }; + }, + async addMaintenanceLog(deviceName, logData: MaintenanceLogData) { return { success: true, - message: `[mock] Device group updated to ${groupName}`, - updated_device: device ? clone(device) : undefined, + message: `[mock] Maintenance log added for ${deviceName}`, + data: clone(logData), }; }, @@ -381,25 +299,6 @@ export function createMockAdapter(): VertexAdapter { }; }, - async getSitesByCohorts(params, signal) { - assertNotAborted(signal); - const devicesInCohorts = mockDevices.filter((device) => - params.cohort_ids.some((cohortId) => device.cohorts.includes(cohortId)), - ); - const siteIds = new Set(devicesInCohorts.map((device) => device.site_id)); - const sites = mockSites.filter((site) => site._id && siteIds.has(site._id)); - const limit = params.limit ?? 100; - const skip = params.skip ?? 0; - - return { - success: true, - message: "Mock cohort sites loaded successfully", - sites: clone(paginate(sites, limit, skip)), - meta: createMeta(sites.length, limit, skip), - cache_generated_at: new Date().toISOString(), - }; - }, - async getSitesByStatus(params) { const sites = filterSites(params).filter((site) => { if (params.status === "online") return site.isOnline; @@ -430,41 +329,6 @@ export function createMockAdapter(): VertexAdapter { }; }, - async createSite(data: CreateSiteInput) { - return { - success: true, - message: "Mock site created successfully", - site: { - _id: `mock-site-${Date.now()}`, - name: data.name, - formatted_name: data.name, - location_name: data.name, - search_name: data.name, - latitude: Number(data.latitude), - longitude: Number(data.longitude), - network: data.network, - groups: ["system"], - isOnline: false, - createdAt: new Date().toISOString(), - }, - }; - }, - - async getSitesSummaryCount(params = {}) { - const sites = filterSites({ network: params.network || "" }); - - return { - message: "Mock site count loaded successfully", - summary: { - total_sites: sites.length, - operational: sites.length, - transmitting: sites.filter((site) => site.isOnline).length, - not_transmitting: sites.filter((site) => !site.isOnline).length, - data_available: sites.filter((site) => site.lastActive).length, - }, - }; - }, - async getSiteActivities(siteId, params = {}) { const activities = mockDeviceActivities.filter( (activity) => activity.site_id === siteId, @@ -487,82 +351,8 @@ export function createMockAdapter(): VertexAdapter { }; }, - async getCohorts(params = {}, signal) { - assertNotAborted(signal); - const cohorts = filterCohorts(params); - const limit = params.limit ?? 100; - const skip = params.skip ?? 0; - - return { - success: true, - message: "Mock cohorts loaded successfully", - cohorts: clone(paginate(cohorts, limit, skip)), - meta: { - total: cohorts.length, - limit, - skip, - page: pageFromSkip(skip, limit), - totalPages: Math.max(1, Math.ceil(cohorts.length / limit)), - }, - }; - }, - - async getCohort(id) { - const cohort = mockCohorts.find((item) => item._id === id || item.name === id); - - if (!cohort) { - throw new Error(`Mock cohort not found: ${id}`); - } - - return { - success: true, - message: "Mock cohort loaded successfully", - cohort: clone(cohort), - }; - }, - - async getGroupCohorts() { - return { - success: true, - message: "Mock group cohorts loaded successfully", - data: mockCohorts.map((cohort) => cohort._id), - }; - }, - - async getOriginalCohort(cohortId) { - const cohort = - mockCohorts.find((item) => item._id === cohortId) || mockCohorts[0]; - - return { - success: true, - message: "Mock original cohort loaded successfully", - original_cohort: clone(cohort), - }; - }, - async getNetworks() { return clone(mockNetworks); }, - - async getLatestReadings(deviceIds) { - const requestedIds = new Set(deviceIds); - return clone( - mockReadings.filter( - (reading) => - requestedIds.has(reading.device_id) || - requestedIds.has(reading.device_name), - ), - ); - }, - - async getReadingHistory(deviceId, range) { - return clone( - mockReadings.filter( - (reading) => - (reading.device_id === deviceId || reading.device_name === deviceId) && - readingIsInRange(reading, range), - ), - ); - }, }; } diff --git a/src/vertex/core/adapters/types.ts b/src/vertex/core/adapters/types.ts index 6359f8b1bd..8adc6c134d 100644 --- a/src/vertex/core/adapters/types.ts +++ b/src/vertex/core/adapters/types.ts @@ -16,7 +16,7 @@ import type { OriginalCohortResponse, } from "@/app/types/cohorts"; import type { Site } from "@/app/types/sites"; -import type { UserDetailsResponse } from "@/app/types/users"; +import type { UserDetailsResponse, LoginCredentials } from "@/app/types/users"; import type { Network } from "@/core/apis/networks"; import type { DeviceActivitiesResponse, @@ -95,25 +95,14 @@ export interface CreateSiteInput { export interface VertexAdapter { getCurrentUser(userId?: string): Promise; + login?(credentials: LoginCredentials): Promise; getDevices( params?: GetDevicesSummaryParams, signal?: AbortSignal, ): Promise; - getDevicesByCohorts( - params: Partial & { - cohort_ids: string[]; - limit?: number; - skip?: number; - search?: string; - sortBy?: string; - order?: "asc" | "desc"; - }, - signal?: AbortSignal, - ): Promise; getDevicesByStatus(params: { status: string; - cohort_id?: string[]; limit?: number; skip?: number; search?: string; @@ -121,38 +110,22 @@ export interface VertexAdapter { order?: "asc" | "desc"; network?: string; }): Promise; + getDeviceCount(params?: { network?: string }): Promise; getDevice(id: string): Promise; - getMyDevices( - userId: string, - groupIds?: string[], - cohortIds?: string[], - ): Promise; - getDeviceCount(params?: { - cohort_id?: string[]; - network?: string; - }): Promise; - checkDeviceAvailability( - deviceName: string, - ): Promise; - claimDevice(data: DeviceClaimRequest): Promise; - deployDevice(data: DeviceDeployInput): Promise<{ success: boolean; message: string }>; - recallDevice( - deviceName: string, - recallData: DeviceRecallInput, - ): Promise<{ success: boolean; message: string }>; createDevice(data: CreateDeviceInput): Promise; updateDevice( deviceId: string, deviceData: Partial, ): Promise<{ success: boolean; message: string; updated_device: Device }>; + deployDevice(data: DeviceDeployInput): Promise<{ success: boolean; message: string }>; + recallDevice( + deviceName: string, + recallData: DeviceRecallInput, + ): Promise<{ success: boolean; message: string }>; addMaintenanceLog( deviceName: string, logData: MaintenanceLogData, ): Promise<{ success: boolean; message: string; data: MaintenanceLogData }>; - updateDeviceGroup( - deviceId: string, - groupName: string, - ): Promise; getDeviceActivities( deviceName: string, params?: { page?: number; limit?: number }, @@ -162,18 +135,6 @@ export interface VertexAdapter { params: GetSitesSummaryParams, signal?: AbortSignal, ): Promise; - getSitesByCohorts( - params: { - cohort_ids: string[]; - limit?: number; - skip?: number; - search?: string; - sortBy?: string; - order?: "asc" | "desc"; - network?: string; - }, - signal?: AbortSignal, - ): Promise; getSitesByStatus(params: { status: string; limit?: number; @@ -185,24 +146,11 @@ export interface VertexAdapter { group?: string; }): Promise; getSite(id: string): Promise<{ message: string; data: Site }>; - createSite(data: CreateSiteInput): Promise; - getSitesSummaryCount(params?: { - network?: string; - }): Promise; getSiteActivities( siteId: string, params?: { page?: number; limit?: number }, ): Promise; - getCohorts( - params?: GetCohortsSummaryParams, - signal?: AbortSignal, - ): Promise; - getCohort(id: string): Promise<{ success: boolean; message: string; cohort: Cohort }>; - getGroupCohorts(groupId: string): Promise; - getOriginalCohort(cohortId: string): Promise; - getNetworks(): Promise; - getLatestReadings(deviceIds: string[]): Promise; - getReadingHistory(deviceId: string, range: DateRange): Promise; } + From e85154386de51e85101021e74a5303157a830320 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 16:54:55 +0300 Subject: [PATCH 06/10] Consolidate API adapters and switch hooks to adapter Introduce a unified airqoAdapter that spreads core API modules (devices, sites, cohorts, networks, users, grids, organizations, permissions, roles) and binds feedback methods; replace createMockAdapter with a mockAdapter that proxies unimplemented methods to return generic mock responses. Update adapter types to derive VertexAdapter from the actual API modules and keep legacy overrides for common method names. Update adapter index to return the new adapters and switch multiple hooks (devices, grids, networks, roles, sites) to use the centralized adapter instead of importing API modules directly. This centralizes API surface, simplifies mocking, and avoids importing individual API modules across hooks. --- src/vertex/core/adapters/airqo.ts | 65 +++++++------ src/vertex/core/adapters/index.ts | 8 +- src/vertex/core/adapters/mock.ts | 18 +++- src/vertex/core/adapters/types.ts | 131 ++++++++------------------- src/vertex/core/hooks/useDevices.ts | 71 ++++++++------- src/vertex/core/hooks/useGrids.ts | 16 ++-- src/vertex/core/hooks/useNetworks.ts | 8 +- src/vertex/core/hooks/useRoles.ts | 6 +- src/vertex/core/hooks/useSites.ts | 23 ++--- 9 files changed, 156 insertions(+), 190 deletions(-) diff --git a/src/vertex/core/adapters/airqo.ts b/src/vertex/core/adapters/airqo.ts index 61971999c0..76b126fae1 100644 --- a/src/vertex/core/adapters/airqo.ts +++ b/src/vertex/core/adapters/airqo.ts @@ -1,35 +1,42 @@ -import type { VertexAdapter } from "./types"; +import { VertexAdapter } from "./types"; import { devices } from "../apis/devices"; import { sites } from "../apis/sites"; -import { users } from "../apis/users"; +import { cohorts } from "../apis/cohorts"; import { networks } from "../apis/networks"; +import { users } from "../apis/users"; +import { grids } from "../apis/grids"; +import { groupsApi } from "../apis/organizations"; +import { permissions } from "../apis/permissions"; +import { roles } from "../apis/roles"; +import { feedbackService } from "../apis/feedback"; -export function createAirQoAdapter(): VertexAdapter { - return { - getCurrentUser: async (userId?: string) => { - if (!userId) { - throw new Error("userId is required for AirQo adapter getCurrentUser"); - } - return users.getUserDetails(userId); - }, - login: users.loginWithDetails, - - getDevices: devices.getDevicesSummaryApi, - getDevicesByStatus: devices.getDevicesByStatusApi, - getDeviceCount: devices.getDeviceCountApi, - getDevice: devices.getDeviceDetails, - createDevice: devices.createDevice, - updateDevice: devices.updateDeviceLocal, - deployDevice: devices.deployDevice, - recallDevice: devices.recallDevice, - addMaintenanceLog: devices.addMaintenanceLog, - getDeviceActivities: devices.getDeviceActivities, +export const airqoAdapter: VertexAdapter = { + // Spread all core API implementations + ...devices, + ...sites, + ...cohorts, + ...networks, + ...users, + ...grids, + ...groupsApi, + ...permissions, + ...roles, - getSites: sites.getSitesSummary, - getSitesByStatus: sites.getSitesByStatusApi, - getSite: sites.getSiteDetails, - getSiteActivities: sites.getSiteActivities, + // Feedback class methods (they don't spread) + submitFeedback: feedbackService.submitFeedback.bind(feedbackService), + submitSatisfactionFeedback: feedbackService.submitSatisfactionFeedback.bind(feedbackService), + submitLoginFeedback: feedbackService.submitLoginFeedback.bind(feedbackService), - getNetworks: networks.getNetworksApi, - }; -} + // Generic overrides + getDevices: devices.getDevicesSummaryApi, + getDevicesByStatus: devices.getDevicesByStatusApi, + getDeviceCount: devices.getDeviceCountApi, + getDevice: devices.getDeviceDetails, + updateDevice: devices.updateDeviceLocal, + getSites: sites.getSitesSummary, + getSite: sites.getSiteDetails, + getSitesByStatus: sites.getSitesByStatusApi, + getNetworks: networks.getNetworksApi, + getCurrentUser: users.getUserDetails, + login: users.loginWithDetails, +}; diff --git a/src/vertex/core/adapters/index.ts b/src/vertex/core/adapters/index.ts index 81cd3cf346..638eaf25ab 100644 --- a/src/vertex/core/adapters/index.ts +++ b/src/vertex/core/adapters/index.ts @@ -1,17 +1,17 @@ import { vertexConfig } from "@/vertex.config"; import type { VertexApiAdapter } from "@/core/config/vertex-config"; import type { VertexAdapter } from "./types"; -import { createAirQoAdapter } from "./airqo"; -import { createMockAdapter } from "./mock"; +import { airqoAdapter } from "./airqo"; +import { mockAdapter } from "./mock"; export function createAdapter( adapterName: VertexApiAdapter = vertexConfig.api.adapter, ): VertexAdapter { switch (adapterName) { case "mock": - return createMockAdapter(); + return mockAdapter; case "airqo": - return createAirQoAdapter(); + return airqoAdapter; default: { const exhaustiveCheck: never = adapterName; throw new Error(`Unsupported Vertex adapter: ${exhaustiveCheck}`); diff --git a/src/vertex/core/adapters/mock.ts b/src/vertex/core/adapters/mock.ts index 51f573e168..1d2adb6784 100644 --- a/src/vertex/core/adapters/mock.ts +++ b/src/vertex/core/adapters/mock.ts @@ -117,8 +117,8 @@ function readingIsInRange(reading: Reading, range: DateRange) { return readingTime >= startTime && readingTime <= endTime; } -export function createMockAdapter(): VertexAdapter { - return { +export const mockAdapter: VertexAdapter = (() => { + const coreMocks: Partial = { async getCurrentUser() { return { success: true, @@ -355,4 +355,16 @@ export function createMockAdapter(): VertexAdapter { return clone(mockNetworks); }, }; -} + + return new Proxy(coreMocks as VertexAdapter, { + get(target, prop, receiver) { + if (prop in target) { + return Reflect.get(target, prop, receiver); + } + return async (...args: any[]) => { + console.warn(`[Mock Adapter] Method '${String(prop)}' is not implemented.`); + return { success: true, message: `Mocked call to ${String(prop)}` }; + }; + } + }); +})(); diff --git a/src/vertex/core/adapters/types.ts b/src/vertex/core/adapters/types.ts index 8adc6c134d..3a5dabdbdf 100644 --- a/src/vertex/core/adapters/types.ts +++ b/src/vertex/core/adapters/types.ts @@ -1,37 +1,40 @@ -import type { - Device, - DeviceAvailabilityResponse, - DeviceClaimRequest, - DeviceClaimResponse, - DeviceCreationResponse, - DevicesSummaryResponse, - DeviceUpdateGroupResponse, - MaintenanceLogData, - MyDevicesResponse, -} from "@/app/types/devices"; -import type { - Cohort, - CohortsSummaryResponse, - GroupCohortsResponse, - OriginalCohortResponse, -} from "@/app/types/cohorts"; -import type { Site } from "@/app/types/sites"; -import type { UserDetailsResponse, LoginCredentials } from "@/app/types/users"; -import type { Network } from "@/core/apis/networks"; -import type { - DeviceActivitiesResponse, - DeviceCountResponse, - DeviceDetailsResponse, - GetDevicesSummaryParams, -} from "@/core/apis/devices"; -import type { - CreateSiteResponse, - GetSitesSummaryParams, - SitesSummaryCountResponse, - SitesSummaryResponse, -} from "@/core/apis/sites"; -import type { GetCohortsSummaryParams } from "@/core/apis/cohorts"; +import { devices } from "../apis/devices"; +import { sites } from "../apis/sites"; +import { cohorts } from "../apis/cohorts"; +import { networks } from "../apis/networks"; +import { users } from "../apis/users"; +import { grids } from "../apis/grids"; +import { groupsApi } from "../apis/organizations"; +import { permissions } from "../apis/permissions"; +import { roles } from "../apis/roles"; +import { feedbackService } from "../apis/feedback"; +type BaseApis = typeof devices & + typeof sites & + typeof cohorts & + typeof networks & + typeof users & + typeof grids & + typeof groupsApi & + typeof permissions & + typeof roles & + typeof feedbackService; + +export interface VertexAdapter extends BaseApis { + // We keep the generic overrides we defined previously so that + // components using adapter.getDevices (instead of adapter.getDevicesSummaryApi) still work + getDevices: typeof devices.getDevicesSummaryApi; + getDevicesByStatus: typeof devices.getDevicesByStatusApi; + getDeviceCount: typeof devices.getDeviceCountApi; + getDevice: typeof devices.getDeviceDetails; + updateDevice: typeof devices.updateDeviceLocal; + getSites: typeof sites.getSitesSummary; + getSite: typeof sites.getSiteDetails; + getSitesByStatus: typeof sites.getSitesByStatusApi; + getNetworks: typeof networks.getNetworksApi; + getCurrentUser: typeof users.getUserDetails; + login: typeof users.loginWithDetails; +} export interface DateRange { startDate: string; endDate: string; @@ -92,65 +95,3 @@ export interface CreateSiteInput { longitude: string; network: string; } - -export interface VertexAdapter { - getCurrentUser(userId?: string): Promise; - login?(credentials: LoginCredentials): Promise; - - getDevices( - params?: GetDevicesSummaryParams, - signal?: AbortSignal, - ): Promise; - getDevicesByStatus(params: { - status: string; - limit?: number; - skip?: number; - search?: string; - sortBy?: string; - order?: "asc" | "desc"; - network?: string; - }): Promise; - getDeviceCount(params?: { network?: string }): Promise; - getDevice(id: string): Promise; - createDevice(data: CreateDeviceInput): Promise; - updateDevice( - deviceId: string, - deviceData: Partial, - ): Promise<{ success: boolean; message: string; updated_device: Device }>; - deployDevice(data: DeviceDeployInput): Promise<{ success: boolean; message: string }>; - recallDevice( - deviceName: string, - recallData: DeviceRecallInput, - ): Promise<{ success: boolean; message: string }>; - addMaintenanceLog( - deviceName: string, - logData: MaintenanceLogData, - ): Promise<{ success: boolean; message: string; data: MaintenanceLogData }>; - getDeviceActivities( - deviceName: string, - params?: { page?: number; limit?: number }, - ): Promise; - - getSites( - params: GetSitesSummaryParams, - signal?: AbortSignal, - ): Promise; - getSitesByStatus(params: { - status: string; - limit?: number; - skip?: number; - search?: string; - sortBy?: string; - order?: "asc" | "desc"; - network?: string; - group?: string; - }): Promise; - getSite(id: string): Promise<{ message: string; data: Site }>; - getSiteActivities( - siteId: string, - params?: { page?: number; limit?: number }, - ): Promise; - - getNetworks(): Promise; -} - diff --git a/src/vertex/core/hooks/useDevices.ts b/src/vertex/core/hooks/useDevices.ts index 7e2c3c70d5..be12a57553 100644 --- a/src/vertex/core/hooks/useDevices.ts +++ b/src/vertex/core/hooks/useDevices.ts @@ -13,6 +13,7 @@ import { DeviceCountResponse, type DeviceActivitiesResponse, } from '../apis/devices'; +import { adapter } from '../adapters'; import { useGroupCohorts, usePersonalUserCohorts } from './useCohorts'; import { useAppSelector } from '../redux/hooks'; import { useMemo } from 'react'; @@ -112,13 +113,13 @@ export const useDevices = (options: DeviceListingOptions = {}) => { if (isAirQoGroup) { // Admin view: status across all (or filtered network) - return devices.getDevicesByStatusApi(commonParams); + return adapter.getDevicesByStatus(commonParams); } else { // Org view: status scoped by organization cohorts if (!groupCohortIds || groupCohortIds.length === 0) { throw new Error('Cohort IDs are not available for this organization.'); } - return devices.getDevicesByStatusApi({ + return adapter.getDevicesByStatus({ ...commonParams, cohort_id: groupCohortIds, }); @@ -135,7 +136,7 @@ export const useDevices = (options: DeviceListingOptions = {}) => { ...(network && { network }), ...rest, }; - return devices.getDevicesSummaryApi(params, signal); + return adapter.getDevices(params, signal); } if (!groupCohortIds) { @@ -143,7 +144,7 @@ export const useDevices = (options: DeviceListingOptions = {}) => { throw new Error('Cohort IDs are not available yet.'); } - return devices.getDevicesByCohorts({ + return adapter.getDevicesByCohorts({ cohort_ids: groupCohortIds, limit: safeLimit, skip, @@ -205,7 +206,7 @@ export const useMyDevices = ( groupIds, cohortIds, ], - queryFn: () => devices.getMyDevices(userId, groupIds, cohortIds), + queryFn: () => adapter.getMyDevices(userId, groupIds, cohortIds), enabled: !!userId && enabled && !!userDetails && !isPersonalCohortsLoading, staleTime: 60_000, // 1 minute }); @@ -258,18 +259,18 @@ export const useDeviceCount = (options: { enabled?: boolean; cohortIds?: string[ ], queryFn: () => { if (network) { - return devices.getDeviceCountApi({ network }); + return adapter.getDeviceCount({ network }); } if (isAirQoGroup) { - return devices.getDeviceCountApi({}); + return adapter.getDeviceCount({}); } if (!effectiveCohortIds || effectiveCohortIds.length === 0) { return Promise.reject(new Error('Cohort IDs must be provided.')); } - return devices.getDeviceCountApi({ cohort_id: effectiveCohortIds }); + return adapter.getDeviceCount({ cohort_id: effectiveCohortIds }); }, enabled: isQueryEnabled, staleTime: 300_000, @@ -286,7 +287,7 @@ export const useDeviceCount = (options: { enabled?: boolean; cohortIds?: string[ export const useDeviceAvailability = (deviceName: string) => { return useQuery>({ queryKey: ['deviceAvailability', deviceName], - queryFn: () => devices.checkDeviceAvailability(deviceName), + queryFn: () => adapter.checkDeviceAvailability(deviceName), enabled: !!deviceName && deviceName.length > 0, staleTime: 30000, }); @@ -305,7 +306,7 @@ export const useClaimDevice = (options?: UseClaimDeviceOptions) => { AxiosError, DeviceClaimRequest >({ - mutationFn: devices.claimDevice, + mutationFn: adapter.claimDevice, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); @@ -330,7 +331,7 @@ export const useBulkClaimDevices = (options?: UseBulkClaimDevicesOptions) => { AxiosError, BulkDeviceClaimRequest >({ - mutationFn: devices.claimDevicesBulk, + mutationFn: adapter.claimDevicesBulk, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); @@ -350,7 +351,7 @@ export const useAssignDeviceToOrganization = () => { AxiosError, DeviceAssignmentRequest >({ - mutationFn: devices.assignDeviceToOrganization, + mutationFn: adapter.assignDeviceToOrganization, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); @@ -372,7 +373,7 @@ export const useUnassignDeviceFromOrganization = (options?: UseUnassignDeviceFro { deviceName: string; userId: string } >({ mutationFn: ({ deviceName, userId }) => - devices.unassignDeviceFromOrganization(deviceName, userId), + adapter.unassignDeviceFromOrganization(deviceName, userId), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['myDevices'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); @@ -387,7 +388,7 @@ export const useUnassignDeviceFromOrganization = (options?: UseUnassignDeviceFro export const useDeviceDetails = (deviceId: string) => { return useQuery>({ queryKey: ['device-details', deviceId], - queryFn: () => devices.getDeviceDetails(deviceId), + queryFn: () => adapter.getDevice(deviceId), enabled: !!deviceId, }); }; @@ -402,7 +403,7 @@ export const useUpdateDeviceLocal = () => { }: { deviceId: string; deviceData: Partial; - }) => devices.updateDeviceLocal(deviceId, deviceData), + }) => adapter.updateDevice(deviceId, deviceData), onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ['device-details', variables.deviceId], @@ -422,7 +423,7 @@ export const useUpdateDeviceGlobal = () => { }: { deviceId: string; deviceData: Partial; - }) => devices.updateDeviceGlobal(deviceId, deviceData), + }) => adapter.updateDeviceGlobal(deviceId, deviceData), onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ['device-details', variables.deviceId], @@ -435,7 +436,7 @@ export const useUpdateDeviceGlobal = () => { export const useDeviceStatusFeed = (deviceNumber?: number) => { return useQuery({ queryKey: ['deviceStatusFeed', deviceNumber], - queryFn: () => devices.getDeviceStatusFeed(deviceNumber!), + queryFn: () => adapter.getDeviceStatusFeed(deviceNumber!), enabled: !!deviceNumber, refetchOnWindowFocus: false, }); @@ -460,7 +461,7 @@ export const useUpdateDeviceBulk = (options?: UseUpdateDeviceBulkOptions) => { BulkDeviceUpdatePayload >({ mutationFn: ({ deviceIds, updateData }) => - devices.bulkUpdateDeviceDetails(deviceIds, updateData), + adapter.bulkUpdateDeviceDetails(deviceIds, updateData), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["devices"] }); @@ -489,7 +490,7 @@ export const useUpdateDeviceGroup = (options?: UseUpdateDeviceGroupOptions) => { { deviceId: string; groupName: string } >({ mutationFn: ({ deviceId, groupName }) => - devices.bulkUpdateDeviceDetails([deviceId], { groups: [groupName] }), + adapter.bulkUpdateDeviceDetails([deviceId], { groups: [groupName] }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["devices"] }); @@ -530,7 +531,7 @@ export const useCreateDevice = () => { ...(tags && tags.length > 0 && { tags }), }; - return devices.createDevice(payload); + return adapter.createDevice(payload); }, onSuccess: async (data) => { @@ -587,7 +588,7 @@ export const useImportDevice = () => { ...rest, ...(tags && tags.length > 0 && { tags }), }; - return devices.importDevice(payload); + return adapter.importDevice(payload); }, onSuccess: () => { // Refresh based on active module @@ -625,9 +626,9 @@ export const useBulkImportDevices = () => { >({ mutationFn: (variables) => { if (variables.type === 'csv') { - return devices.importBulkDevicesCSV(variables.payload); + return adapter.importBulkDevicesCSV(variables.payload); } - return devices.importBulkDevicesJSON(variables.payload); + return adapter.importBulkDevicesJSON(variables.payload); }, onSuccess: (data) => { // Refresh based on active module @@ -672,7 +673,7 @@ export const useDeployDevice = () => { lastName?: string; email?: string; userName?: string; - }) => devices.deployDevice(deviceData), + }) => adapter.deployDevice(deviceData), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['claimedDevices'] }); @@ -700,7 +701,7 @@ export const useRecallDevice = () => { email?: string; userName?: string; }; - }) => devices.recallDevice(deviceName, recallData), + }) => adapter.recallDevice(deviceName, recallData), onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['device-details'] }); @@ -720,7 +721,7 @@ export const useAddMaintenanceLog = () => { }: { deviceName: string; logData: MaintenanceLogData; - }) => devices.addMaintenanceLog(deviceName, logData), + }) => adapter.addMaintenanceLog(deviceName, logData), onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['device-details'] }); @@ -736,7 +737,7 @@ export const useDecryptDeviceKeys = () => { AxiosError, DecryptionRequest[] >({ - mutationFn: devices.decryptDeviceKeys, + mutationFn: adapter.decryptDeviceKeys, onSuccess: () => { logger.info('Keys decrypted successfully!'); }, @@ -757,7 +758,7 @@ export const usePrepareDeviceForShipping = (options?: UsePrepareDeviceForShippin { deviceName: string; tokenType?: 'hex' | 'readable' } >({ mutationFn: ({ deviceName, tokenType }) => - devices.prepareDeviceForShipping(deviceName, tokenType), + adapter.prepareDeviceForShipping(deviceName, tokenType), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['shippingStatus'] }); options?.onSuccess?.(data); @@ -782,7 +783,7 @@ export const usePrepareBulkDevicesForShipping = (options?: UsePrepareBulkDevices { deviceNames: string[]; tokenType?: 'hex' | 'readable'; batchName?: string } >({ mutationFn: ({ deviceNames, tokenType, batchName }) => - devices.prepareBulkDevicesForShipping(deviceNames, tokenType, batchName), + adapter.prepareBulkDevicesForShipping(deviceNames, tokenType, batchName), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['shippingBatches'] }); options?.onSuccess?.(data); @@ -804,7 +805,7 @@ export const useGenerateShippingLabels = (options?: UseGenerateShippingLabelsOpt AxiosError, string[] >({ - mutationFn: (deviceNames) => devices.generateShippingLabels(deviceNames), + mutationFn: (deviceNames) => adapter.generateShippingLabels(deviceNames), onSuccess: (data) => { options?.onSuccess?.(data); }, @@ -817,7 +818,7 @@ export const useGenerateShippingLabels = (options?: UseGenerateShippingLabelsOpt export const useShippingStatus = (deviceNames?: string[]) => { return useQuery>({ queryKey: ['shippingStatus', deviceNames], - queryFn: () => devices.getShippingStatus(deviceNames), + queryFn: () => adapter.getShippingStatus(deviceNames), staleTime: 60000, }); }; @@ -825,7 +826,7 @@ export const useShippingStatus = (deviceNames?: string[]) => { export const useOrphanedDevices = (userId: string) => { return useQuery({ queryKey: ['orphanedDevices', userId], - queryFn: () => devices.getOrphanedDevices(userId), + queryFn: () => adapter.getOrphanedDevices(userId), enabled: !!userId, staleTime: 300_000, // 5 minutes }); @@ -834,7 +835,7 @@ export const useOrphanedDevices = (userId: string) => { export const useShippingBatches = (params: { limit?: number; skip?: number } = {}) => { return useQuery>({ queryKey: ['shippingBatches', params], - queryFn: () => devices.getShippingBatches(params), + queryFn: () => adapter.getShippingBatches(params), staleTime: 60_000, }); }; @@ -842,7 +843,7 @@ export const useShippingBatches = (params: { limit?: number; skip?: number } = { export const useShippingBatchDetails = (batchId: string) => { return useQuery>({ queryKey: ['shippingBatchDetails', batchId], - queryFn: () => devices.getShippingBatchDetails(batchId), + queryFn: () => adapter.getShippingBatchDetails(batchId), enabled: !!batchId, staleTime: 60_000, }); @@ -852,7 +853,7 @@ export const useDeviceActivities = (deviceName: string) => { return useInfiniteQuery>({ queryKey: ['deviceActivities', deviceName], queryFn: ({ pageParam = 1 }) => - devices.getDeviceActivities(deviceName, { page: pageParam as number, limit: 10 }), + adapter.getDeviceActivities(deviceName, { page: pageParam as number, limit: 10 }), getNextPageParam: (lastPage: DeviceActivitiesResponse, allPages: DeviceActivitiesResponse[]) => { if (!lastPage.meta) { if (!lastPage.site_activities || lastPage.site_activities.length < 10) return undefined; diff --git a/src/vertex/core/hooks/useGrids.ts b/src/vertex/core/hooks/useGrids.ts index 88a3f2496f..257b49c50b 100644 --- a/src/vertex/core/hooks/useGrids.ts +++ b/src/vertex/core/hooks/useGrids.ts @@ -11,6 +11,8 @@ import { useDispatch } from "react-redux"; import React from "react"; +import { adapter } from '../adapters'; + interface ErrorResponse { message: string; errors?: @@ -47,7 +49,7 @@ export const useGrids = (options: GridListingOptions = {}) => { ...(sortBy && { sortBy }), ...(order && { order }), }; - return grids.getGridsApi(params); + return adapter.getGridsApi(params); }, enabled: true, staleTime: 300_000, @@ -77,7 +79,7 @@ export const useGridDetails = (gridId: string) => { const dispatch = useDispatch(); const { data, isLoading, error } = useQuery>({ queryKey: ["gridDetails", gridId], - queryFn: () => grids.getGridDetailsApi(gridId), + queryFn: () => adapter.getGridDetailsApi(gridId), enabled: !!gridId, }); @@ -108,7 +110,7 @@ export const useUpdateGridDetails = (gridId: string, options?: UseUpdateGridDeta error, } = useMutation, { name?: string; visibility?: boolean; admin_level?: string }>({ mutationFn: (updatedFields: { name?: string; visibility?: boolean; admin_level?: string }) => - grids.updateGridDetailsApi(gridId, updatedFields), + adapter.updateGridDetailsApi(gridId, updatedFields), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["gridDetails", gridId] }); queryClient.invalidateQueries({ queryKey: ["grids"] }); @@ -132,7 +134,7 @@ export const useCreateGrid = (options?: UseCreateGridOptions) => { const queryClient = useQueryClient(); const { mutate: createGrid, isPending: isLoading, error } = useMutation, CreateGrid>({ mutationFn: async (newGrid: CreateGrid) => - await grids.createGridApi(newGrid), + await adapter.createGridApi(newGrid), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["grids"] }); options?.onSuccess?.(data); @@ -153,7 +155,7 @@ interface UseCreateAdminLevelOptions { export const useCreateAdminLevel = (options?: UseCreateAdminLevelOptions) => { const queryClient = useQueryClient(); const { mutate: createAdminLevel, isPending: isLoading, error } = useMutation, { name: string }>({ - mutationFn: (data: { name: string }) => grids.createAdminLevelApi(data), + mutationFn: (data: { name: string }) => adapter.createAdminLevelApi(data), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["admin-levels"] }); options?.onSuccess?.(data); @@ -170,7 +172,7 @@ export const useCreateAdminLevel = (options?: UseCreateAdminLevelOptions) => { export const useAdminLevels = () => { const { data, isLoading, error } = useQuery>({ queryKey: ["admin-levels"], - queryFn: () => grids.getAdminLevelsApi(), + queryFn: () => adapter.getAdminLevelsApi(), staleTime: 300_000, }); @@ -189,7 +191,7 @@ interface UseUpdateAdminLevelOptions { export const useUpdateAdminLevel = (options?: UseUpdateAdminLevelOptions) => { const queryClient = useQueryClient(); const { mutate: updateAdminLevel, isPending: isLoading, error } = useMutation, { levelId: string; data: { name: string } }>({ - mutationFn: ({ levelId, data }) => grids.updateAdminLevelApi(levelId, data), + mutationFn: ({ levelId, data }) => adapter.updateAdminLevelApi(levelId, data), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["admin-levels"] }); options?.onSuccess?.(data); diff --git a/src/vertex/core/hooks/useNetworks.ts b/src/vertex/core/hooks/useNetworks.ts index 28c35e1130..6f55fd73a0 100644 --- a/src/vertex/core/hooks/useNetworks.ts +++ b/src/vertex/core/hooks/useNetworks.ts @@ -5,7 +5,7 @@ import { networks as networksApi, } from "@/core/apis/networks"; import { DeviceListingOptions } from "./useDevices"; -import { devices } from "../apis/devices"; +import { adapter } from "../adapters"; import { AxiosError } from "axios"; import type { DevicesSummaryResponse } from "@/app/types/devices"; @@ -22,7 +22,7 @@ export const useNetworks = () => { error, } = useQuery>({ queryKey: ["networks"], - queryFn: networksApi.getNetworksApi, + queryFn: adapter.getNetworks, staleTime: 300_000, // 5 minutes refetchOnWindowFocus: false, }); @@ -65,7 +65,7 @@ export const useNetworkDevices = (options: DeviceListingOptions = {}) => { // Hybrid Strategy: If filterStatus is provided, use the status endpoint if (filterStatus) { - return devices.getDevicesByStatusApi({ + return adapter.getDevicesByStatus({ status: filterStatus, limit: safeLimit, skip, @@ -76,7 +76,7 @@ export const useNetworkDevices = (options: DeviceListingOptions = {}) => { }); } - return devices.getDevicesSummaryApi({ + return adapter.getDevices({ limit: safeLimit, skip, ...(search && { search }), diff --git a/src/vertex/core/hooks/useRoles.ts b/src/vertex/core/hooks/useRoles.ts index 9167e86aa9..f9c455047c 100644 --- a/src/vertex/core/hooks/useRoles.ts +++ b/src/vertex/core/hooks/useRoles.ts @@ -6,6 +6,8 @@ import { AxiosError } from "axios"; import { Role } from "@/app/types/roles"; import React from "react"; +import { adapter } from '../adapters'; + interface ErrorResponse { message: string; } @@ -19,7 +21,7 @@ export const useRoles = () => { const { data, isLoading, error } = useQuery>({ queryKey: ["roles"], - queryFn: () => roles.getRolesApi(), + queryFn: () => adapter.getRolesApi(), onSuccess: (data: RolesResponse) => { dispatch(setRoles(data.roles)); }, @@ -40,7 +42,7 @@ export const useGroupRoles = (groupId: string) => { const { data, isLoading, error } = useQuery({ queryKey: ["grouproles", groupId], - queryFn: () => roles.getOrgRolesApi(groupId || ""), + queryFn: () => adapter.getOrgRolesApi(groupId || ""), }); React.useEffect(() => { diff --git a/src/vertex/core/hooks/useSites.ts b/src/vertex/core/hooks/useSites.ts index 0a791870fa..7e07d3ebd2 100644 --- a/src/vertex/core/hooks/useSites.ts +++ b/src/vertex/core/hooks/useSites.ts @@ -7,6 +7,7 @@ import { CreateSiteResponse, SiteRefreshResponse, } from "../apis/sites"; +import { adapter } from '../adapters'; import { DeviceActivitiesResponse } from "../apis/devices"; import { useGroupCohorts } from "./useCohorts"; @@ -37,7 +38,7 @@ export interface SiteListingOptions { export const useSiteActivitiesInfinite = (siteId: string) => { return useInfiniteQuery>({ queryKey: ["siteActivities", siteId], - queryFn: ({ pageParam = 1 }) => sites.getSiteActivities(siteId, { page: pageParam as number, limit: 10 }), + queryFn: ({ pageParam = 1 }) => adapter.getSiteActivities(siteId, { page: pageParam as number, limit: 10 }), getNextPageParam: (lastPage, allPages) => { if (!lastPage.meta) { if (!lastPage.site_activities || lastPage.site_activities.length < 10) return undefined; @@ -84,19 +85,19 @@ export const useSites = (options: SiteListingOptions = {}) => { }; if (options.status) { - return sites.getSitesByStatusApi({ + return adapter.getSitesByStatus({ status: options.status, ...params }); } - return sites.getSitesSummary(params, signal); + return adapter.getSites(params, signal); } if (!groupCohortIds) { throw new Error("Cohort IDs are not available yet."); } - return sites.getSitesByCohorts({ + return adapter.getSitesByCohorts({ cohort_ids: groupCohortIds, limit: safeLimit, skip, @@ -126,7 +127,7 @@ export const useSiteStatistics = (network?: string) => { const query = useQuery({ queryKey: ["sites-count-summary", network, activeGroup?.grp_title], - queryFn: () => sites.getSitesSummaryCount({ network: network || "" }), + queryFn: () => adapter.getSitesSummaryCount({ network: network || "" }), enabled: !!activeGroup?.grp_title, staleTime: 300_000, }); @@ -150,7 +151,7 @@ export const useApproximateCoordinates = () => { { latitude: string; longitude: string } >({ mutationFn: ({ latitude, longitude }) => - sites.getApproximateCoordinates(latitude, longitude), + adapter.getApproximateCoordinates(latitude, longitude), onError: (error) => { ReusableToast({ message: `Unable to get approximate coordinates: ${getApiErrorMessage(error)}`, @@ -180,7 +181,7 @@ export const useSiteDetails = ( return useQuery({ queryKey: ["site-details", siteId], queryFn: async () => { - const response = await sites.getSiteDetails(siteId); + const response = await adapter.getSite(siteId); return response.data; }, enabled: !!siteId && enabled, @@ -200,7 +201,7 @@ export const useUpdateSiteDetails = () => { Object.entries(data).filter(([, value]) => value !== undefined) ); - return sites.updateSiteDetails(siteId, cleanedData); + return adapter.updateSiteDetails(siteId, cleanedData); }, onSuccess: (data, { siteId }) => { ReusableToast({ @@ -233,11 +234,11 @@ export const useCreateSite = () => { return useMutation, CreateSiteRequest>({ mutationFn: async (data: CreateSiteRequest) => { - const createdSite = await sites.createSite(data); + const createdSite = await adapter.createSite(data); const siteId = createdSite?.site?._id; if (siteId && activeGroup?.grp_title) { - await sites.bulkUpdate({ + await adapter.bulkUpdate({ siteIds: [siteId], updateData: { groups: [activeGroup.grp_title], @@ -269,7 +270,7 @@ export const useRefreshSiteMetadata = () => { const queryClient = useQueryClient(); return useMutation, string>({ - mutationFn: (siteId: string) => sites.refreshSiteMetadata(siteId), + mutationFn: (siteId: string) => adapter.refreshSiteMetadata(siteId), onSuccess: (data, siteId) => { // Update the cache with the newly enriched site data queryClient.setQueryData(["site-details", siteId], data.site); From ccf4a4809c9f52d6e8509f83181bbe677ecaedaf Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 17:06:38 +0300 Subject: [PATCH 07/10] Use vertexConfig for org branding & features Replace hardcoded AirQo strings/assets with centralized vertexConfig values and add feature flags and theming support. Updated components and pages to use vertexConfig.org properties (logo, name, shortName, supportEmail, websiteUrl, primaryColor) for branding and contact links. RootLayout metadata now derives title/OG/twitter info from vertexConfig and converts primaryColor to RGB for a --primary CSS variable. Primary sidebar menu items (network requests, sites, grids, shipping) are now conditionally rendered based on vertexConfig.features. Files changed: auth-error, layout, login page, DownloadHero, desktop-titlebar, primary-sidebar, and topbar. --- src/vertex/app/auth-error/page.tsx | 9 +- src/vertex/app/layout.tsx | 43 +++--- src/vertex/app/login/page.tsx | 8 +- .../features/download/DownloadHero.tsx | 11 +- .../components/layout/desktop-titlebar.tsx | 7 +- .../components/layout/primary-sidebar.tsx | 127 ++++++++++-------- src/vertex/components/layout/topbar.tsx | 13 +- 7 files changed, 119 insertions(+), 99 deletions(-) diff --git a/src/vertex/app/auth-error/page.tsx b/src/vertex/app/auth-error/page.tsx index 11eb6aedfe..551b586c57 100644 --- a/src/vertex/app/auth-error/page.tsx +++ b/src/vertex/app/auth-error/page.tsx @@ -6,6 +6,7 @@ import Link from 'next/link' import Image from 'next/image' import { AlertCircle, ArrowLeft, ShieldAlert, Settings, Mail, UserPlus, Link as LinkIcon } from 'lucide-react' import ReusableButton from "@/components/shared/button/ReusableButton" +import { vertexConfig } from "@/vertex.config"; type ErrorType = { title: string; @@ -87,8 +88,8 @@ function AuthErrorContent() {
AirQo Logo Contact Support @@ -134,7 +135,7 @@ function AuthErrorContent() {
- © {new Date().getFullYear()} AirQo. All rights reserved. + © {new Date().getFullYear()} {vertexConfig.org.name}. All rights reserved.
) diff --git a/src/vertex/app/layout.tsx b/src/vertex/app/layout.tsx index 9c4dede794..f62aa6bab3 100644 --- a/src/vertex/app/layout.tsx +++ b/src/vertex/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from 'next/font/google'; import { getServerSession } from "next-auth/next"; import { options } from "@/app/api/auth/[...nextauth]/options"; import logger from '@/lib/logger'; +import { vertexConfig } from '@/vertex.config'; const inter = Inter({ subsets: ['latin'], @@ -15,44 +16,40 @@ const inter = Inter({ export const metadata: Metadata = { title: { - template: '%s | AirQo Vertex', - default: 'AirQo Vertex', + template: `%s | ${vertexConfig.org.name}`, + default: vertexConfig.org.name, }, - description: - "AirQo is Africa's leading air quality monitoring, research and analytics network. We use low cost technologies and AI to close the gaps in air quality data across the continent.", + description: vertexConfig.org.name + " is a leading air quality monitoring platform.", keywords: [ 'air quality', 'monitoring', - 'Africa', 'analytics', - 'pollution', 'environment', 'data', 'management', 'device', ], - authors: [{ name: 'Vertex Team' }], - creator: 'Vertex', - publisher: 'Vertex', + authors: [{ name: `${vertexConfig.org.shortName} Team` }], + creator: vertexConfig.org.name, + publisher: vertexConfig.org.name, openGraph: { - title: 'AirQo Vertex', - description: - "Africa's leading air quality monitoring network using low-cost technologies and AI.", + title: vertexConfig.org.name, + description: "Leading air quality device management platform", type: 'website', - url: 'https://vertex.airqo.net', + url: vertexConfig.org.websiteUrl, images: [ { - url: '/images/airqo-logo.svg', + url: vertexConfig.org.logo, width: 1200, height: 630, - alt: 'Vertex', + alt: vertexConfig.org.name, }, ], }, twitter: { card: 'summary_large_image', - title: 'AirQo Vertex', - description: "Africa's leading air quality device management platform", + title: vertexConfig.org.name, + description: "Leading air quality device management platform", images: ['/favicon.ico'], }, icons: { @@ -61,6 +58,14 @@ export const metadata: Metadata = { }, }; +const hexToRgbValues = (hex: string) => { + const normalizedHex = hex.replace('#', ''); + const r = parseInt(normalizedHex.slice(0, 2), 16); + const g = parseInt(normalizedHex.slice(2, 4), 16); + const b = parseInt(normalizedHex.slice(4, 6), 16); + return `${r} ${g} ${b}`; +}; + export default async function RootLayout({ children, }: { @@ -73,8 +78,10 @@ export default async function RootLayout({ logger.error("Failed to fetch session:", { error }); } + const primaryRgb = hexToRgbValues(vertexConfig.org.primaryColor); + return ( - + diff --git a/src/vertex/app/login/page.tsx b/src/vertex/app/login/page.tsx index 9f42c0dc19..846ef749cd 100644 --- a/src/vertex/app/login/page.tsx +++ b/src/vertex/app/login/page.tsx @@ -23,9 +23,9 @@ import { } from "@/core/redux/slices/userSlice"; import { getLastActiveModule } from "@/core/utils/userPreferences"; import { ROUTE_LINKS } from "@/core/routes"; -// import GoogleAuthSection from "@/components/features/auth/google-auth-section"; import SocialAuthSection from "@/components/features/auth/social-auth-section"; import { motion, AnimatePresence } from "framer-motion"; +import { vertexConfig } from "@/vertex.config"; const loginSchema = z.object({ @@ -217,8 +217,8 @@ export default function LoginPage() {
AirQo LogoShare your data

- Add your devices and stream live air quality data through AirQo's open data channels. + Add your devices and stream live air quality data through {vertexConfig.org.name}'s open data channels.

diff --git a/src/vertex/components/features/download/DownloadHero.tsx b/src/vertex/components/features/download/DownloadHero.tsx index 4a72ed4588..c508c66ef0 100644 --- a/src/vertex/components/features/download/DownloadHero.tsx +++ b/src/vertex/components/features/download/DownloadHero.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import ReusableButton from '@/components/shared/button/ReusableButton'; import { VERTEX_DESKTOP_DOWNLOADS } from '@/core/constants/app-downloads'; +import { vertexConfig } from '@/vertex.config'; const WindowsIcon = ({ className }: { className?: string }) => ( @@ -27,15 +28,15 @@ export default function DownloadHero() {
AirQo logo - Vertex + {vertexConfig.org.name}
@@ -43,7 +44,7 @@ export default function DownloadHero() { Deploy Devices. Share Air Data

- Install Vertex Desktop for a focused workspace built around field + Install {vertexConfig.org.name} Desktop for a focused workspace built around field deployment, device visibility, and trusted air quality data workflows.

@@ -76,7 +77,7 @@ export default function DownloadHero() {
- Vertex Desktop + {vertexConfig.org.name} Desktop
diff --git a/src/vertex/components/layout/desktop-titlebar.tsx b/src/vertex/components/layout/desktop-titlebar.tsx index ec7b3e529d..0b6e8c2656 100644 --- a/src/vertex/components/layout/desktop-titlebar.tsx +++ b/src/vertex/components/layout/desktop-titlebar.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import Image from 'next/image'; +import { vertexConfig } from '@/vertex.config'; /** * DesktopTitleBar @@ -120,7 +121,7 @@ export default function DesktopTitleBar() { const text = isDark ? '#f8fafc' : '#111827'; const mutedText = isDark ? '#94a3b8' : '#6b7280'; const hoverBg = isDark ? '#334155' : '#e5e7eb'; - const logoSrc = brandingIcon || '/images/airqo_logo.svg'; + const logoSrc = brandingIcon || vertexConfig.org.logo; const titlebarStyle: AppRegionStyle = { position: 'fixed', @@ -202,14 +203,14 @@ export default function DesktopTitleBar() { > AirQo - AirQo Vertex + {vertexConfig.org.name}
diff --git a/src/vertex/components/layout/primary-sidebar.tsx b/src/vertex/components/layout/primary-sidebar.tsx index de02eec30e..29ce414dcf 100644 --- a/src/vertex/components/layout/primary-sidebar.tsx +++ b/src/vertex/components/layout/primary-sidebar.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'; import { ROUTE_LINKS } from '@/core/routes'; import { PERMISSIONS } from '@/core/permissions/constants'; import PermissionTooltip from '@/components/ui/permission-tooltip'; +import { vertexConfig } from '@/vertex.config'; interface PrimarySidebarProps { isOpen: boolean; @@ -134,13 +135,13 @@ const PrimarySidebar: React.FC = ({
Logo
- Vertex + {vertexConfig.org.name} {activeGroup?.grp_title && (
= ({ isActive={pathname === ROUTE_LINKS.ADMIN_NETWORKS} /> - { - navigateWithShimmer(ROUTE_LINKS.ADMIN_NETWORK_REQUESTS, () => { - handleModuleChange('admin', ROUTE_LINKS.ADMIN_NETWORK_REQUESTS); - setIsDropdownOpen(false); - }); - }} - label="Manufacturer Requests" - subLabel="Review new sensor requests" - isActive={pathname === ROUTE_LINKS.ADMIN_NETWORK_REQUESTS} - /> + {vertexConfig.features.networkRequests && ( + { + navigateWithShimmer(ROUTE_LINKS.ADMIN_NETWORK_REQUESTS, () => { + handleModuleChange('admin', ROUTE_LINKS.ADMIN_NETWORK_REQUESTS); + setIsDropdownOpen(false); + }); + }} + label="Manufacturer Requests" + subLabel="Review new sensor requests" + isActive={pathname === ROUTE_LINKS.ADMIN_NETWORK_REQUESTS} + /> + )} = ({ isActive={pathname.startsWith(ROUTE_LINKS.COHORTS)} /> - { - navigateWithShimmer(ROUTE_LINKS.SITES, () => { - handleModuleChange('admin', ROUTE_LINKS.SITES); - setIsDropdownOpen(false); - }); - }} - label="Sites" - subLabel="Manage location deployments" - isActive={pathname.startsWith(ROUTE_LINKS.SITES)} - /> + {vertexConfig.features.siteManagement && ( + { + navigateWithShimmer(ROUTE_LINKS.SITES, () => { + handleModuleChange('admin', ROUTE_LINKS.SITES); + setIsDropdownOpen(false); + }); + }} + label="Sites" + subLabel="Manage location deployments" + isActive={pathname.startsWith(ROUTE_LINKS.SITES)} + /> + )} - { - navigateWithShimmer(ROUTE_LINKS.GRIDS, () => { - handleModuleChange('admin', ROUTE_LINKS.GRIDS); - setIsDropdownOpen(false); - }); - }} - label="Grids" - subLabel="Configure spatial grids" - isActive={pathname.startsWith(ROUTE_LINKS.GRIDS)} - /> + {vertexConfig.features.siteManagement && ( + { + navigateWithShimmer(ROUTE_LINKS.GRIDS, () => { + handleModuleChange('admin', ROUTE_LINKS.GRIDS); + setIsDropdownOpen(false); + }); + }} + label="Grids" + subLabel="Configure spatial grids" + isActive={pathname.startsWith(ROUTE_LINKS.GRIDS)} + /> + )} - { - navigateWithShimmer(ROUTE_LINKS.ADMIN_SHIPPING, () => { - handleModuleChange('admin', ROUTE_LINKS.ADMIN_SHIPPING); - setIsDropdownOpen(false); - }); - }} - label="Shipping" - subLabel="Track device logistics" - isActive={pathname.startsWith(ROUTE_LINKS.ADMIN_SHIPPING)} - /> + {vertexConfig.features.shipping && ( + { + navigateWithShimmer(ROUTE_LINKS.ADMIN_SHIPPING, () => { + handleModuleChange('admin', ROUTE_LINKS.ADMIN_SHIPPING); + setIsDropdownOpen(false); + }); + }} + label="Shipping" + subLabel="Track device logistics" + isActive={pathname.startsWith(ROUTE_LINKS.ADMIN_SHIPPING)} + /> + )} )} diff --git a/src/vertex/components/layout/topbar.tsx b/src/vertex/components/layout/topbar.tsx index 01bc9b6e29..2ea8b2aadb 100644 --- a/src/vertex/components/layout/topbar.tsx +++ b/src/vertex/components/layout/topbar.tsx @@ -25,12 +25,13 @@ import { openFeedbackDialog } from '../features/feedback/feedback-dialog'; import { useSession } from 'next-auth/react'; import type { UserDetails } from '@/app/types/users'; import { useTheme } from "next-themes"; +import { vertexConfig } from '@/vertex.config'; interface TopbarProps { onMenuClick: () => void; } -const AirqoLogoRaw = '/images/airqo_logo.svg'; +const LogoRaw = vertexConfig.org.logo; const Topbar: React.FC = ({ onMenuClick }) => { const { setTheme, resolvedTheme } = useTheme(); @@ -66,8 +67,8 @@ const Topbar: React.FC = ({ onMenuClick }) => { {...buttonProps} > AirQo logo = ({ onMenuClick }) => { - Vertex + {vertexConfig.org.name} {isAdminMode && ( Administrator @@ -144,8 +145,8 @@ const Topbar: React.FC = ({ onMenuClick }) => { {isPreviousSiteOpen ? ( -
diff --git a/src/vertex/components/features/org-picker/organization-modal.tsx b/src/vertex/components/features/org-picker/organization-modal.tsx index 0b6f368a4b..19deb0b5a9 100644 --- a/src/vertex/components/features/org-picker/organization-modal.tsx +++ b/src/vertex/components/features/org-picker/organization-modal.tsx @@ -89,17 +89,17 @@ const OrganizationModal: React.FC = ({ return (
handleSelection(group)} - className={`flex items-center justify-between p-2 hover:bg-accent dark:hover:bg-zinc-800 rounded-lg cursor-pointer transition-colors duration-200 ${isActive && "border border-blue-200 bg-blue-50/50 dark:bg-blue-900/20 dark:border-blue-800"}`} + className={`flex items-center justify-between p-2 hover:bg-accent dark:hover:bg-zinc-800 rounded-lg cursor-pointer transition-colors duration-200 ${isActive && "border border-primary/30 bg-primary/5 dark:bg-primary/20 dark:border-primary/50"}`} >
-
+
{group.grp_title.charAt(0)}

{formatTitle(group.grp_title)}

- {isActive &&
} + {isActive &&
}
); }; diff --git a/src/vertex/components/features/shipping/ShippingBatchesTable.tsx b/src/vertex/components/features/shipping/ShippingBatchesTable.tsx index f710fa6388..0f114625a9 100644 --- a/src/vertex/components/features/shipping/ShippingBatchesTable.tsx +++ b/src/vertex/components/features/shipping/ShippingBatchesTable.tsx @@ -28,7 +28,7 @@ const ShippingBatchesTable: React.FC = () => { key: 'device_count', label: 'Device Count', render: (value) => ( - + {value as number} devices ) @@ -47,7 +47,7 @@ const ShippingBatchesTable: React.FC = () => { label: 'Actions', render: (_, item) => ( @@ -122,7 +122,7 @@ export const TableExportModal: React.FC = ({ type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => handleColumnToggle(col.key)} - className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + className="rounded border-gray-300 text-primary focus:ring-primary" /> {col.title} @@ -142,7 +142,7 @@ export const TableExportModal: React.FC = ({ type="checkbox" checked={selectedColumns.includes(col.key)} onChange={() => handleColumnToggle(col.key)} - className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" + className="rounded border-gray-300 text-primary focus:ring-primary" /> {col.title} From 3683d8417f0e0bbaee166955a78de35a958c0258 Mon Sep 17 00:00:00 2001 From: Codebmk Date: Sun, 7 Jun 2026 17:45:15 +0300 Subject: [PATCH 09/10] Add Vertex web app template Add a full Vertex (Next.js) web app scaffold under src/vertex-template. This commit introduces app pages, UI components, feature modules (devices, networks, cohorts, grids, shipping, sites), hooks, core services, Redux slices, API routes, types, config files (.eslintrc, .prettierrc, tailwind, next.config, tsconfig), Dockerfile, env example, README and various utilities to bootstrap the AirQo device & network management web application. The template includes route guards, permission constants, and client/server helpers to support development and production builds. --- src/vertex-template/.dockerignore | 6 + src/vertex-template/.env.example | 21 + src/vertex-template/.eslintrc.json | 3 + src/vertex-template/.gitignore | 125 + src/vertex-template/.prettierignore | 11 + src/vertex-template/.prettierrc | 10 + src/vertex-template/Dockerfile | 41 + src/vertex-template/README.md | 92 + .../cohorts/[id]/devices/[deviceId]/page.tsx | 25 + .../admin/cohorts/[id]/page.tsx | 177 + .../(authenticated)/admin/cohorts/page.tsx | 306 + .../(authenticated)/admin/grids/[id]/page.tsx | 76 + .../app/(authenticated)/admin/grids/page.tsx | 29 + .../app/(authenticated)/admin/layout.tsx | 27 + .../networks/[id]/devices/[deviceId]/page.tsx | 25 + .../admin/networks/[id]/page.tsx | 132 + .../(authenticated)/admin/networks/page.tsx | 35 + .../requests/NetworkRequestsClient.tsx | 183 + .../admin/networks/requests/page.tsx | 39 + .../admin/shipping/[batchId]/page.tsx | 218 + .../(authenticated)/admin/shipping/page.tsx | 89 + .../admin/sites/[id]/devices.tsx | 82 + .../sites/[id]/devices/[deviceId]/page.tsx | 25 + .../(authenticated)/admin/sites/[id]/page.tsx | 123 + .../app/(authenticated)/admin/sites/page.tsx | 46 + .../app/(authenticated)/cohorts/[id]/page.tsx | 123 + .../app/(authenticated)/cohorts/page.tsx | 176 + .../devices/my-devices/page.tsx | 232 + .../devices/overview/[id]/page.tsx | 11 + .../(authenticated)/devices/overview/page.tsx | 72 + .../app/(authenticated)/home/page.tsx | 694 + .../app/(authenticated)/layout.tsx | 29 + .../app/(authenticated)/sites/[id]/page.tsx | 125 + .../(authenticated)/sites/overview/page.tsx | 27 + ...QO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md | 193 + .../internal/ACCESS-CONTROL-ARCHITECTURE.md | 79 + .../app/_docs/internal/RBAC-Feature-Access.md | 104 + .../app/_docs/internal/device-status.md | 36 + .../vertex-template-adapter-foundation.md | 43 + .../vertex-template-config-contract.md | 47 + .../vertex-template-coupling-audit.md | 137 + .../app/api/[...path]/route.ts | 48 + .../app/api/auth/[...nextauth]/options.ts | 355 + .../app/api/auth/[...nextauth]/route.ts | 37 + .../app/api/cloudinary/upload/route.ts | 107 + .../[id]/[action]/route.ts | 55 + .../network-creation-requests/route.ts | 57 + .../app/api/log-to-slack/route.ts | 257 + src/vertex-template/app/api/network/route.ts | 95 + src/vertex-template/app/auth-error/page.tsx | 154 + src/vertex-template/app/changelog.md | 9 + src/vertex-template/app/client-layout.tsx | 31 + src/vertex-template/app/error.tsx | 36 + src/vertex-template/app/favicon-16x16.png | Bin 0 -> 373 bytes src/vertex-template/app/favicon-32x32.png | Bin 0 -> 730 bytes src/vertex-template/app/favicon.ico | Bin 0 -> 15406 bytes .../app/fonts/GeistMonoVF.woff | Bin 0 -> 67864 bytes src/vertex-template/app/fonts/GeistVF.woff | Bin 0 -> 66268 bytes src/vertex-template/app/globals.css | 543 + src/vertex-template/app/layout.tsx | 92 + src/vertex-template/app/login/page.tsx | 407 + src/vertex-template/app/not-found.tsx | 68 + src/vertex-template/app/page.tsx | 8 + src/vertex-template/app/providers.tsx | 63 + src/vertex-template/app/types/clients.ts | 26 + src/vertex-template/app/types/cohorts.ts | 106 + src/vertex-template/app/types/devices.ts | 461 + src/vertex-template/app/types/export.ts | 17 + src/vertex-template/app/types/grids.ts | 40 + src/vertex-template/app/types/groups.ts | 29 + src/vertex-template/app/types/layout.ts | 5 + src/vertex-template/app/types/roles.ts | 39 + src/vertex-template/app/types/sites.ts | 104 + src/vertex-template/app/types/userStats.ts | 25 + src/vertex-template/app/types/users.ts | 130 + src/vertex-template/components.json | 21 + .../features/auth/cookie-info-banner.tsx | 52 + .../features/auth/social-auth-section.tsx | 160 + .../features/claim/DeviceEntryRow.tsx | 67 + .../features/claim/FileUploadParser.tsx | 352 + .../features/claim/claim-device-modal.tsx | 984 ++ .../features/claim/steps/BulkClaimResults.tsx | 128 + .../features/claim/steps/BulkInputStep.tsx | 97 + .../claim/steps/CohortAssignmentBanner.tsx | 22 + .../features/claim/steps/CohortImportStep.tsx | 33 + .../claim/steps/ConfirmationSteps.tsx | 79 + .../features/claim/steps/ManualInputStep.tsx | 53 + .../features/claim/steps/MethodSelectStep.tsx | 89 + .../features/claim/steps/QRScanStep.tsx | 98 + .../features/claim/steps/SuccessStep.tsx | 40 + .../components/features/claim/utils.ts | 31 + .../cohorts/assign-cohort-devices.tsx | 312 + .../cohorts/assign-cohorts-to-group.tsx | 146 + .../features/cohorts/cohort-detail-card.tsx | 319 + .../cohorts/cohort-measurements-api-card.tsx | 59 + .../cohorts/cohort-organizations-card.tsx | 229 + .../features/cohorts/cohorts-empty-state.tsx | 45 + .../cohorts/create-cohort-from-cohorts.tsx | 254 + .../features/cohorts/create-cohort.tsx | 587 + .../features/cohorts/device-name-parser.tsx | 320 + .../cohorts/edit-cohort-details-modal.tsx | 312 + .../cohorts/unassign-cohort-devices.tsx | 214 + .../cohorts/unassign-cohort-from-group.tsx | 83 + .../features/dashboard/stat-card.tsx | 165 + .../features/dashboard/stats-cards.tsx | 134 + .../devices/add-maintenance-log-modal.tsx | 152 + .../bulk-edit-device-details-modal.tsx | 202 + .../client-paginated-devices-table.tsx | 187 + .../features/devices/create-device-modal.tsx | 196 + .../devices/deploy-device-component.tsx | 861 ++ .../features/devices/deploy-device-modal.tsx | 36 + .../features/devices/device-activity-item.tsx | 130 + .../devices/device-assignment-modal.tsx | 174 + .../features/devices/device-category-card.tsx | 75 + .../features/devices/device-details-card.tsx | 138 + .../devices/device-details-layout.tsx | 198 + .../features/devices/device-details-modal.tsx | 680 + .../features/devices/device-history-card.tsx | 197 + .../features/devices/device-list-table.tsx | 325 + .../features/devices/device-location-card.tsx | 94 + .../devices/device-measurements-api-card.tsx | 55 + .../features/devices/import-device-modal.tsx | 781 + .../devices/import-steps/BulkImportForm.tsx | 116 + .../devices/import-steps/BulkResultsStep.tsx | 45 + .../import-steps/CohortSelectionStep.tsx | 148 + .../devices/import-steps/ConfirmationStep.tsx | 53 + .../devices/import-steps/FieldMappingStep.tsx | 67 + .../import-steps/ImportMethodSelectStep.tsx | 55 + .../import-steps/ImportPreviewStep.tsx | 54 + .../import-steps/ImportSuccessStep.tsx | 23 + .../devices/import-steps/SingleImportForm.tsx | 188 + .../features/devices/import-steps/types.ts | 32 + .../devices/maintenance-status-card.tsx | 60 + .../features/devices/online-status-card.tsx | 219 + .../devices/orphaned-devices-alert.tsx | 68 + .../features/devices/qr-scanner.tsx | 178 + .../features/devices/recall-device-dialog.tsx | 100 + .../features/devices/run-device-test-card.tsx | 177 + .../features/devices/utils/table-columns.tsx | 206 + .../features/feedback/feedback-dialog.ts | 9 + .../features/feedback/feedback-launcher.tsx | 945 ++ .../feedback/login-feedback-toast.tsx | 76 + .../feedback/page-satisfaction-banner.tsx | 292 + .../reusable-satisfaction-feedback-toast.tsx | 279 + .../features/grids/admin-levels-modal.tsx | 164 + .../features/grids/create-admin-level.tsx | 132 + .../components/features/grids/create-grid.tsx | 215 + .../features/grids/date-range-picker.tsx | 64 + .../grids/edit-grid-details-dialog.tsx | 105 + .../features/grids/grid-details-card.tsx | 63 + .../grids/grid-measurements-api-card.tsx | 53 + .../features/grids/grids-list-table.tsx | 104 + .../features/home/HomeEmptyState.tsx | 69 + .../features/home/context-header.tsx | 56 + .../features/home/network-visibility-card.tsx | 330 + .../features/home/onboarding-checklist.tsx | 328 + .../LocationAutocomplete.tsx | 196 + .../components/features/mini-map/mini-map.tsx | 280 + .../features/network-status-banner/index.tsx | 210 + .../features/networks/NetworkStatsCards.tsx | 129 + .../client-paginated-networks-table.tsx | 144 + .../features/networks/create-network-form.tsx | 129 + .../features/networks/network-detail-card.tsx | 37 + .../networks/network-device-list-table.tsx | 205 + .../networks/network-request-dialog.tsx | 224 + .../features/networks/request-table.tsx | 126 + .../components/features/networks/schema.ts | 31 + .../org-picker/organization-modal.tsx | 161 + .../org-picker/organization-picker.tsx | 144 + .../shipping/PrepareShippingModal.tsx | 314 + .../shipping/ShippingBatchesTable.tsx | 92 + .../features/shipping/ShippingLabelPrint.tsx | 59 + .../shipping/ShippingLabelPrintModal.tsx | 319 + .../sites/client-paginated-sites-table.tsx | 170 + .../features/sites/create-site-form.tsx | 304 + .../sites/edit-site-details-dialog.tsx | 283 + .../features/sites/site-activity-card.tsx | 88 + .../features/sites/site-information-card.tsx | 124 + .../sites/site-measurements-api-card.tsx | 59 + .../features/sites/site-mobile-app-card.tsx | 51 + .../features/sites/site-stats-cards.tsx | 111 + .../features/sites/sites-list-table.tsx | 191 + .../components/layout/AppDropdown.tsx | 215 + .../components/layout/Footer.tsx | 22 + .../components/layout/NavItem.tsx | 126 + .../layout/accessConfig/context-guard.tsx | 69 + .../layout/accessConfig/forbidden-guard.tsx | 24 + .../layout/accessConfig/permission-guard.tsx | 108 + .../layout/accessConfig/route-guard.tsx | 164 + .../components/layout/layout.tsx | 167 + .../components/layout/loading/org-loading.tsx | 29 + .../layout/loading/session-loading.tsx | 17 + .../components/layout/primary-sidebar.tsx | 409 + .../components/layout/secondary-sidebar.tsx | 293 + .../components/layout/sub-menu.tsx | 62 + .../components/layout/topbar.tsx | 214 + .../components/shared/ErrorBoundary.tsx | 77 + .../shared/button/ReusableButton.tsx | 150 + .../components/shared/card/CardWrapper.tsx | 200 + .../components/shared/charts/Bar.tsx | 78 + .../components/shared/charts/Line.tsx | 124 + .../shared/dialog/ReusableDialog.tsx | 314 + .../shared/fileupload/ReusableFileUpload.tsx | 96 + .../shared/inputfield/ReusableInputField.tsx | 185 + .../shared/select/ReusableSelectInput.tsx | 311 + .../components/shared/table/ReusableTable.tsx | 1525 ++ .../shared/table/TableExportModal.tsx | 157 + .../components/shared/toast/ReusableToast.tsx | 293 + .../components/theme-provider.tsx | 8 + .../components/ui/ErrorBoundary.tsx | 33 + .../components/ui/accordion.tsx | 69 + .../components/ui/alert-dialog.tsx | 141 + src/vertex-template/components/ui/alert.tsx | 59 + src/vertex-template/components/ui/avatar.tsx | 50 + src/vertex-template/components/ui/badge.tsx | 36 + src/vertex-template/components/ui/banner.tsx | 165 + src/vertex-template/components/ui/button.tsx | 56 + .../components/ui/calendar.tsx | 70 + src/vertex-template/components/ui/card.tsx | 79 + src/vertex-template/components/ui/chart.tsx | 365 + .../components/ui/checkbox.tsx | 30 + .../components/ui/collapsible.tsx | 11 + .../components/ui/combobox.tsx | 186 + src/vertex-template/components/ui/command.tsx | 132 + .../components/ui/custom-dialog.tsx | 28 + .../components/ui/date-picker.tsx | 55 + src/vertex-template/components/ui/dialog.tsx | 122 + .../components/ui/dropdown-menu.tsx | 200 + .../components/ui/empty-state.tsx | 20 + .../components/ui/forbidden-error.tsx | 62 + .../components/ui/forbidden-page.tsx | 18 + src/vertex-template/components/ui/form.tsx | 179 + .../components/ui/hcaptcha-widget.tsx | 49 + .../components/ui/hover-card.tsx | 29 + src/vertex-template/components/ui/input.tsx | 22 + src/vertex-template/components/ui/label.tsx | 26 + .../components/ui/multi-select.tsx | 204 + .../components/ui/pagination.tsx | 117 + .../components/ui/permission-tooltip.tsx | 54 + src/vertex-template/components/ui/popover.tsx | 46 + .../components/ui/progress.tsx | 28 + .../components/ui/scroll-area.tsx | 48 + .../components/ui/select-field.tsx | 293 + src/vertex-template/components/ui/select.tsx | 160 + .../components/ui/separator.tsx | 31 + src/vertex-template/components/ui/sheet.tsx | 140 + .../components/ui/skeleton.tsx | 15 + src/vertex-template/components/ui/sonner.tsx | 31 + src/vertex-template/components/ui/stepper.tsx | 113 + src/vertex-template/components/ui/switch.tsx | 29 + src/vertex-template/components/ui/table.tsx | 117 + src/vertex-template/components/ui/tabs.tsx | 57 + .../components/ui/textarea.tsx | 22 + src/vertex-template/components/ui/toast.tsx | 129 + src/vertex-template/components/ui/toaster.tsx | 35 + src/vertex-template/components/ui/tooltip.tsx | 30 + .../context/banner-context.tsx | 116 + .../context/page-title-context.tsx | 202 + src/vertex-template/core/adapters/index.ts | 23 + .../core/adapters/mock-fixtures.ts | 435 + src/vertex-template/core/adapters/mock.ts | 363 + src/vertex-template/core/adapters/types.ts | 87 + src/vertex-template/core/apis/axiosConfig.ts | 121 + src/vertex-template/core/apis/cloudinary.ts | 99 + src/vertex-template/core/apis/cohorts.ts | 271 + src/vertex-template/core/apis/devices.ts | 732 + src/vertex-template/core/apis/feedback.ts | 133 + src/vertex-template/core/apis/grids.ts | 128 + src/vertex-template/core/apis/networks.ts | 112 + .../core/apis/organizations.ts | 38 + src/vertex-template/core/apis/permissions.ts | 23 + src/vertex-template/core/apis/roles.ts | 82 + src/vertex-template/core/apis/sites.ts | 285 + src/vertex-template/core/apis/users.ts | 73 + .../core/auth/authProvider.tsx | 698 + .../core/auth/oauth-session.ts | 166 + .../core/config/proxyConfig.ts | 217 + .../core/config/vertex-config.ts | 141 + .../core/constants/app-downloads.ts | 8 + src/vertex-template/core/constants/devices.ts | 34 + src/vertex-template/core/constants/ui.ts | 5 + .../core/hooks/useBannerWithDelay.ts | 23 + .../core/hooks/useClipboard.ts | 27 + src/vertex-template/core/hooks/useCohorts.ts | 581 + .../core/hooks/useContextAwareRouting.ts | 63 + src/vertex-template/core/hooks/useDevices.ts | 871 ++ .../core/hooks/useForbiddenHandler.ts | 34 + src/vertex-template/core/hooks/useGrids.ts | 205 + src/vertex-template/core/hooks/useGroups.ts | 61 + src/vertex-template/core/hooks/useLogout.ts | 85 + src/vertex-template/core/hooks/useNetworks.ts | 102 + .../core/hooks/usePermissions.ts | 252 + .../core/hooks/useRecentlyVisited.ts | 99 + src/vertex-template/core/hooks/useRoles.ts | 62 + .../core/hooks/useServerSideTableState.ts | 72 + src/vertex-template/core/hooks/useSites.ts | 305 + .../core/hooks/useUserContext.ts | 271 + src/vertex-template/core/hooks/useWindow.ts | 32 + .../core/permissions/constants.ts | 419 + .../core/permissions/permissionService.ts | 437 + .../core/providers/query-provider.tsx | 129 + src/vertex-template/core/redux/hooks.ts | 6 + .../core/redux/slices/analyticsSlice.ts | 55 + .../core/redux/slices/cohortsSlice.ts | 69 + .../core/redux/slices/deviceSlice.ts | 47 + .../core/redux/slices/devicesSlice.ts | 38 + .../core/redux/slices/gridsSlice.ts | 61 + .../core/redux/slices/groupsSlice.ts | 54 + .../core/redux/slices/rolesSlice.ts | 37 + .../core/redux/slices/sitesSlice.ts | 94 + .../core/redux/slices/userSlice.ts | 328 + src/vertex-template/core/redux/store.ts | 73 + src/vertex-template/core/routes.ts | 16 + .../core/services/network-service.ts | 137 + src/vertex-template/core/urls.tsx | 22 + src/vertex-template/core/utils/clientCache.ts | 56 + src/vertex-template/core/utils/cohortName.ts | 30 + .../core/utils/getApiErrorMessage.ts | 92 + src/vertex-template/core/utils/platform.ts | 45 + src/vertex-template/core/utils/proxyClient.ts | 208 + .../core/utils/rememberedAccounts.ts | 59 + src/vertex-template/core/utils/responsive.ts | 9 + .../core/utils/secureApiProxyClient.ts | 207 + .../core/utils/sessionManager.ts | 36 + src/vertex-template/core/utils/status.ts | 215 + src/vertex-template/core/utils/urlHelpers.ts | 77 + .../core/utils/userPreferences.ts | 81 + src/vertex-template/hooks/use-toast.ts | 194 + src/vertex-template/lib/envConstants.ts | 174 + src/vertex-template/lib/logger.ts | 71 + src/vertex-template/lib/utils.ts | 43 + src/vertex-template/lighthouserc.json | 76 + src/vertex-template/middleware.ts | 51 + src/vertex-template/next-env.d.ts | 5 + src/vertex-template/next.config.js | 50 + src/vertex-template/next.config.mjs | 66 + src/vertex-template/package-lock.json | 12612 ++++++++++++++++ src/vertex-template/package.json | 110 + src/vertex-template/postcss.config.mjs | 8 + .../public/icons/Errors/OopsIcon.jsx | 56 + src/vertex-template/public/images/AQR.jpeg | Bin 0 -> 72848 bytes .../public/images/airqo_logo.svg | 3 + src/vertex-template/start-next.js | 41 + src/vertex-template/tailwind.config.ts | 85 + src/vertex-template/tsconfig.json | 26 + src/vertex-template/types/next-auth.d.ts | 48 + src/vertex-template/vertex.config.example.ts | 54 + src/vertex-template/vertex.config.ts | 59 + 348 files changed, 60832 insertions(+) create mode 100644 src/vertex-template/.dockerignore create mode 100644 src/vertex-template/.env.example create mode 100644 src/vertex-template/.eslintrc.json create mode 100644 src/vertex-template/.gitignore create mode 100644 src/vertex-template/.prettierignore create mode 100644 src/vertex-template/.prettierrc create mode 100644 src/vertex-template/Dockerfile create mode 100644 src/vertex-template/README.md create mode 100644 src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/grids/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/layout.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/networks/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/shipping/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/admin/sites/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/cohorts/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/devices/overview/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/home/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/layout.tsx create mode 100644 src/vertex-template/app/(authenticated)/sites/[id]/page.tsx create mode 100644 src/vertex-template/app/(authenticated)/sites/overview/page.tsx create mode 100644 src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md create mode 100644 src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md create mode 100644 src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md create mode 100644 src/vertex-template/app/_docs/internal/device-status.md create mode 100644 src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md create mode 100644 src/vertex-template/app/_docs/internal/vertex-template-config-contract.md create mode 100644 src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md create mode 100644 src/vertex-template/app/api/[...path]/route.ts create mode 100644 src/vertex-template/app/api/auth/[...nextauth]/options.ts create mode 100644 src/vertex-template/app/api/auth/[...nextauth]/route.ts create mode 100644 src/vertex-template/app/api/cloudinary/upload/route.ts create mode 100644 src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts create mode 100644 src/vertex-template/app/api/devices/network-creation-requests/route.ts create mode 100644 src/vertex-template/app/api/log-to-slack/route.ts create mode 100644 src/vertex-template/app/api/network/route.ts create mode 100644 src/vertex-template/app/auth-error/page.tsx create mode 100644 src/vertex-template/app/changelog.md create mode 100644 src/vertex-template/app/client-layout.tsx create mode 100644 src/vertex-template/app/error.tsx create mode 100644 src/vertex-template/app/favicon-16x16.png create mode 100644 src/vertex-template/app/favicon-32x32.png create mode 100644 src/vertex-template/app/favicon.ico create mode 100644 src/vertex-template/app/fonts/GeistMonoVF.woff create mode 100644 src/vertex-template/app/fonts/GeistVF.woff create mode 100644 src/vertex-template/app/globals.css create mode 100644 src/vertex-template/app/layout.tsx create mode 100644 src/vertex-template/app/login/page.tsx create mode 100644 src/vertex-template/app/not-found.tsx create mode 100644 src/vertex-template/app/page.tsx create mode 100644 src/vertex-template/app/providers.tsx create mode 100644 src/vertex-template/app/types/clients.ts create mode 100644 src/vertex-template/app/types/cohorts.ts create mode 100644 src/vertex-template/app/types/devices.ts create mode 100644 src/vertex-template/app/types/export.ts create mode 100644 src/vertex-template/app/types/grids.ts create mode 100644 src/vertex-template/app/types/groups.ts create mode 100644 src/vertex-template/app/types/layout.ts create mode 100644 src/vertex-template/app/types/roles.ts create mode 100644 src/vertex-template/app/types/sites.ts create mode 100644 src/vertex-template/app/types/userStats.ts create mode 100644 src/vertex-template/app/types/users.ts create mode 100644 src/vertex-template/components.json create mode 100644 src/vertex-template/components/features/auth/cookie-info-banner.tsx create mode 100644 src/vertex-template/components/features/auth/social-auth-section.tsx create mode 100644 src/vertex-template/components/features/claim/DeviceEntryRow.tsx create mode 100644 src/vertex-template/components/features/claim/FileUploadParser.tsx create mode 100644 src/vertex-template/components/features/claim/claim-device-modal.tsx create mode 100644 src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx create mode 100644 src/vertex-template/components/features/claim/steps/BulkInputStep.tsx create mode 100644 src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx create mode 100644 src/vertex-template/components/features/claim/steps/CohortImportStep.tsx create mode 100644 src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx create mode 100644 src/vertex-template/components/features/claim/steps/ManualInputStep.tsx create mode 100644 src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx create mode 100644 src/vertex-template/components/features/claim/steps/QRScanStep.tsx create mode 100644 src/vertex-template/components/features/claim/steps/SuccessStep.tsx create mode 100644 src/vertex-template/components/features/claim/utils.ts create mode 100644 src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx create mode 100644 src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx create mode 100644 src/vertex-template/components/features/cohorts/cohort-detail-card.tsx create mode 100644 src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx create mode 100644 src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx create mode 100644 src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx create mode 100644 src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx create mode 100644 src/vertex-template/components/features/cohorts/create-cohort.tsx create mode 100644 src/vertex-template/components/features/cohorts/device-name-parser.tsx create mode 100644 src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx create mode 100644 src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx create mode 100644 src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx create mode 100644 src/vertex-template/components/features/dashboard/stat-card.tsx create mode 100644 src/vertex-template/components/features/dashboard/stats-cards.tsx create mode 100644 src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx create mode 100644 src/vertex-template/components/features/devices/bulk-edit-device-details-modal.tsx create mode 100644 src/vertex-template/components/features/devices/client-paginated-devices-table.tsx create mode 100644 src/vertex-template/components/features/devices/create-device-modal.tsx create mode 100644 src/vertex-template/components/features/devices/deploy-device-component.tsx create mode 100644 src/vertex-template/components/features/devices/deploy-device-modal.tsx create mode 100644 src/vertex-template/components/features/devices/device-activity-item.tsx create mode 100644 src/vertex-template/components/features/devices/device-assignment-modal.tsx create mode 100644 src/vertex-template/components/features/devices/device-category-card.tsx create mode 100644 src/vertex-template/components/features/devices/device-details-card.tsx create mode 100644 src/vertex-template/components/features/devices/device-details-layout.tsx create mode 100644 src/vertex-template/components/features/devices/device-details-modal.tsx create mode 100644 src/vertex-template/components/features/devices/device-history-card.tsx create mode 100644 src/vertex-template/components/features/devices/device-list-table.tsx create mode 100644 src/vertex-template/components/features/devices/device-location-card.tsx create mode 100644 src/vertex-template/components/features/devices/device-measurements-api-card.tsx create mode 100644 src/vertex-template/components/features/devices/import-device-modal.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/BulkImportForm.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/BulkResultsStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/CohortSelectionStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/ConfirmationStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/FieldMappingStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/ImportMethodSelectStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/ImportPreviewStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/ImportSuccessStep.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/SingleImportForm.tsx create mode 100644 src/vertex-template/components/features/devices/import-steps/types.ts create mode 100644 src/vertex-template/components/features/devices/maintenance-status-card.tsx create mode 100644 src/vertex-template/components/features/devices/online-status-card.tsx create mode 100644 src/vertex-template/components/features/devices/orphaned-devices-alert.tsx create mode 100644 src/vertex-template/components/features/devices/qr-scanner.tsx create mode 100644 src/vertex-template/components/features/devices/recall-device-dialog.tsx create mode 100644 src/vertex-template/components/features/devices/run-device-test-card.tsx create mode 100644 src/vertex-template/components/features/devices/utils/table-columns.tsx create mode 100644 src/vertex-template/components/features/feedback/feedback-dialog.ts create mode 100644 src/vertex-template/components/features/feedback/feedback-launcher.tsx create mode 100644 src/vertex-template/components/features/feedback/login-feedback-toast.tsx create mode 100644 src/vertex-template/components/features/feedback/page-satisfaction-banner.tsx create mode 100644 src/vertex-template/components/features/feedback/reusable-satisfaction-feedback-toast.tsx create mode 100644 src/vertex-template/components/features/grids/admin-levels-modal.tsx create mode 100644 src/vertex-template/components/features/grids/create-admin-level.tsx create mode 100644 src/vertex-template/components/features/grids/create-grid.tsx create mode 100644 src/vertex-template/components/features/grids/date-range-picker.tsx create mode 100644 src/vertex-template/components/features/grids/edit-grid-details-dialog.tsx create mode 100644 src/vertex-template/components/features/grids/grid-details-card.tsx create mode 100644 src/vertex-template/components/features/grids/grid-measurements-api-card.tsx create mode 100644 src/vertex-template/components/features/grids/grids-list-table.tsx create mode 100644 src/vertex-template/components/features/home/HomeEmptyState.tsx create mode 100644 src/vertex-template/components/features/home/context-header.tsx create mode 100644 src/vertex-template/components/features/home/network-visibility-card.tsx create mode 100644 src/vertex-template/components/features/home/onboarding-checklist.tsx create mode 100644 src/vertex-template/components/features/location-autocomplete/LocationAutocomplete.tsx create mode 100644 src/vertex-template/components/features/mini-map/mini-map.tsx create mode 100644 src/vertex-template/components/features/network-status-banner/index.tsx create mode 100644 src/vertex-template/components/features/networks/NetworkStatsCards.tsx create mode 100644 src/vertex-template/components/features/networks/client-paginated-networks-table.tsx create mode 100644 src/vertex-template/components/features/networks/create-network-form.tsx create mode 100644 src/vertex-template/components/features/networks/network-detail-card.tsx create mode 100644 src/vertex-template/components/features/networks/network-device-list-table.tsx create mode 100644 src/vertex-template/components/features/networks/network-request-dialog.tsx create mode 100644 src/vertex-template/components/features/networks/request-table.tsx create mode 100644 src/vertex-template/components/features/networks/schema.ts create mode 100644 src/vertex-template/components/features/org-picker/organization-modal.tsx create mode 100644 src/vertex-template/components/features/org-picker/organization-picker.tsx create mode 100644 src/vertex-template/components/features/shipping/PrepareShippingModal.tsx create mode 100644 src/vertex-template/components/features/shipping/ShippingBatchesTable.tsx create mode 100644 src/vertex-template/components/features/shipping/ShippingLabelPrint.tsx create mode 100644 src/vertex-template/components/features/shipping/ShippingLabelPrintModal.tsx create mode 100644 src/vertex-template/components/features/sites/client-paginated-sites-table.tsx create mode 100644 src/vertex-template/components/features/sites/create-site-form.tsx create mode 100644 src/vertex-template/components/features/sites/edit-site-details-dialog.tsx create mode 100644 src/vertex-template/components/features/sites/site-activity-card.tsx create mode 100644 src/vertex-template/components/features/sites/site-information-card.tsx create mode 100644 src/vertex-template/components/features/sites/site-measurements-api-card.tsx create mode 100644 src/vertex-template/components/features/sites/site-mobile-app-card.tsx create mode 100644 src/vertex-template/components/features/sites/site-stats-cards.tsx create mode 100644 src/vertex-template/components/features/sites/sites-list-table.tsx create mode 100644 src/vertex-template/components/layout/AppDropdown.tsx create mode 100644 src/vertex-template/components/layout/Footer.tsx create mode 100644 src/vertex-template/components/layout/NavItem.tsx create mode 100644 src/vertex-template/components/layout/accessConfig/context-guard.tsx create mode 100644 src/vertex-template/components/layout/accessConfig/forbidden-guard.tsx create mode 100644 src/vertex-template/components/layout/accessConfig/permission-guard.tsx create mode 100644 src/vertex-template/components/layout/accessConfig/route-guard.tsx create mode 100644 src/vertex-template/components/layout/layout.tsx create mode 100644 src/vertex-template/components/layout/loading/org-loading.tsx create mode 100644 src/vertex-template/components/layout/loading/session-loading.tsx create mode 100644 src/vertex-template/components/layout/primary-sidebar.tsx create mode 100644 src/vertex-template/components/layout/secondary-sidebar.tsx create mode 100644 src/vertex-template/components/layout/sub-menu.tsx create mode 100644 src/vertex-template/components/layout/topbar.tsx create mode 100644 src/vertex-template/components/shared/ErrorBoundary.tsx create mode 100644 src/vertex-template/components/shared/button/ReusableButton.tsx create mode 100644 src/vertex-template/components/shared/card/CardWrapper.tsx create mode 100644 src/vertex-template/components/shared/charts/Bar.tsx create mode 100644 src/vertex-template/components/shared/charts/Line.tsx create mode 100644 src/vertex-template/components/shared/dialog/ReusableDialog.tsx create mode 100644 src/vertex-template/components/shared/fileupload/ReusableFileUpload.tsx create mode 100644 src/vertex-template/components/shared/inputfield/ReusableInputField.tsx create mode 100644 src/vertex-template/components/shared/select/ReusableSelectInput.tsx create mode 100644 src/vertex-template/components/shared/table/ReusableTable.tsx create mode 100644 src/vertex-template/components/shared/table/TableExportModal.tsx create mode 100644 src/vertex-template/components/shared/toast/ReusableToast.tsx create mode 100644 src/vertex-template/components/theme-provider.tsx create mode 100644 src/vertex-template/components/ui/ErrorBoundary.tsx create mode 100644 src/vertex-template/components/ui/accordion.tsx create mode 100644 src/vertex-template/components/ui/alert-dialog.tsx create mode 100644 src/vertex-template/components/ui/alert.tsx create mode 100644 src/vertex-template/components/ui/avatar.tsx create mode 100644 src/vertex-template/components/ui/badge.tsx create mode 100644 src/vertex-template/components/ui/banner.tsx create mode 100644 src/vertex-template/components/ui/button.tsx create mode 100644 src/vertex-template/components/ui/calendar.tsx create mode 100644 src/vertex-template/components/ui/card.tsx create mode 100644 src/vertex-template/components/ui/chart.tsx create mode 100644 src/vertex-template/components/ui/checkbox.tsx create mode 100644 src/vertex-template/components/ui/collapsible.tsx create mode 100644 src/vertex-template/components/ui/combobox.tsx create mode 100644 src/vertex-template/components/ui/command.tsx create mode 100644 src/vertex-template/components/ui/custom-dialog.tsx create mode 100644 src/vertex-template/components/ui/date-picker.tsx create mode 100644 src/vertex-template/components/ui/dialog.tsx create mode 100644 src/vertex-template/components/ui/dropdown-menu.tsx create mode 100644 src/vertex-template/components/ui/empty-state.tsx create mode 100644 src/vertex-template/components/ui/forbidden-error.tsx create mode 100644 src/vertex-template/components/ui/forbidden-page.tsx create mode 100644 src/vertex-template/components/ui/form.tsx create mode 100644 src/vertex-template/components/ui/hcaptcha-widget.tsx create mode 100644 src/vertex-template/components/ui/hover-card.tsx create mode 100644 src/vertex-template/components/ui/input.tsx create mode 100644 src/vertex-template/components/ui/label.tsx create mode 100644 src/vertex-template/components/ui/multi-select.tsx create mode 100644 src/vertex-template/components/ui/pagination.tsx create mode 100644 src/vertex-template/components/ui/permission-tooltip.tsx create mode 100644 src/vertex-template/components/ui/popover.tsx create mode 100644 src/vertex-template/components/ui/progress.tsx create mode 100644 src/vertex-template/components/ui/scroll-area.tsx create mode 100644 src/vertex-template/components/ui/select-field.tsx create mode 100644 src/vertex-template/components/ui/select.tsx create mode 100644 src/vertex-template/components/ui/separator.tsx create mode 100644 src/vertex-template/components/ui/sheet.tsx create mode 100644 src/vertex-template/components/ui/skeleton.tsx create mode 100644 src/vertex-template/components/ui/sonner.tsx create mode 100644 src/vertex-template/components/ui/stepper.tsx create mode 100644 src/vertex-template/components/ui/switch.tsx create mode 100644 src/vertex-template/components/ui/table.tsx create mode 100644 src/vertex-template/components/ui/tabs.tsx create mode 100644 src/vertex-template/components/ui/textarea.tsx create mode 100644 src/vertex-template/components/ui/toast.tsx create mode 100644 src/vertex-template/components/ui/toaster.tsx create mode 100644 src/vertex-template/components/ui/tooltip.tsx create mode 100644 src/vertex-template/context/banner-context.tsx create mode 100644 src/vertex-template/context/page-title-context.tsx create mode 100644 src/vertex-template/core/adapters/index.ts create mode 100644 src/vertex-template/core/adapters/mock-fixtures.ts create mode 100644 src/vertex-template/core/adapters/mock.ts create mode 100644 src/vertex-template/core/adapters/types.ts create mode 100644 src/vertex-template/core/apis/axiosConfig.ts create mode 100644 src/vertex-template/core/apis/cloudinary.ts create mode 100644 src/vertex-template/core/apis/cohorts.ts create mode 100644 src/vertex-template/core/apis/devices.ts create mode 100644 src/vertex-template/core/apis/feedback.ts create mode 100644 src/vertex-template/core/apis/grids.ts create mode 100644 src/vertex-template/core/apis/networks.ts create mode 100644 src/vertex-template/core/apis/organizations.ts create mode 100644 src/vertex-template/core/apis/permissions.ts create mode 100644 src/vertex-template/core/apis/roles.ts create mode 100644 src/vertex-template/core/apis/sites.ts create mode 100644 src/vertex-template/core/apis/users.ts create mode 100644 src/vertex-template/core/auth/authProvider.tsx create mode 100644 src/vertex-template/core/auth/oauth-session.ts create mode 100644 src/vertex-template/core/config/proxyConfig.ts create mode 100644 src/vertex-template/core/config/vertex-config.ts create mode 100644 src/vertex-template/core/constants/app-downloads.ts create mode 100644 src/vertex-template/core/constants/devices.ts create mode 100644 src/vertex-template/core/constants/ui.ts create mode 100644 src/vertex-template/core/hooks/useBannerWithDelay.ts create mode 100644 src/vertex-template/core/hooks/useClipboard.ts create mode 100644 src/vertex-template/core/hooks/useCohorts.ts create mode 100644 src/vertex-template/core/hooks/useContextAwareRouting.ts create mode 100644 src/vertex-template/core/hooks/useDevices.ts create mode 100644 src/vertex-template/core/hooks/useForbiddenHandler.ts create mode 100644 src/vertex-template/core/hooks/useGrids.ts create mode 100644 src/vertex-template/core/hooks/useGroups.ts create mode 100644 src/vertex-template/core/hooks/useLogout.ts create mode 100644 src/vertex-template/core/hooks/useNetworks.ts create mode 100644 src/vertex-template/core/hooks/usePermissions.ts create mode 100644 src/vertex-template/core/hooks/useRecentlyVisited.ts create mode 100644 src/vertex-template/core/hooks/useRoles.ts create mode 100644 src/vertex-template/core/hooks/useServerSideTableState.ts create mode 100644 src/vertex-template/core/hooks/useSites.ts create mode 100644 src/vertex-template/core/hooks/useUserContext.ts create mode 100644 src/vertex-template/core/hooks/useWindow.ts create mode 100644 src/vertex-template/core/permissions/constants.ts create mode 100644 src/vertex-template/core/permissions/permissionService.ts create mode 100644 src/vertex-template/core/providers/query-provider.tsx create mode 100644 src/vertex-template/core/redux/hooks.ts create mode 100644 src/vertex-template/core/redux/slices/analyticsSlice.ts create mode 100644 src/vertex-template/core/redux/slices/cohortsSlice.ts create mode 100644 src/vertex-template/core/redux/slices/deviceSlice.ts create mode 100644 src/vertex-template/core/redux/slices/devicesSlice.ts create mode 100644 src/vertex-template/core/redux/slices/gridsSlice.ts create mode 100644 src/vertex-template/core/redux/slices/groupsSlice.ts create mode 100644 src/vertex-template/core/redux/slices/rolesSlice.ts create mode 100644 src/vertex-template/core/redux/slices/sitesSlice.ts create mode 100644 src/vertex-template/core/redux/slices/userSlice.ts create mode 100644 src/vertex-template/core/redux/store.ts create mode 100644 src/vertex-template/core/routes.ts create mode 100644 src/vertex-template/core/services/network-service.ts create mode 100644 src/vertex-template/core/urls.tsx create mode 100644 src/vertex-template/core/utils/clientCache.ts create mode 100644 src/vertex-template/core/utils/cohortName.ts create mode 100644 src/vertex-template/core/utils/getApiErrorMessage.ts create mode 100644 src/vertex-template/core/utils/platform.ts create mode 100644 src/vertex-template/core/utils/proxyClient.ts create mode 100644 src/vertex-template/core/utils/rememberedAccounts.ts create mode 100644 src/vertex-template/core/utils/responsive.ts create mode 100644 src/vertex-template/core/utils/secureApiProxyClient.ts create mode 100644 src/vertex-template/core/utils/sessionManager.ts create mode 100644 src/vertex-template/core/utils/status.ts create mode 100644 src/vertex-template/core/utils/urlHelpers.ts create mode 100644 src/vertex-template/core/utils/userPreferences.ts create mode 100644 src/vertex-template/hooks/use-toast.ts create mode 100644 src/vertex-template/lib/envConstants.ts create mode 100644 src/vertex-template/lib/logger.ts create mode 100644 src/vertex-template/lib/utils.ts create mode 100644 src/vertex-template/lighthouserc.json create mode 100644 src/vertex-template/middleware.ts create mode 100644 src/vertex-template/next-env.d.ts create mode 100644 src/vertex-template/next.config.js create mode 100644 src/vertex-template/next.config.mjs create mode 100644 src/vertex-template/package-lock.json create mode 100644 src/vertex-template/package.json create mode 100644 src/vertex-template/postcss.config.mjs create mode 100644 src/vertex-template/public/icons/Errors/OopsIcon.jsx create mode 100644 src/vertex-template/public/images/AQR.jpeg create mode 100644 src/vertex-template/public/images/airqo_logo.svg create mode 100644 src/vertex-template/start-next.js create mode 100644 src/vertex-template/tailwind.config.ts create mode 100644 src/vertex-template/tsconfig.json create mode 100644 src/vertex-template/types/next-auth.d.ts create mode 100644 src/vertex-template/vertex.config.example.ts create mode 100644 src/vertex-template/vertex.config.ts diff --git a/src/vertex-template/.dockerignore b/src/vertex-template/.dockerignore new file mode 100644 index 0000000000..79578a8e7b --- /dev/null +++ b/src/vertex-template/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +node_modules +npm-debug.log* +.vscode +coverage \ No newline at end of file diff --git a/src/vertex-template/.env.example b/src/vertex-template/.env.example new file mode 100644 index 0000000000..0ae37dbaef --- /dev/null +++ b/src/vertex-template/.env.example @@ -0,0 +1,21 @@ +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 + +NEXT_PUBLIC_SLACK_BOT_TOKEN= +NEXT_PUBLIC_SLACK_CHANNEL=notifs-airqo-netmanager-web +SLACK_WEBHOOK_URL= + +NEXT_PUBLIC_API_TOKEN= +NEXT_PUBLIC_API_BASE_URL=https://api.airqo.net +NEXT_PUBLIC_API_URL=https://staging-vertex.airqo.net/api/v2/ +NEXT_PUBLIC_ENV=development +NEXT_PUBLIC_ANALYTICS_URL=https://staging-analytics.airqo.net +NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL= +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= +NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED=false +ADMIN_SECRET= +NEXT_PUBLIC_CLOUDINARY_NAME= +NEXT_PUBLIC_CLOUDINARY_PRESET= +NEXT_PUBLIC_HCAPTCHA_SITE_KEY= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= diff --git a/src/vertex-template/.eslintrc.json b/src/vertex-template/.eslintrc.json new file mode 100644 index 0000000000..3722418549 --- /dev/null +++ b/src/vertex-template/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/src/vertex-template/.gitignore b/src/vertex-template/.gitignore new file mode 100644 index 0000000000..7acd1072c3 --- /dev/null +++ b/src/vertex-template/.gitignore @@ -0,0 +1,125 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Removing the idea +.idea/ + + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.local +.env.development + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next/ + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Cypress files +cypress/videos +cypress/screenshots + +# Yarn add output +.pnp.cjs +.pnp.loader.mjs +.yarn/ +.yarnrc.yml + +# vscode files +.vscode/* + +.lighthouseci \ No newline at end of file diff --git a/src/vertex-template/.prettierignore b/src/vertex-template/.prettierignore new file mode 100644 index 0000000000..90e6bd2ed2 --- /dev/null +++ b/src/vertex-template/.prettierignore @@ -0,0 +1,11 @@ +node_modules +.next +dist +build +*.log +.DS_Store +*.lock +yarn.lock +package-lock.json +coverage +.nyc_output \ No newline at end of file diff --git a/src/vertex-template/.prettierrc b/src/vertex-template/.prettierrc new file mode 100644 index 0000000000..53b311509d --- /dev/null +++ b/src/vertex-template/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid" +} diff --git a/src/vertex-template/Dockerfile b/src/vertex-template/Dockerfile new file mode 100644 index 0000000000..dec87e368e --- /dev/null +++ b/src/vertex-template/Dockerfile @@ -0,0 +1,41 @@ +# First stage: Build the app +FROM node:18-alpine AS builder + +# Set the working directory +WORKDIR /app + +# Copy package files and install dependencies +COPY package*.json ./ +RUN npm ci + + +# Copy the rest of the application code +COPY . . + +# Build the Next.js app +RUN npm run build + +# Second stage: Production container +FROM node:18-alpine AS runner + +# Set the working directory +WORKDIR /app + +# Copy built files from builder stage +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/public ./public +COPY --from=builder /app/start-next.js ./start-next.js + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +USER node + +# Expose the application port +EXPOSE 3000 + +# Command to run the application +CMD ["npm", "run", "start"] diff --git a/src/vertex-template/README.md b/src/vertex-template/README.md new file mode 100644 index 0000000000..889af50aff --- /dev/null +++ b/src/vertex-template/README.md @@ -0,0 +1,92 @@ +# Vertex (Web App). + +`vertex` is the AirQo web application for Device and Network management. + +## Quick start + +1. Install dependencies: + +```bash +cd src/vertex +npm install +``` + +2. Create local environment file: + +```bash +copy .env.example .env +``` + +3. Run development server: + +```bash +npm run dev +``` + +4. Open `http://localhost:3000`. + +## Scripts + +- `npm run dev`: Start local development server. +- `npm run dev:inspect`: Start dev server with Node inspector enabled. +- `npm run build`: Build production bundle. +- `npm run start`: Start production server from built output. +- `npm run lint`: Run lint checks. +- `npm run format`: Format code with Prettier. +- `npm run format:check`: Check formatting. +- `npm run check-size`: Build and run bundle size checks. + +## Environment variables + +Use `src/vertex/.env.example` as the base. Common variables include: + +- `NEXT_PUBLIC_API_URL`: Backend API base URL. +- `NEXT_PUBLIC_API_BASE_URL`: Public API origin used for measurement URL examples. +- `NEXT_PUBLIC_ANALYTICS_URL`: Analytics platform URL. +- `NEXT_PUBLIC_VERTEX_DESKTOP_WINDOWS_DOWNLOAD_URL`: Optional Windows installer URL for Vertex Desktop downloads. +- `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`: Mapbox token for map features. +- `NEXT_PUBLIC_ENV`: App environment label (for example `development`). +- `NEXT_PUBLIC_MOCK_PERMISSIONS_ENABLED`: Enables mock permissions when needed. +- `ADMIN_SECRET`: Secret used by admin/protected server operations. +- `NEXT_PUBLIC_CLOUDINARY_NAME`: Cloudinary cloud name. +- `NEXT_PUBLIC_CLOUDINARY_PRESET`: Cloudinary upload preset. +- `NEXT_PUBLIC_HCAPTCHA_SITE_KEY`: HCaptcha site key needed for the new login flow. +- `SLACK_WEBHOOK_URL`: Slack webhook for server-side notifications. +- `NEXT_PUBLIC_SLACK_BOT_TOKEN`, `NEXT_PUBLIC_SLACK_CHANNEL`: Slack client configuration. + +## Vertex configuration + +Vertex has a typed app configuration surface in `vertex.config.ts`. This is the file that the future `create-vertex-app` CLI will generate or update for each scaffolded instance. + +V1 supports two data adapters: + +- `mock`: runs locally without API credentials and is the default for generated templates. +- `airqo`: uses the existing AirQo API, auth, and proxy behavior. + +Generic REST backends are intentionally out of scope for v1. + +Use `vertex.config.example.ts` as the template-facing reference. Keep validation and shared types in `core/config/vertex-config.ts` so contributors can add config-driven behavior without inventing new config shapes. + +## Authentication and SSO + +For normal app-local authentication, set: + +```bash +NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3001 +``` + +Notes: + +- `NEXTAUTH_SECRET` should be unique to Vertex. +- `NEXTAUTH_URL` should match the local or production origin for Vertex. + +## Related projects + +- `src/vertex-desktop`: Electron wrapper that loads the hosted Vertex web app. +- Most feature and business logic remains in this web app; desktop-specific behavior stays in `src/vertex-desktop`. + +## Deployment notes + +- Staging and production deployment automation is configured in `.github/workflows/`. +- This app is deployed independently from desktop installer releases. diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..f9ee003f32 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminCohortDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx new file mode 100644 index 0000000000..29b204c19e --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/[id]/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft, AqMinusCircle, AqPlus } from "@airqo/icons-react"; +import { AssignCohortDevicesDialog } from "@/components/features/cohorts/assign-cohort-devices"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { useCohortDetails } from "@/core/hooks/useCohorts"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card"; +import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal"; +import { usePermission } from "@/core/hooks/usePermissions"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { UnassignCohortDevicesDialog } from "@/components/features/cohorts/unassign-cohort-devices"; +import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card"; +import { usePageTitle } from "@/context/page-title-context"; +import { CohortOrganizationsCard } from "@/components/features/cohorts/cohort-organizations-card"; + +// Loading skeleton for content grid +const ContentGridSkeleton = () => ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+); + +export default function CohortDetailsPage() { + const router = useRouter(); + const params = useParams(); + const cohortId = params?.id as string; + + const { data: cohort, isLoading, error } = useCohortDetails(cohortId); + + usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" }); + + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + const [cohortDetails, setCohortDetails] = useState<{ + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }>({ + name: "", + id: "", + visibility: true, + cohort_tags: [], + }); + const [showDetailsModal, setShowDetailsModal] = useState(false); + const [showAssignDialog, setShowAssignDialog] = useState(false); + const [showUnassignDialog, setShowUnassignDialog] = useState(false); + + const handleOpenDetails = () => setShowDetailsModal(true); + const handleCloseDetails = () => setShowDetailsModal(false); + + useEffect(() => { + if (cohort) { + setCohortDetails({ + name: cohort.name, + id: cohort._id, + visibility: cohort.visibility, + cohort_tags: cohort.cohort_tags || [], + }); + } + }, [cohort]); + + const devices = useMemo(() => cohort?.devices || [], [cohort]); + + const handleAssignSuccess = () => { + setShowAssignDialog(false); + }; + + const handleUnassignSuccess = () => { + setShowUnassignDialog(false); + }; + + return ( + +
+
+ router.back()} Icon={AqArrowLeft}> + Back + + +
+ + + + {isLoading ? ( + + ) : error ? ( +
+ Unable to load cohort details:{" "} + {String((error as Error)?.message || "Unknown error")} +
+ ) : ( +
+ {/* Cards section */} +
+ + + +
+ + {/* Devices list */} +
+
+

Cohort devices

+
+ setShowAssignDialog(true)} + disabled={!canUpdateDevice} + permission={PERMISSIONS.DEVICE.UPDATE} + Icon={AqPlus} + padding="px-3 py-1.5" + className="text-sm font-medium" + > + Add Devices + + setShowUnassignDialog(true)} + disabled={!canUpdateDevice} + permission={PERMISSIONS.DEVICE.UPDATE} + Icon={AqMinusCircle} + padding="px-3 py-1.5" + className="border-red-700 hover:bg-red-700 text-red-700 text-sm font-medium" + > + Remove Devices + +
+
+ { + router.push(`/admin/cohorts/${cohortId}/devices/${device._id}`); + }} + /> +
+ +
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx new file mode 100644 index 0000000000..588b38e098 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/cohorts/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { CreateCohortDialog } from "@/components/features/cohorts/create-cohort"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable"; +import { useCohorts, useUserCohorts } from "@/core/hooks/useCohorts"; +import { Cohort } from "@/app/types/cohorts"; +import { useState, useMemo } from "react"; +import { format } from 'date-fns'; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqPlus } from "@airqo/icons-react"; +import { CreateCohortFromSelectionDialog } from "@/components/features/cohorts/create-cohort-from-cohorts"; +import { AssignCohortsToGroupDialog } from "@/components/features/cohorts/assign-cohorts-to-group"; +import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; +import { usePageTitle } from "@/context/page-title-context"; + +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; + +type CohortRow = { + id: string; + name: string; + numberOfDevices: number; + visibility: boolean; + cohort_tags?: string[]; + dateCreated?: string; +} + +export default function CohortsPage() { + usePageTitle({ title: "Cohorts", section: "Administrative Panel" }); + + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const { + pagination, setPagination, + searchTerm, setSearchTerm, + sorting, setSorting + } = useServerSideTableState({ initialPageSize: 25 }); + + const [view, setView] = useState<'organization' | 'user'>('organization'); + + // Tag Logic + const defaultTag = DEFAULT_COHORT_TAGS[0]?.value || "All"; + const urlTag = searchParams.get('tags'); + const selectedTag = urlTag || defaultTag; + + const handleTagClick = (tag: string) => { + const params = new URLSearchParams(searchParams); + if (tag === 'All') { + params.delete('tags'); + params.set('tags', 'All'); + } else { + params.set('tags', tag); + } + setPagination(prev => ({ ...prev, pageIndex: 0 })); + router.replace(`${pathname}?${params.toString()}`); + }; + + // Count Queries (Stable, always enabled, minimal payload, no search/sort) + const { meta: orgCountMeta, isFetching: isFetchingOrgCount } = useCohorts({ + page: 1, + limit: 1, + }); + + const { meta: userCountMeta, isFetching: isFetchingUserCount } = useUserCohorts({ + page: 1, + limit: 1, + }); + + // Table Queries (Dynamic, enabled only when active) + const { cohorts: orgCohorts, meta: orgMeta, isFetching: isFetchingOrg, error: orgError } = useCohorts({ + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + tags: selectedTag === "All" ? undefined : selectedTag, + }, { + enabled: view === 'organization' + }); + + const { cohorts: userCohorts, meta: userMeta, isFetching: isFetchingUser, error: userError } = useUserCohorts({ + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + }, { + enabled: view === 'user' + }); + + const cohorts = view === 'organization' ? orgCohorts : userCohorts; + const meta = view === 'organization' ? orgMeta : userMeta; + const isFetching = view === 'organization' ? isFetchingOrg : isFetchingUser; + const error = view === 'organization' ? orgError : userError; + + const pageCount = meta?.totalPages ?? 0; + + const [showCreateCohortModal, setShowCreateCohortModal] = useState(false); + const [showCreateFromCohorts, setShowCreateFromCohorts] = useState(false); + const [showAssignToGroup, setShowAssignToGroup] = useState(false); + const [selectedCohortIds, setSelectedCohortIds] = useState([]); + + const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({ + ...c, + id: c._id, + dateCreated: c.createdAt, + })), [cohorts]); + + const columns: TableColumn[] = [ + { + key: "name", + label: "Cohort Name", + sortable: true, + render: (v) => v ?? "-" + }, + { + key: "numberOfDevices", + label: "Number of devices", + sortable: true, + render: (v) => (v ?? 0) + }, + { + key: "visibility", + label: "Visibility", + sortable: true, + render: (v) => ( + {v ? "Public" : "Private"} + ) + }, + { + key: "cohort_tags", + label: "Tags", + sortable: true, + render: (value) => { + const tags = Array.isArray(value) ? value : []; + if (tags.length === 0) return "-"; + return ( +
+ {tags.map((tag, index) => { + const normalized = String(tag || "").replace(/_/g, " "); + const displayTag = normalized.toLowerCase() === "external device" ? "misc" : normalized; + return ( + + {displayTag} + + ); + })} +
+ ); + } + }, + { + key: "dateCreated", + label: "Date created", + sortable: true, + render: (value) => { + const date = new Date(value as string); + return format(date, "MMM d yyyy, h:mm a"); + } + } + ] + + const tableActions = [ + { + label: "Create cohort from selection", + value: "create-from-cohorts", + handler: (ids: (string | number)[]) => { + setSelectedCohortIds(ids.map(String)); + setShowCreateFromCohorts(true); + }, + }, + { + label: "Assign to Organization", + value: "assign-to-group", + handler: (ids: (string | number)[]) => { + setSelectedCohortIds(ids.map(String)); + setShowAssignToGroup(true); + }, + }, + ]; + + return ( + +
+
+
+

Cohorts

+

+ Manage and organize your device cohorts +

+
+ + +
+ + {view === 'organization' && ( +
+ {[...DEFAULT_COHORT_TAGS.map(t => ({ value: t.value, label: t.label })), { value: 'All', label: 'All Cohorts' }].map(({ value: tag, label }) => ( + + ))} +
+ )} +
+ { + setShowCreateCohortModal(true); + }} + Icon={AqPlus} + > + Create Cohort + +
+ +
+ { + const row = item as CohortRow; + if (row?.id) router.push(`/admin/cohorts/${row.id}`) + }} + emptyState={error ? (error.message || "unable to load cohorts") : "No cohorts available"} + multiSelect + onSelectedIdsChange={(ids: (string | number)[]) => setSelectedCohortIds(ids.map(String))} + actions={tableActions} + serverSidePagination + pageCount={pageCount} + pagination={pagination} + onPaginationChange={setPagination} + onSearchChange={setSearchTerm} + searchTerm={searchTerm} + sorting={sorting} + onSortingChange={setSorting} + searchable + /> +
+ + + + + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx new file mode 100644 index 0000000000..8feda80022 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/grids/[id]/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { useGridDetails } from "@/core/hooks/useGrids"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqArrowLeft } from "@airqo/icons-react"; +import GridDetailsCard from "@/components/features/grids/grid-details-card"; +import GridMeasurementsApiCard from "@/components/features/grids/grid-measurements-api-card"; +import EditGridDetailsDialog from "@/components/features/grids/edit-grid-details-dialog"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ClientPaginatedSitesTable from "@/components/features/sites/client-paginated-sites-table"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function GridDetailsPage() { + const router = useRouter(); + const { id } = useParams(); + const gridId = id.toString(); + const { gridDetails, isLoading, error } = useGridDetails(gridId); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + usePageTitle({ title: gridDetails?.name || "Grid Details", section: "Grids" }); + + if (error) { + return ( +
+ + + Error + {error?.message} + +
+ ); + } + + return ( + +
+ {/* Header */} +
+ router.back()} Icon={AqArrowLeft}> + Back + +
+ + {isLoading ? ( + + ) : !gridDetails ? null : + <> +
+
+ setIsEditDialogOpen(true)} loading={isLoading} /> +
+
+ +
+
+ + + setIsEditDialogOpen(false)} grid={gridDetails} /> + + } +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/grids/page.tsx b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx new file mode 100644 index 0000000000..ceda910a0c --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/grids/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { CreateGridForm } from "@/components/features/grids/create-grid"; +import { CreateAdminLevel } from "@/components/features/grids/create-admin-level"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import GridsTable from "@/components/features/grids/grids-list-table"; + +export default function GridsPage() { + return ( + +
+
+
+

Grids

+

+ Manage and organize your monitoring grids +

+
+
+ + +
+
+ + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/layout.tsx b/src/vertex-template/app/(authenticated)/admin/layout.tsx new file mode 100644 index 0000000000..71c1c29cea --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; + +/** + * Admin Layout + * + * This layout wraps all admin pages with a RouteGuard to ensure + * that only AIRQO ADMIN users in the personal context (with airqo group active) + * can access admin features. + * + * Applies to all pages in the /admin/* routes. + */ +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..8c1cad41b9 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminNetworkDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx new file mode 100644 index 0000000000..c5484e91e1 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/[id]/page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import NetworkDetailsCard from "@/components/features/networks/network-detail-card"; +import NetworkDevicesTable from "@/components/features/networks/network-device-list-table"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { Plus, Upload } from "lucide-react"; +import CreateDeviceModal from "@/components/features/devices/create-device-modal"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import { useState } from "react"; +import { usePermission } from "@/core/hooks/usePermissions"; +import { NetworkStatsCards } from "@/components/features/networks/NetworkStatsCards"; +import { usePageTitle } from "@/context/page-title-context"; + +// Loading skeleton for content grid +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function NetworkDetailsPage() { + const router = useRouter(); + const params = useParams(); + const networkId = params?.id as string; + + const { networks, isLoading, error } = useNetworks(); + const [isCreateDeviceOpen, setCreateDeviceOpen] = useState(false); + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + + const network = useMemo(() => { + return networks.find((n) => n._id === networkId); + }, [networks, networkId]); + usePageTitle({ + title: network?.net_name || "Sensor Manufacturer Details", + section: "Sensor Manufacturers", + }); + + const isAirQoNetwork = network?.net_name?.toLowerCase() === "airqo"; + + return ( + +
+
+ router.push("/admin/networks")} Icon={AqArrowLeft}> + Back + +
+ {isAirQoNetwork ? ( + setCreateDeviceOpen(true)} + Icon={Plus} + permission={PERMISSIONS.DEVICE.UPDATE} + > + Add AirQo Device + + ) : ( + setImportDeviceOpen(true)} + Icon={Upload} + permission={PERMISSIONS.DEVICE.UPDATE} + > + Import External Device + + )} +
+
+ + + + + {isLoading ? ( + + ) : error ? ( +
+ Unable to load Sensor Manufacturer details:{" "} + {String((error as Error)?.message || "Unknown error")} +
+ ) : !network ? ( +
+ Sensor Manufacturer not found +
+ ) : ( +
+ {/* Network basic info card */} +
+ +
+ + {/* Network Stats Cards */} +
+ +
+ + {/* Devices list */} +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx new file mode 100644 index 0000000000..e3f26ffb44 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import ClientPaginatedNetworksTable from "@/components/features/networks/client-paginated-networks-table"; +import { CreateNetworkForm } from "@/components/features/networks/create-network-form"; +import { useNetworks } from "@/core/hooks/useNetworks"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function NetworksPage() { + const { networks, isFetching, error } = useNetworks(); + + return ( + +
+
+
+

Sensor Manufacturers

+ +
+ +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx new file mode 100644 index 0000000000..044cb0cc47 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/NetworkRequestsClient.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useMemo } from "react"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +import NetworkRequestTable from "@/components/features/networks/request-table"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { NetworkCreationRequest } from "@/core/apis/networks"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBanner } from "@/context/banner-context"; + +type TabStatus = "all" | "pending" | "under_review" | "approved" | "denied"; + +interface NetworkRequestsClientProps { + initialRequests: NetworkCreationRequest[]; +} + +export default function NetworkRequestsClient({ initialRequests }: NetworkRequestsClientProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("pending"); + const [isUpdating, setIsUpdating] = useState(false); + const { showBanner } = useBanner(); + // Action management + const [selectedRequest, setSelectedRequest] = useState(null); + const [actionType, setActionType] = useState<'approve' | 'deny' | 'review' | null>(null); + const [notes, setNotes] = useState(""); + + const handleRefresh = () => { + router.refresh(); + }; + + const counts = useMemo(() => ({ + all: initialRequests.length, + pending: initialRequests.filter(r => r.status === "pending").length, + under_review: initialRequests.filter(r => r.status === "under_review").length, + approved: initialRequests.filter(r => r.status === "approved").length, + denied: initialRequests.filter(r => r.status === "denied").length, + }), [initialRequests]); + + const filteredRequests = useMemo(() => { + let filtered = initialRequests; + + if (activeTab !== "all") { + filtered = filtered.filter(r => r.status === activeTab); + } + + return filtered; + }, [initialRequests, activeTab]); + + const handleActionRequest = (request: NetworkCreationRequest, type: 'approve' | 'deny' | 'review') => { + setSelectedRequest(request); + setActionType(type); + setNotes(""); + }; + + const confirmAction = async () => { + if (!selectedRequest || !actionType) return; + + setIsUpdating(true); + try { + const response = await axios.put(`/api/devices/network-creation-requests/${selectedRequest._id}/${actionType}`, { + reviewer_notes: notes + }); + + showBanner({ + message: response.data.message || 'Status updated successfully', + severity: 'success', + scoped: false + }); + + setSelectedRequest(null); + setActionType(null); + router.refresh(); // Invalidate server data + } catch (error: unknown) { + showBanner({ + message: `Action failed: ${getApiErrorMessage(error)}`, + severity: 'error', + scoped: false + }); + } finally { + setIsUpdating(false); + } + }; + + return ( + +
+ {/* Page Header */} +
+
+

Sensor Manufacturer Requests

+

+ Manage and review new sensor requests across the platform. +

+
+
+ + {/* Filters & Tabs */} +
+ setActiveTab(v as TabStatus)} className="w-fit"> + + + Pending ({counts.pending}) + + + In Review ({counts.under_review}) + + + Approved ({counts.approved}) + + + Denied ({counts.denied}) + + + All ({counts.all}) + + + + + + Refresh + +
+ + {/* Table Content */} +
+ +
+ + {/* Status Action Dialog */} + setSelectedRequest(null)} + title={`${actionType?.charAt(0).toUpperCase()}${actionType?.slice(1)} Request`} + size="md" + primaryAction={{ + label: isUpdating ? "Processing..." : "Confirm", + onClick: confirmAction, + disabled: isUpdating + }} + secondaryAction={{ + label: "Cancel", + onClick: () => setSelectedRequest(null), + variant: "outline" + }} + > +
+

+ You are about to {actionType} the request for {selectedRequest?.net_name}. +

+ setNotes(e.target.value)} + rows={4} + /> + {actionType === 'deny' && ( +

+ Note: Denial notes will be included in the email notification to the requester. +

+ )} +
+
+
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx new file mode 100644 index 0000000000..1b9d5eb98a --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/networks/requests/page.tsx @@ -0,0 +1,39 @@ +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import NetworkRequestsClient from "./NetworkRequestsClient"; +import { NetworkCreationRequest } from "@/core/apis/networks"; +import logger from "@/lib/logger"; + +import { notFound } from "next/navigation"; +import { networkService } from "@/core/services/network-service"; + +async function getNetworkRequests(): Promise { + try { + const session = await getServerSession(options); + const token = (session as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!token) { + return []; + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + logger.error("ADMIN_SECRET is not defined in environment variables"); + return []; + } + + return await networkService.getNetworkCreationRequests(token, adminSecret); + } catch (error) { + if (error instanceof Error && error.message === "NOT_FOUND") { + notFound(); + } + logger.error(`Error fetching network requests on server: ${error}`); + return []; + } +} + +export default async function NetworkRequestsPage() { + const requests = await getNetworkRequests(); + + return ; +} diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx new file mode 100644 index 0000000000..f63017e579 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/shipping/[batchId]/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useShippingBatchDetails, useGenerateShippingLabels } from '@/core/hooks/useDevices'; +import { format } from 'date-fns'; +import { ArrowLeft } from 'lucide-react'; +import ReusableTable, { TableColumn } from '@/components/shared/table/ReusableTable'; +import { ShippingStatusDevice } from '@/app/types/devices'; +import { Skeleton } from "@/components/ui/skeleton"; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqArrowLeft } from '@airqo/icons-react'; +import { useBanner } from '@/context/banner-context'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import ShippingLabelPrintModal from '@/components/features/shipping/ShippingLabelPrintModal'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState, useCallback } from 'react'; +import { usePageTitle } from '@/context/page-title-context'; + +type BatchDevice = ShippingStatusDevice & { + id: string | number; + createdAt?: string; + [key: string]: unknown; +}; + +const BatchDetailsPage = () => { + const params = useParams(); + const router = useRouter(); + const queryClient = useQueryClient(); + const batchId = params.batchId as string; + const { data, isLoading, error } = useShippingBatchDetails(batchId); + const { showBanner } = useBanner(); + const { mutate: generateLabels, isPending: isGenerating, data: labelsData } = useGenerateShippingLabels({ + onSuccess: (data) => { + if (data.success) { + showBanner({ severity: 'success', message: `Successfully generated ${data.shipping_labels.labels.length} shipping label(s)`, scoped: false }); + setShowLabelModal(true); + } + }, + onError: (error) => { + showBanner({ severity: 'error', message: `Label Generation Failed: ${getApiErrorMessage(error)}`, scoped: false }); + }, + }); + const [showLabelModal, setShowLabelModal] = useState(false); + usePageTitle({ + title: data?.batch?.batch_name || "Shipping Batch", + section: "Shipping", + }); + + const handleGenerateLabels = useCallback((ids: (string | number)[]) => { + if (!ids || ids.length === 0) { + showBanner({ severity: 'error', message: 'Please select at least one device', scoped: false }); + return; + } + + const selectedDeviceNames = (data?.batch?.devices || []) + .filter(device => ids.includes(device._id || device.name)) + .map(device => device.name) + .filter(name => name && name.trim().length > 0); + + if (selectedDeviceNames.length === 0) { + showBanner({ severity: 'error', message: 'Selected devices have no valid names', scoped: false }); + return; + } + + generateLabels(selectedDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); + + const handleGenerateAllLabels = useCallback(() => { + const allDeviceNames = (data?.batch?.devices || []) + .filter(device => device.claim_status !== 'claimed') + .map(device => device.name) + .filter(name => name && name.trim().length > 0); + + if (allDeviceNames.length === 0) { + showBanner({ severity: 'error', message: 'No unclaimed devices found with valid names in this batch', scoped: false }); + return; + } + + generateLabels(allDeviceNames); + }, [generateLabels, data?.batch?.devices, showBanner]); + + const handleCloseModal = useCallback(() => { + setShowLabelModal(false); + queryClient.invalidateQueries({ queryKey: ['shippingBatchDetails', batchId] }); + }, [queryClient, batchId]); + + const actions = [ + { + label: isGenerating ? 'Generating...' : 'Generate Labels', + value: 'generate_labels', + handler: handleGenerateLabels + } + ]; + + const columns: TableColumn[] = [ + { + key: 'name', + label: 'Device Name', + render: (value) => {value as string} + }, + { + key: 'claim_token', + label: 'Claim Token', + render: (value) => {(value as string | null) || '-'} + }, + { + key: 'claim_status', + label: 'Status', + render: (value) => ( + + {value ? (value as string).charAt(0).toUpperCase() + (value as string).slice(1) : 'Unknown'} + + ) + }, + { + key: 'createdAt', + label: 'Created At', + render: (value) => { + const date = value ? new Date(value as string) : null; + return + {date && !isNaN(date.getTime()) ? format(date, 'MMM dd, yyyy HH:mm') : '-'} + ; + } + }, + ]; + + if (!isLoading && (error || !data?.batch)) { + return ( +
+
+ Error loading batch details: {error?.message || 'Batch not found'} +
+ +
+ ); + } + + const batch = data?.batch; + + const tableData: BatchDevice[] = (batch?.devices || []).map(device => ({ + ...device, + id: device._id || device.name + })); + + return ( +
+ {/* Header */} + router.back()} Icon={AqArrowLeft}> + Back + +
+
+ {isLoading ? ( +
+ + +
+ ) : ( + <> +

+ {batch?.batch_name || 'Unnamed Batch'} +

+

+ Batch ID: {batch?._id} +

+ + )} +
+ {!isLoading && ( +
+ + {isGenerating ? 'Generating...' : 'Generate Labels'} + +
+ )} +
+ + {/* Devices Table */} +
+ device.claim_status !== 'claimed'} + /> +
+ + {/* Label Print Modal */} + {labelsData?.success && ( + + )} +
+ ); +}; + +export default BatchDetailsPage; diff --git a/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx new file mode 100644 index 0000000000..cdfe3fcf5e --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/shipping/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import React, { useState } from 'react'; +import { useShippingStatus } from '@/core/hooks/useDevices'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqPlus } from '@airqo/icons-react'; +import { PrepareShippingModal } from '@/components/features/shipping/PrepareShippingModal'; +import ShippingBatchesTable from '@/components/features/shipping/ShippingBatchesTable'; +import { Skeleton } from "@/components/ui/skeleton"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +const ShippingPage = () => { + const [showPrepareModal, setShowPrepareModal] = useState(false); + + return ( + +
+
+
+

Device Shipping Management

+

Manage device shipping status and labels

+
+ setShowPrepareModal(true)} + Icon={AqPlus} + > + Prepare New Batch + +
+ +
+ +
+ +
+ +
+ + setShowPrepareModal(false)} + /> +
+
+ ); +}; + +const ShippingStatus = () => { + const { data: statusData, isLoading } = useShippingStatus(); + + return ( +
+ {isLoading ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ + +
+ ))} +
+ ) : ( + statusData?.shipping_status?.summary && ( +
+
+

{statusData.shipping_status.summary.total_devices}

+

Total Devices

+
+
+

{statusData.shipping_status.summary.prepared_for_shipping}

+

Prepared

+
+
+

{statusData.shipping_status.summary.claimed_devices}

+

Claimed

+
+
+

{statusData.shipping_status.summary.deployed_devices}

+

Deployed

+
+
+ ) + )} +
+ ); +}; + +export default ShippingPage; diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx new file mode 100644 index 0000000000..d3f18bd909 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { SiteDevice } from "@/app/types/sites"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { CircuitBoard } from "lucide-react"; + +interface SiteDevicesProps { + devices: SiteDevice[]; +} + +export function SiteDevices({ devices }: SiteDevicesProps) { + if (!devices.length) { + return ( +
+
+ +

No devices found

+

+ There are no devices deployed at this site yet. +

+
+
+ ); + } + + return ( +
+ + + + Name + Description + Site + Is Primary + Is Co-located + Added On + Deployment status + + + + {devices.map((device) => ( + + {device.name} + {device.description || "N/A"} + {device.site || "N/A"} + + + {device.isPrimary ? "Yes" : "No"} + + + + + {device.isCoLocated ? "Yes" : "No"} + + + {device.registrationDate} + + + {device.deploymentStatus} + + + + ))} + +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx new file mode 100644 index 0000000000..3f9dc28cbb --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/devices/[deviceId]/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function AdminSiteDeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.deviceId; + + if (!deviceId || typeof deviceId !== 'string') { + return ( +
+

Invalid device ID

+
+ ); + } + + return ( + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx new file mode 100644 index 0000000000..a2393823d3 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/[id]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useParams } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { SiteInformationCard } from "@/components/features/sites/site-information-card"; +import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card"; +import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card"; +import SiteActivityCard from "@/components/features/sites/site-activity-card"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function SiteDetailsPage() { + const params = useParams(); + const siteId = params.id as string; + const { data: site, isLoading, error } = useSiteDetails(siteId); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata(); + const router = useRouter(); + const [editSection, setEditSection] = useState<"general" | "mobile" | null>( + null + ); + usePageTitle({ title: site?.name || "Site Details", section: "Sites" }); + + if (error) { + return ( +
+ + + Error + {error.message} + +
+ ); + } + + return ( +
+
+ router.back()} + Icon={AqArrowLeft} + > + Back + + + refreshMetadata(siteId)} + disabled={isRefreshing || isLoading || !site} + loading={isRefreshing} + Icon={RefreshCw} + className="text-xs font-medium" + > + Refresh Metadata + +
+ + {isLoading ? ( + + ) : !site ? ( +
+ Site not found. +
+ ) : ( + <> +
+
+ setEditSection("general")} + /> +
+ +
+ setEditSection("mobile")} + /> + +
+ +
+ +
+
+
+ { + router.push(`/admin/sites/${siteId}/devices/${device._id}`); + }} + /> +
+ !open && setEditSection(null)} + site={site} + section={editSection || "general"} + /> + + )} +
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/admin/sites/page.tsx b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx new file mode 100644 index 0000000000..aa1df046ef --- /dev/null +++ b/src/vertex-template/app/(authenticated)/admin/sites/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import SitesTable from "@/components/features/sites/sites-list-table"; +import { SiteStatsCards } from "@/components/features/sites/site-stats-cards"; +import { usePageTitle } from "@/context/page-title-context"; +import dynamic from "next/dynamic"; + +const CreateSiteForm = dynamic(() => + import('@/components/features/sites/create-site-form').then(mod => mod.CreateSiteForm), + { + ssr: false, + loading: () =>
+ } +); + +export default function SitesPage() { + usePageTitle({ title: "Sites", section: "Administrative Panel" }); + + return ( + +
+
+
+

Sites

+

+ Manage and organize your monitoring sites +

+
+ +
+ +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx new file mode 100644 index 0000000000..f34c2d053b --- /dev/null +++ b/src/vertex-template/app/(authenticated)/cohorts/[id]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { useCohortDetails } from "@/core/hooks/useCohorts"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import CohortDetailsCard from "@/components/features/cohorts/cohort-detail-card"; +import CohortDetailsModal from "@/components/features/cohorts/edit-cohort-details-modal"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import CohortMeasurementsApiCard from "@/components/features/cohorts/cohort-measurements-api-card"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(2)].map((_, i) => ( +
+ ))} +
+); + +export default function CohortDetailsPage() { + const router = useRouter(); + const params = useParams(); + const cohortId = typeof params?.id === "string" ? params.id : ""; + + const { data: cohort, isLoading, error } = useCohortDetails(cohortId, { enabled: !!cohortId }); + usePageTitle({ title: cohort?.name || "Cohort Details", section: "Cohorts" }); + + const [cohortDetails, setCohortDetails] = useState<{ + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }>({ + name: "", + id: "", + visibility: true, + cohort_tags: [], + }); + + const [showDetailsModal, setShowDetailsModal] = useState(false); + + useEffect(() => { + if (cohort) { + setCohortDetails({ + name: cohort.name, + id: cohort._id, + visibility: cohort.visibility, + cohort_tags: cohort.cohort_tags || [], + }); + } + }, [cohort]); + + const devices = useMemo(() => cohort?.devices || [], [cohort]); + + if (!cohortId) { + return ( +
+

Invalid cohort ID

+
+ ); + } + + return ( + +
+
+ router.back()} Icon={AqArrowLeft}> + Back + +
+ + {isLoading ? ( + + ) : error ? ( + + + Error + + {String((error as Error)?.message || "Unable to load cohort details")} + + + ) : ( +
+ {/* Top Cards: Details & API */} +
+ setShowDetailsModal(true)} + loading={isLoading} + /> + +
+ + {/* Devices List */} +
+

Cohort Devices

+ +
+ + setShowDetailsModal(false)} + /> +
+ )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/cohorts/page.tsx b/src/vertex-template/app/(authenticated)/cohorts/page.tsx new file mode 100644 index 0000000000..e5ba98aa4a --- /dev/null +++ b/src/vertex-template/app/(authenticated)/cohorts/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { format } from 'date-fns'; +import { Badge } from "@/components/ui/badge"; +import ReusableTable, { TableColumn } from "@/components/shared/table/ReusableTable"; +import { useCohorts, useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { Cohort } from "@/app/types/cohorts"; +import { useServerSideTableState } from "@/core/hooks/useServerSideTableState"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import CohortsEmptyState from "@/components/features/cohorts/cohorts-empty-state"; + +type CohortRow = { + id: string; + name: string; + numberOfDevices: number; + visibility: boolean; + dateCreated?: string; +} + +export default function CohortsPage() { + const router = useRouter(); + const { data: session } = useSession(); + const user = useAppSelector((state) => state.user.userDetails); + + const { + pagination, setPagination, + searchTerm, setSearchTerm, + sorting, setSorting + } = useServerSideTableState({ initialPageSize: 25 }); + + const { isExternalOrg, activeGroup, userScope } = useUserContext(); + + const isPersonalScope = userScope === 'personal'; + const userId = (session?.user as { id?: string })?.id || user?._id; + + // --- Personal scope: use personal user cohorts API --- + const { + data: personalCohortIds = [], + isLoading: isLoadingPersonalCohorts, + } = usePersonalUserCohorts(userId, { + enabled: isPersonalScope && !!userId, + }); + + // --- Org scope: use group cohorts --- + const { + data: groupCohortIds, + isLoading: isLoadingGroupCohorts, + } = useGroupCohorts(activeGroup?._id, { + enabled: isExternalOrg && !!activeGroup?._id && !isPersonalScope, + }); + + // Resolve effective cohort IDs based on scope + const targetCohortIds = useMemo(() => { + if (isPersonalScope) return personalCohortIds; + if (isExternalOrg) return groupCohortIds || []; + return []; + }, [isPersonalScope, personalCohortIds, isExternalOrg, groupCohortIds]); + + const isResolvingIds = + (isPersonalScope && isLoadingPersonalCohorts) || + (isExternalOrg && !isPersonalScope && isLoadingGroupCohorts); + + const hasIdsToFetch = targetCohortIds.length > 0; + const shouldFetchCohorts = hasIdsToFetch && !isResolvingIds; + + const { cohorts, meta, isFetching: isFetchingCohorts, error } = useCohorts( + { + page: pagination.pageIndex + 1, + limit: pagination.pageSize, + search: searchTerm, + sortBy: sorting[0]?.id, + order: sorting.length ? (sorting[0]?.desc ? "desc" : "asc") : undefined, + cohort_id: hasIdsToFetch ? targetCohortIds : undefined, + }, + { enabled: shouldFetchCohorts } + ); + + const pageCount = meta?.totalPages ?? 0; + + const tableLoading = isResolvingIds || (hasIdsToFetch && isFetchingCohorts); + const displayError = (!hasIdsToFetch && !tableLoading) ? null : error; + const showEmptyState = + !tableLoading && + !displayError && + (!hasIdsToFetch || (cohorts && cohorts.length === 0)); + + const rows: CohortRow[] = useMemo(() => (cohorts || []).map((c: Cohort) => ({ + ...c, + id: c._id, + dateCreated: c.createdAt, + })), [cohorts]); + + const columns: TableColumn[] = [ + { + key: "name", + label: "Cohort Name", + sortable: true, + render: (v) => v ?? "-" + }, + { + key: "numberOfDevices", + label: "Number of devices", + sortable: true, + render: (v) => (v ?? 0) + }, + { + key: "visibility", + label: "Visibility", + sortable: true, + render: (v) => ( + {v ? "Public" : "Private"} + ) + }, + { + key: "dateCreated", + label: "Date created", + sortable: true, + render: (value) => { + if (!value) return "-"; + const date = new Date(value as string); + if (isNaN(date.getTime())) return "-"; + return format(date, "MMM d yyyy, h:mm a"); + } + } + ]; + + if (showEmptyState) { + return ( + + + + ); + } + + return ( + +
+
+

Cohorts

+

+ Cohorts are groups of devices claimed by you or your organization. Use them to control data privacy settings and determine whether your device data is public or private. +

+
+ +
+ { + const row = item as CohortRow; + if (row?.id) router.push(`/cohorts/${row.id}`); + }} + emptyState={displayError ? (displayError.message || "Unable to load cohorts") : "No cohorts available"} + serverSidePagination + pageCount={pageCount} + pagination={pagination} + onPaginationChange={setPagination} + onSearchChange={setSearchTerm} + searchTerm={searchTerm} + sorting={sorting} + onSortingChange={setSorting} + searchable + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx new file mode 100644 index 0000000000..d8688e08ab --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/my-devices/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import React, { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { AqCollocation, AqPlus } from "@airqo/icons-react"; +import { Upload } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useMyDevices, useDevices } from "@/core/hooks/useDevices"; +import { useAppSelector } from "@/core/redux/hooks"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { DeviceAssignmentModal } from "@/components/features/devices/device-assignment-modal"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import dynamic from "next/dynamic"; + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; + +import { OrphanedDevicesAlert } from "@/components/features/devices/orphaned-devices-alert"; +import ReusableButton from "@/components/shared/button/ReusableButton"; + +const MyDevicesPage = () => { + const { userDetails, activeGroup } = useAppSelector((state) => state.user); + const [showAssignmentModal, setShowAssignmentModal] = useState(false); + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + const { userScope } = useUserContext(); + + const { + data: myDevicesData, + isLoading: isLoadingMyDevices, + error: myDevicesError, + } = useMyDevices(userDetails?._id || "", activeGroup?._id, { + enabled: userScope === 'personal', + }); + + const { + devices: orgDevices, + isLoading: isLoadingOrgDevices, + error: orgDevicesError, + } = useDevices({ + enabled: userScope === 'organisation', + }); + + const devices = React.useMemo(() => { + return userScope === 'personal' + ? myDevicesData?.devices || [] + : orgDevices; + }, [userScope, myDevicesData?.devices, orgDevices]); + const isLoading = userScope === 'personal' ? isLoadingMyDevices : isLoadingOrgDevices; + const error = userScope === 'personal' ? myDevicesError : orgDevicesError; + const searchParams = useSearchParams(); + const rawStatus = searchParams.get("status"); + const statusFilter = ["operational", "transmitting", "not_transmitting", "data_available"].includes(rawStatus || "") + ? rawStatus + : null; + + const filteredDevices = React.useMemo(() => { + if (!devices) return []; + if (!statusFilter) return devices; + + return devices.filter((device) => { + if (statusFilter === "operational") { + return device.rawOnlineStatus === true && device.isOnline === true; + } + + if (statusFilter === "transmitting") { + return device.rawOnlineStatus === true && device.isOnline === false; + } + + if (statusFilter === "not_transmitting") { + return device.rawOnlineStatus === false && device.isOnline === false; + } + + if (statusFilter === "data_available") { + return device.rawOnlineStatus === false && device.isOnline === true; + } + + return true; + }); + }, [devices, statusFilter]); + + if (error) { + return ( + +
+ {/* Header */} +
+
+

My Devices

+

+ Manage your personal and shared devices + {activeGroup && ( + + • Viewing in {activeGroup.grp_title} + + )} +

+
+
+ setIsClaimModalOpen(true)} + Icon={AqPlus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + setImportDeviceOpen(true)} + Icon={Upload} + > + Import External Device + +
+
+ + {/* Empty State */} + + +
+ +

+ Unable to load devices +

+

+ There was an error loading your devices. Please try again or + contact support if the problem persists. +

+
+ window.location.reload()}> + Retry + +
+
+
+
+
+
+ ); + } + + return ( + +
+ {/* Header */} +
+
+
+

My Devices

+ {statusFilter && ( + + Filtered: {statusFilter.replace("_", " ")} + + )} +
+

+ Manage your personal and shared devices +

+
+
+ setIsClaimModalOpen(true)} + disabled={isLoading} + Icon={AqPlus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + setImportDeviceOpen(true)} + disabled={isLoading} + Icon={Upload} + > + Import External Device + + {/* + + + + + setShowAssignmentModal(true)}> + Share Device + + + */} +
+
+ + {userDetails?._id && } + + + + {/* Modals */} + + setIsClaimModalOpen(false)} + /> + { + setShowAssignmentModal(false); + }} + onSuccess={() => { + setShowAssignmentModal(false); + }} + /> +
+
+ ); +}; + +export default MyDevicesPage; diff --git a/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx new file mode 100644 index 0000000000..53c78217ba --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/overview/[id]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import DeviceDetailsLayout from "@/components/features/devices/device-details-layout"; + +export default function DeviceDetailsPage() { + const params = useParams(); + const deviceId = params?.id as string; + + return ; +} diff --git a/src/vertex-template/app/(authenticated)/devices/overview/page.tsx b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx new file mode 100644 index 0000000000..182dcc69c2 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/devices/overview/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Upload } from "lucide-react"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { usePermission } from "@/core/hooks/usePermissions"; +import ImportDeviceModal from "@/components/features/devices/import-device-modal"; +import DevicesTable from "@/components/features/devices/device-list-table"; +import dynamic from "next/dynamic"; + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); +import ReusableButton from "@/components/shared/button/ReusableButton"; + +export default function DevicesPage() { + const [isImportDeviceOpen, setImportDeviceOpen] = useState(false); + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + // Permission checks + const canUpdateDevice = usePermission(PERMISSIONS.DEVICE.UPDATE); + + return ( + +
+
+
+

Devices

+

+ Manage and organize your devices. +

+
+
+
+ setIsClaimModalOpen(true)} + Icon={Plus} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Add AirQo Device + + + setImportDeviceOpen(true)} + Icon={Upload} + permission={PERMISSIONS.DEVICE.CLAIM} + > + Import External Device + +
+
+ + {/* Modal Components */} + + setIsClaimModalOpen(false)} + /> + + +
+ + ); +} diff --git a/src/vertex-template/app/(authenticated)/home/page.tsx b/src/vertex-template/app/(authenticated)/home/page.tsx new file mode 100644 index 0000000000..fa0bf88607 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/home/page.tsx @@ -0,0 +1,694 @@ +"use client"; + +import React from "react"; +import dynamic from "next/dynamic"; +import { useSession } from "next-auth/react"; +import { Plus, Upload, AlertTriangle } from "lucide-react"; +import { useAppSelector, useAppDispatch } from "@/core/redux/hooks"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { usePermissions } from "@/core/hooks/usePermissions"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDevices, useMyDevices } from "@/core/hooks/useDevices"; +import { useGroupCohorts, usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { useQueryClient } from "@tanstack/react-query"; +import ContextHeader from "@/components/features/home/context-header"; +import NetworkVisibilityCard from "@/components/features/home/network-visibility-card"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import OnboardingChecklist from "@/components/features/home/onboarding-checklist"; +import { cn } from "@/lib/utils"; +import { Device } from "@/app/types/devices"; +import { Group } from "@/app/types/groups"; +import { useGroupDetails, useUpdateGroupOnboarding } from "@/core/hooks/useGroups"; +import { updateActiveGroupOnboarding } from "@/core/redux/slices/userSlice"; +import { formatTitle } from "@/components/features/org-picker/organization-picker"; +import ReusableToast from "@/components/shared/toast/ReusableToast"; +import logger from "@/lib/logger"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; + +// ─── Checklist localStorage helpers ────────────────────────────────────────── +// Keyed per org/user so state is independent across workspace switches. + +function getChecklistKey(orgId: string) { + return `vertex_onboarding_${orgId}`; +} + +function getChecklistState(orgId: string): { + completedSteps: string[]; + dismissed: boolean; +} { + if (typeof window === "undefined") return { completedSteps: [], dismissed: false }; + try { + const raw = window.localStorage.getItem(getChecklistKey(orgId)); + return raw ? JSON.parse(raw) : { completedSteps: [], dismissed: false }; + } catch { + return { completedSteps: [], dismissed: false }; + } +} + +function saveChecklistState( + orgId: string, + state: { completedSteps: string[]; dismissed: boolean } +) { + try { + window.localStorage.setItem(getChecklistKey(orgId), JSON.stringify(state)); + } catch { + // localStorage unavailable — fail silently + } +} + +// ─── Skeletons ──────────────────────────────────────────────────────────────── + +const StatsSkeleton = () => ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ +
+ + +
+
+ +
+
+
+ ))} +
+
+); + +// ─── Lazy-loaded heavy components ───────────────────────────────────────────── + +const DashboardStatsCards = dynamic( + () => import("@/components/features/dashboard/stats-cards").then((mod) => ({ + default: mod.DashboardStatsCards, + })), + { ssr: false, loading: () => } +); + +const ClaimDeviceModal = dynamic( + () => import("@/components/features/claim/claim-device-modal"), + { ssr: false } +); + +const ImportDeviceModal = dynamic( + () => import("@/components/features/devices/import-device-modal"), + { ssr: false } +); + +const AssignCohortDevicesDialog = dynamic( + () => import("@/components/features/cohorts/assign-cohort-devices").then(mod => ({ default: mod.AssignCohortDevicesDialog })), + { ssr: false } +); + +const LoginFeedbackToast = dynamic( + () => import("@/components/features/feedback/login-feedback-toast"), + { ssr: false } +); + +// ─── Page component ─────────────────────────────────────────────────────────── + +const WelcomePage = () => { + const { data: session } = useSession(); + const { + userContext, + userScope, + hasError, + error, + isLoading: isLoadingUserContext, + } = useUserContext(); + + const [isClaimModalOpen, setIsClaimModalOpen] = React.useState(false); + const [isImportModalOpen, setIsImportModalOpen] = React.useState(false); + const [justCompletedClaim, setJustCompletedClaim] = React.useState(false); + const [isAddDeviceChoiceOpen, setIsAddDeviceChoiceOpen] = React.useState(false); + const [isAssignCohortModalOpen, setIsAssignCohortModalOpen] = React.useState(false); + const [newlyClaimedDevice, setNewlyClaimedDevice] = React.useState[] | undefined + >(); + const [accordionItems, setAccordionItems] = React.useState(["stats", "visibility"]); + const [highlightVisibility, setHighlightVisibility] = React.useState(false); + const visibilityRef = React.useRef(null); + const queryClient = useQueryClient(); + const dispatch = useAppDispatch(); + const isMounted = React.useRef(true); + + React.useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + const user = useAppSelector((state) => state.user.userDetails); + const userId = (session?.user as { id?: string })?.id || user?._id; + + // Stable key per workspace — personal users get their own key, org users get their + // actual group ID so switching Kampala ↔ Wakiso gives independent checklist state. + const activeGroup = useAppSelector((state) => state.user.activeGroup); + const orgId = userContext === "personal" + ? `personal_${userId}` + : activeGroup?._id + ? `org_${activeGroup._id}` + : null; + + const { data: groupDetailsData, isLoading: isLoadingGroupDetails } = useGroupDetails(activeGroup?._id as string, { + enabled: userScope === "organisation" && !!activeGroup?._id, + staleTime: 5 * 60 * 1000, + }); + + const groupDetails = groupDetailsData?.group; + + const [localChecklistState, setLocalChecklistState] = React.useState(() => + getChecklistState(orgId ?? "") + ); + + React.useEffect(() => { + if (orgId && userScope === "personal") { + setLocalChecklistState(getChecklistState(orgId)); + } + }, [orgId, userScope]); + + const activeChecklistState = React.useMemo(() => { + if (userScope === "organisation") { + const checklistSrc = groupDetails?.onboarding_checklist || activeGroup?.onboarding_checklist; + return { + completedSteps: checklistSrc?.completed_steps || [], + dismissed: checklistSrc?.is_dismissed || false, + }; + } + return localChecklistState; + }, [userScope, activeGroup?.onboarding_checklist, groupDetails?.onboarding_checklist, localChecklistState]); + + // ── Permissions ──────────────────────────────────────────────────────────── + const permissionsToCheck = [PERMISSIONS.DEVICE.UPDATE]; + const permissionsMap = usePermissions(permissionsToCheck); + const canClaimDevice = permissionsMap[PERMISSIONS.DEVICE.UPDATE]; + + // ── Device data ──────────────────────────────────────────────────────────── + const { devices: groupDevices, isLoading: isLoadingGroupDevices } = useDevices({ + limit: 1, + enabled: userScope === "organisation", + }); + + const { data: myDevicesData, isLoading: isLoadingMyDevices } = useMyDevices( + userId || "", + undefined, + { enabled: !!userId && userScope === "personal" } + ); + + // Cohort data — used to auto-detect step 2 completion + const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { + enabled: userScope === "organisation" && !!activeGroup?._id, + }); + const { data: personalCohortIds } = usePersonalUserCohorts(userId, { + enabled: !!userId && userScope === "personal", + }); + + // ── Auto-sync step completion from real data ───────────────────────────── + // This ensures that if an org already has devices/cohorts when a user first + // logs in, steps 1 and 2 are immediately shown as complete. + const autoSteps = React.useMemo(() => { + if (!orgId) return []; + + const hasDevices = + userScope === "personal" + ? (myDevicesData?.devices ?? []).length > 0 + : groupDevices.length > 0; + + const hasCohorts = + userScope === "personal" + ? (personalCohortIds ?? []).length > 0 + : (groupCohortIds ?? []).length > 0; + + const steps: string[] = []; + if (hasDevices) { + steps.push("add-device", "assign-cohort"); + } else if (hasCohorts) { + steps.push("assign-cohort"); + } + return steps; + }, [orgId, userScope, myDevicesData?.devices, groupDevices.length, personalCohortIds, groupCohortIds]); + + const visuallyCompletedSteps = React.useMemo(() => { + return Array.from(new Set([...(activeChecklistState.completedSteps || []), ...autoSteps])); + }, [activeChecklistState.completedSteps, autoSteps]); + + React.useEffect(() => { + if (autoSteps.length === 0) return; + + if (userScope === "personal") { + setLocalChecklistState((prev) => { + const merged = Array.from(new Set([...(prev.completedSteps || []), ...autoSteps])); + // Only save/re-render if something actually changed + if (merged.length === (prev.completedSteps || []).length) return prev; + const next = { ...prev, completedSteps: merged }; + saveChecklistState(orgId as string, next); + return next; + }); + } + }, [orgId, userScope, autoSteps]); + + const { mutateAsync: updateGroupOnboarding } = useUpdateGroupOnboarding(); + + const updateChecklist = React.useCallback( + async (patch: { action?: 'mark_step_complete' | 'dismiss_checklist', step_id?: string, completedSteps?: string[], dismissed?: boolean }) => { + if (userScope === "personal") { + setLocalChecklistState((prev) => { + const next = { ...prev }; + if (patch.completedSteps) next.completedSteps = patch.completedSteps; + if (patch.dismissed !== undefined) next.dismissed = patch.dismissed; + if (orgId) saveChecklistState(orgId, next); + return next; + }); + return; + } + + // Handle Organisation Scope + if (userScope === "organisation" && activeGroup?._id) { + if (!patch.action) { + if (patch.dismissed) patch.action = 'dismiss_checklist'; + else if (patch.completedSteps) { + const newestStep = patch.completedSteps[patch.completedSteps.length - 1]; + if (newestStep) { + patch.action = 'mark_step_complete'; + patch.step_id = newestStep; + } + } + } + + if (patch.action) { + try { + const missingAutoSteps = autoSteps.filter(step => + !activeChecklistState.completedSteps.includes(step) && + !(patch.action === 'mark_step_complete' && patch.step_id === step) + ); + + if (missingAutoSteps.length > 0) { + for (const step of missingAutoSteps) { + try { + await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: 'mark_step_complete', step_id: step } }); + } catch (e) { + logger.error("Failed to sync auto-step:", { error: getApiErrorMessage(e) }); + } + } + } + + const res = await updateGroupOnboarding({ groupId: activeGroup._id, payload: { action: patch.action, step_id: patch.step_id } }); + const updatedChecklist = res.data?.onboarding_checklist || res.group?.onboarding_checklist; + + if (res.success && updatedChecklist) { + dispatch(updateActiveGroupOnboarding(updatedChecklist)); + queryClient.setQueryData(['groupDetails', activeGroup._id], (old: { group?: Group } | undefined) => { + if (!old || !old.group) return old; + return { + ...old, + group: { + ...old.group, + onboarding_checklist: updatedChecklist, + } + }; + }); + } + } catch (error) { + logger.error("Failed to update onboarding checklist:", { error: getApiErrorMessage(error) }); + } + } + } + }, + [orgId, userScope, activeGroup?._id, dispatch, activeChecklistState.completedSteps, autoSteps, queryClient, updateGroupOnboarding] + ); + + const openAddDeviceChoice = React.useCallback(() => { + setIsAddDeviceChoiceOpen(true); + }, []); + + const handleGoToVisibility = React.useCallback(() => { + setAccordionItems(prev => + prev.includes("visibility") ? prev : [...prev, "visibility"] + ); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + visibilityRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + setHighlightVisibility(true); + setTimeout(() => setHighlightVisibility(false), 4000); // longer — matches coach mark + }); + }); + }, []); + + const openClaimModal = React.useCallback(() => { + if (justCompletedClaim) return; + setIsAddDeviceChoiceOpen(false); + setIsClaimModalOpen(true); + }, [justCompletedClaim]); + + const openImportModal = React.useCallback(() => { + setIsAddDeviceChoiceOpen(false); + setIsImportModalOpen(true); + }, []); + + const refreshHomeData = React.useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["devices"] }); + queryClient.invalidateQueries({ queryKey: ["myDevices"] }); + queryClient.invalidateQueries({ queryKey: ["groupCohorts"] }); + queryClient.invalidateQueries({ queryKey: ["personalUserCohorts"] }); + queryClient.invalidateQueries({ queryKey: ["cohorts"] }); + queryClient.invalidateQueries({ queryKey: ["deviceCount"] }); + }, [queryClient]); + + const handleDeviceAdded = React.useCallback( + (deviceInfo?: { deviceId?: string; deviceName?: string; cohortId?: string; isCohortImport?: boolean }) => { + setJustCompletedClaim(true); + setTimeout(() => setJustCompletedClaim(false), 1000); + refreshHomeData(); + + if (deviceInfo?.isCohortImport || deviceInfo?.cohortId) { + setNewlyClaimedDevice(undefined); + updateChecklist({ + action: 'mark_step_complete', + step_id: 'add-device', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device", "assign-cohort"])), + }); + } else { + if (deviceInfo?.deviceId) { + setNewlyClaimedDevice([{ _id: deviceInfo.deviceId, name: deviceInfo.deviceName || "", long_name: deviceInfo.deviceName || "" }]); + } + updateChecklist({ + action: 'mark_step_complete', + step_id: 'add-device', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "add-device"])), + }); + } + }, + [activeChecklistState.completedSteps, refreshHomeData, updateChecklist] + ); + + const handleCohortAssigned = React.useCallback(() => { + updateChecklist({ + action: 'mark_step_complete', + step_id: 'assign-cohort', + completedSteps: Array.from(new Set([...(activeChecklistState.completedSteps || []), "assign-cohort"])), + }); + setNewlyClaimedDevice(undefined); + }, [activeChecklistState.completedSteps, updateChecklist]); + + + + + // ── Early returns ────────────────────────────────────────────────────────── + + if (hasError) { + return ( +
+ + + {error || "Failed to load dashboard context."} + +
+ ); + } + + // ── Derived state ────────────────────────────────────────────────────────── + + const hasNoDevices = + userScope === "personal" + ? (myDevicesData?.devices ?? []).length === 0 + : groupDevices.length === 0; + + const TOTAL_STEPS = 3; // add-device, assign-cohort, set-visibility + + // The checklist stays visible as long as: + // 1. Not all steps are complete, AND + // 2. The user hasn't explicitly dismissed it + const allStepsComplete = visuallyCompletedSteps.length >= TOTAL_STEPS; + const isLoadingGroupDetailsSafe = userScope === "organisation" && isLoadingGroupDetails; + const showChecklist = !allStepsComplete && !activeChecklistState.dismissed && !isLoadingGroupDetailsSafe; + + const showClaimDevice = (() => { + switch (userContext) { + case "personal": + case "external-org": + default: + return true; + } + })(); + + const renderSharedModals = () => ( + <> + { + setIsAssignCohortModalOpen(open); + if (!open) setNewlyClaimedDevice(undefined); + }} + selectedDevices={newlyClaimedDevice as Device[]} + onSuccess={handleCohortAssigned} + title="Group your devices" + /> + + setIsClaimModalOpen(false)} + onSuccess={handleDeviceAdded} + mode={showChecklist ? "guided" : "fast"} + /> + setIsImportModalOpen(open)} + onSuccess={handleDeviceAdded} + mode={showChecklist ? "guided" : "fast"} + /> + setIsAddDeviceChoiceOpen(false)} + title="Add a device" + showFooter={false} + size="xl" + > +
+ + + +
+
+ + ); + + const isLoading = + (userScope === "personal" && isLoadingMyDevices) || + (userScope === "organisation" && isLoadingGroupDevices) || + isLoadingUserContext; + + if (isLoading) { + return ( + <> +
+
+
+ + +
+
+
+ +
+
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ {renderSharedModals()} + + ); + } + + const renderMainContent = () => { + if (hasNoDevices) { + return ( +
+ + {showChecklist && ( + updateChecklist({ action: 'dismiss_checklist', dismissed: true })} + onAddDevice={openAddDeviceChoice} + onGoToCohorts={() => setIsAssignCohortModalOpen(true)} + onGoToVisibility={handleGoToVisibility} + onMarkAsDone={() => {}} + organizationName={formatTitle(activeGroup?.grp_title || "")} + isReadOnly={!canClaimDevice} + /> + )} +
+ ); + } + + return ( +
+ + + {showChecklist && ( + updateChecklist({ action: 'dismiss_checklist', dismissed: true })} + onAddDevice={openAddDeviceChoice} + onGoToCohorts={() => setIsAssignCohortModalOpen(true)} + onGoToVisibility={handleGoToVisibility} + onMarkAsDone={() => {}} + organizationName={formatTitle(activeGroup?.grp_title || "")} + isReadOnly={!canClaimDevice} + /> + )} + + {showClaimDevice && ( +
+

Home

+
+ setIsClaimModalOpen(true)} + Icon={Plus} + > + Add AirQo Device + + setIsImportModalOpen(true)} + Icon={Upload} + > + Import External Device + +
+
+ )} + +
+ + + +

Device Health

+
+ + + +
+ + + +

Device Visibility

+
+ + { + const nextCompletedSteps = Array.from( + new Set([...visuallyCompletedSteps, "set-visibility"]) + ); + const justCompletedSetup = + !visuallyCompletedSteps.includes("set-visibility") && + nextCompletedSteps.length >= TOTAL_STEPS; + + updateChecklist({ + action: 'mark_step_complete', + step_id: 'set-visibility', + completedSteps: nextCompletedSteps, + }); + + if (justCompletedSetup) { + ReusableToast({ + message: "Workspace setup complete. You're ready to monitor and manage your devices.", + type: "SUCCESS", + }); + setTimeout(() => { + if (isMounted.current) { + updateChecklist({ action: 'dismiss_checklist', dismissed: true }); + } + }, 2000); + } + }} + /> + +
+
+
+ + {userId && (user?.email || user?.userName) && ( + + )} +
+ ); + }; + + return ( + <> + {renderMainContent()} + {renderSharedModals()} + + ); +}; + +export default WelcomePage; diff --git a/src/vertex-template/app/(authenticated)/layout.tsx b/src/vertex-template/app/(authenticated)/layout.tsx new file mode 100644 index 0000000000..34d3c5f3c6 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/layout.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Layout from "@/components/layout/layout"; +import { ForbiddenGuard } from "@/components/layout/accessConfig/forbidden-guard"; +import { useForbiddenHandler } from "@/core/hooks/useForbiddenHandler"; +import { useContextAwareRouting } from "@/core/hooks/useContextAwareRouting"; +import { PageTitleProvider } from "@/context/page-title-context"; + +export default function AuthenticatedLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Listen for forbidden events + useForbiddenHandler(); + + // Handle context-aware routing + useContextAwareRouting(); + + return ( + + + + {children} + + + + ); +} diff --git a/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx new file mode 100644 index 0000000000..39bea20b74 --- /dev/null +++ b/src/vertex-template/app/(authenticated)/sites/[id]/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AqArrowLeft } from "@airqo/icons-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { useSiteDetails, useRefreshSiteMetadata } from "@/core/hooks/useSites"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useParams } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { SiteInformationCard } from "@/components/features/sites/site-information-card"; +import { SiteMobileAppCard } from "@/components/features/sites/site-mobile-app-card"; +import { EditSiteDetailsDialog } from "@/components/features/sites/edit-site-details-dialog"; +import ClientPaginatedDevicesTable from "@/components/features/devices/client-paginated-devices-table"; +import SiteMeasurementsApiCard from "@/components/features/sites/site-measurements-api-card"; +import SiteActivityCard from "@/components/features/sites/site-activity-card"; +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { usePageTitle } from "@/context/page-title-context"; + +const ContentGridSkeleton = () => ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+); + +export default function UserSiteDetailsPage() { + const params = useParams(); + const siteId = params.id as string; + const { data: site, isLoading, error } = useSiteDetails(siteId); + const { mutate: refreshMetadata, isPending: isRefreshing } = useRefreshSiteMetadata(); + const router = useRouter(); + const [editSection, setEditSection] = useState<"general" | "mobile" | null>( + null + ); + usePageTitle({ title: site?.name || "Site Details", section: "Sites" }); + + return ( + +
+
+ router.back()} + Icon={AqArrowLeft} + > + Back + + + refreshMetadata(siteId)} + disabled={isRefreshing || isLoading || !site} + loading={isRefreshing} + Icon={RefreshCw} + className="text-xs font-medium" + > + Refresh Metadata + +
+ + {error ? ( +
+ + + Error + + Unable to load site details. Please try again. + + +
+ ) : isLoading ? ( + + ) : !site ? ( +
+ Site not found. +
+ ) : ( + <> +
+
+ setEditSection("general")} + /> +
+ +
+ setEditSection("mobile")} + /> + +
+ +
+ +
+
+
+ { + router.push(`/devices/overview/${device._id}`); + }} + /> +
+ !open && setEditSection(null)} + site={site} + section={editSection || "general"} + /> + + )} +
+
+ ); +} diff --git a/src/vertex-template/app/(authenticated)/sites/overview/page.tsx b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx new file mode 100644 index 0000000000..121b18edcf --- /dev/null +++ b/src/vertex-template/app/(authenticated)/sites/overview/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { RouteGuard } from "@/components/layout/accessConfig/route-guard"; +import SitesTable from "@/components/features/sites/sites-list-table"; +import { PERMISSIONS } from "@/core/permissions/constants"; + +export default function SitesOverviewPage() { + return ( + +
+
+
+

Sites

+

+ Manage and organize your monitoring sites +

+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md new file mode 100644 index 0000000000..551b609522 --- /dev/null +++ b/src/vertex-template/app/_docs/deprecated/AIRQO-GROUP-PERMISSIONS-IN-PRIVATE-CONTEXT.md @@ -0,0 +1,193 @@ +> **NOTE**: This document describes the specific solution for Private Context permissions. For the complete system architecture regarding Scopes and Contexts, please refer to [ACCESS-CONTROL-ARCHITECTURE.md](../internal/ACCESS-CONTROL-ARCHITECTURE.md). + +# Using AirQo Group Permissions in Private Context + +## Problem Statement +Every user has an AirQo group assigned to them. The app has different contexts (personal, airqo-internal, external-org) with different view states. However, in the **private context**, we needed to use the permissions assigned to the user's AirQo group for certain use cases, instead of hardcoding or skipping permission checks. + +## Solution Overview + +We implemented a fallback mechanism that: +1. Checks if the user is in `personal` context +2. Retrieves their AirQo group (if they have one) +3. Uses that group's permissions for permission checks + +## Implementation Details + +### 1. Permission Service (`core/permissions/permissionService.ts`) + +Added a new method to retrieve the user's AirQo group: + +```typescript +/** + * Get user's AirQo group (if they have one) + * This is useful for private context permission checks + */ +getAirQoGroup(user: UserDetails): Group | undefined { + if (!user.groups) return undefined; + + return user.groups.find((group) => + group.grp_title.toLowerCase() === 'airqo' + ); +} +``` + +This method: +- Searches through the user's groups +- Finds the group with title "airqo" (case-insensitive) +- Returns the group object or undefined if not found + +### 2. usePermission Hook (`core/hooks/usePermissions.ts`) + +Modified to use AirQo group permissions as fallback in personal context: + +```typescript +export const usePermission = (permission: Permission, context?: Partial) => { + const user = useAppSelector((state) => state.user.userDetails); + const activeGroup = useAppSelector((state) => state.user.activeGroup); + const activeNetwork = useAppSelector((state) => state.user.activeNetwork); + const userContext = useAppSelector((state) => state.user.userContext); + + const result = useMemo(() => { + if (MOCK_PERMISSIONS_ENABLED) { + return MOCK_PERMISSIONS[permission] ?? false; + } + + if (!user) return false; + + // If in personal context and no active organization is provided, + // check against AirQo group permissions (if user has one) + let effectiveContext = context; + if (userContext === 'personal' && !context?.activeOrganization && !activeGroup) { + const airqoGroup = permissionService.getAirQoGroup(user); + if (airqoGroup) { + effectiveContext = { + ...context, + activeOrganization: airqoGroup, + }; + } + } + + return permissionService.hasPermission(user, permission, { + ...effectiveContext, // ✅ Spread first + activeOrganization: effectiveContext?.activeOrganization ?? activeGroup ?? undefined, + activeNetwork: effectiveContext?.activeNetwork ?? activeNetwork ?? undefined, + }); + }, [user, permission, activeGroup, activeNetwork, userContext, context]); + + return result; +}; +``` + +Key changes: +- Added `userContext` to the hook's dependencies +- Check if we're in `personal` context without an active group +- If so, retrieve the AirQo group and use it as the `activeOrganization` +- This ensures permission checks use the AirQo group's role permissions + +### 3. useUserContext Hook (`core/hooks/useUserContext.ts`) + +Updated `getContextPermissions()` to use actual permission checks in personal context: + +```typescript +const getContextPermissions = () => { + // In personal context, check AirQo group permissions if they exist + if (userContext === 'personal') { + // Device view is always available in personal context for owned devices + // But for other permissions, check the user's AirQo group permissions + return { + canViewDevices: true, // Always true for personal devices + canViewSites, + canViewUserManagement, + canViewAccessControl, + canViewOrganizations: false, + canViewNetworks: false, + }; + } + + // For organizational contexts, use the regular permission checks + return { + canViewDevices, + canViewSites, + canViewUserManagement, + canViewAccessControl, + canViewNetworks, + }; +}; +``` + +Changes: +- Instead of hardcoding `false` for all permissions except `canViewDevices` +- Now uses the actual permission hooks (`canViewSites`, `canViewUserManagement`, etc.) +- These will automatically use AirQo group permissions thanks to the `usePermission` hook changes + +## How It Works + +### Scenario 1: User in Personal Context WITH AirQo Group + +1. User is in `personal` context +2. No `activeGroup` is set (personal mode) +3. User calls `usePermission(PERMISSIONS.SITE.VIEW)` +4. Hook detects personal context without active group +5. Retrieves user's AirQo group using `permissionService.getAirQoGroup(user)` +6. Uses AirQo group as the `activeOrganization` for permission check +7. Permission service checks if the user's AirQo group role has `SITE.VIEW` permission +8. Returns `true` or `false` based on actual permissions + +### Scenario 2: User in Personal Context WITHOUT AirQo Group + +1. User is in `personal` context +2. No `activeGroup` is set +3. User has no AirQo group +4. Permission check falls through to default behavior +5. Returns `false` (no permissions without a group) + +### Scenario 3: User in Organizational Context + +1. User is in `airqo-internal` or `external-org` context +2. `activeGroup` is set +3. Permission checks use the `activeGroup` directly +4. No AirQo group fallback needed + +## Benefits + +1. **Consistent Permission Model**: Same RBAC system works across all contexts +2. **Flexibility**: Users can have different permissions in their AirQo group that apply to personal context +3. **No Hardcoding**: Permissions are data-driven, not hardcoded in the UI +4. **Backward Compatible**: Doesn't break existing behavior for organizational contexts +5. **Fine-Grained Control**: Admins can control what users can do in personal context via AirQo group roles + +## Use Cases + +This implementation enables scenarios like: + +- **AirQo staff** with appropriate permissions can view sites even in personal mode +- **Admin users** can access user management features from personal context +- **Technical staff** can perform maintenance operations regardless of context +- **Analysts** can access data export features in personal mode + +## Testing + +To test this implementation: + +1. **Create a user with AirQo group** that has specific permissions (e.g., `SITE.VIEW`) +2. **Switch to personal context** +3. **Check if permission-gated features** are accessible based on AirQo group permissions +4. **Verify permission checks** return correct values + +Example: +```typescript +// In a component +const canViewSites = usePermission(PERMISSIONS.SITE.VIEW); +// In personal context, this will check the user's AirQo group permissions + +// In a feature +{canViewSites && } +// This button will show in personal mode if the user's AirQo group has SITE.VIEW +``` + +## Notes + +- The `canViewDevices` permission is always `true` in personal context because users should always see their own devices +- Organizations-specific features (`canViewOrganizations`, `canViewNetworks`) remain `false` in personal context as they don't make sense there +- The implementation preserves all existing behavior for `airqo-internal` and `external-org` contexts diff --git a/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md new file mode 100644 index 0000000000..fcea6f4919 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/ACCESS-CONTROL-ARCHITECTURE.md @@ -0,0 +1,79 @@ +# Access Control Architecture: Scopes & Contexts + +> **Note**: This document describes the current access control model, focusing on the decoupling of Context and Scope. + +## Core Concepts + +The application uses a **Scope-based Architecture** to decouple *where a user is* (Context) from *how the application behaves* (Scope). + +### 1. Context (`userContext`) +Represents the user's current "location" or logical grouping. +- **`personal`**: The default view for users. This encompasses both "normal" users managing their own devices AND AirQo staff managing the system (via their personal workspace). +- **`external-org`**: Specific to users belonging to third-party organizations (e.g., KCCA, NEMA) when they have explicitly switched to that organization's view. + +### 2. Scope (`userScope`) +Represents the *behavioral mode* of the application. This determines navigation, available features, and data visibility. +- **`personal` Scope**: Focuses on "My Resources". Shows personal devices, user-specific data, and for AirQo staff, allows system management. +- **`organisation` Scope**: Focuses on "Shared Resources". Shows organization-wide dashboards, all devices in the org, team management. This is strictly for External Organizations. + +## The Model: Context → Scope Mapping + +We enforce strict scoping rules to ensure consistent user experience: + +| Context | Mapped Scope | Behavior Description | +| :--- | :--- | :--- | +| `personal` | `personal` | **"My Workspace"**. User manages their own devices, follows their own sites. AirQo staff also perform system admin here. | +| `external-org` | `organisation` | **"Organization Dashboard"**. Traditional admin view for external partners to manage their specific entity. | + +> **Key Architecture Note**: We previously had an `airqo-internal` context, but this has been deprecated. AirQo group members now operate strictly within the `personal` context. They do not "administer the AirQo Organization" like an external org admin; instead, they administrate the *System* from their *Personal Workspace*. + +## Permission Resolution Strategy + +How we decide if a user can do something (`usePermission` hook) depends on the Scope. + +### 1. In `organisation` Scope (External Orgs) +* **Source**: The `activeGroup` (the organization the user is currently viewing). +* **Logic**: Standard RBAC. Does the user's role *in this specific group* have the permission? +* **Example**: A KCCA Admin viewing KCCA dashboard checks KCCA group permissions. + +### 2. In `personal` Scope (Personal Workspace) +This uses a layered approach to allow seamless system management. + +* **Layer 1: Ownership (Implicit)** + * Users always have owner-level access to their *own* resources (e.g., `canViewDevices` is always true for own devices). + +* **Layer 2: Active Group Assignment (Universal)** + * **Problem**: Users in Personal Mode need permissions to access certain features (like Admin tools), but traditionally had no active group. + * **Solution**: If a user belongs to the **AirQo** group, the system now automatically sets it as their `activeGroup` even when in Personal Mode. + * **Result**: Standard permissions work out-of-the-box. An Admin sees their admin features because they genuinely have the AirQo group active. + +### Implementation Details + +#### `userSlice.ts` +Logic defining the active group assignment: +```typescript +if (!action.payload) { + // Personal Mode + // If user belongs to AirQo group, keep it active! + const airqoGroup = state.userGroups.find((g) => g.grp_title === 'airqo'); + if (airqoGroup) { + state.activeGroup = airqoGroup; + // ... + } +} +``` + +## Matrix: Who Sees What Where? + +| Feature | AirQo Staff (Personal Context) | External Org Admin (Org Context) | +| :--- | :--- | :--- | +| **Scope** | `personal` | `organisation` | +| **My Devices** | ✅ Visible | ❌ Hidden | +| **Org Dashboard** | ❌ Hidden | ✅ Visible | +| **Network Mgmt** | ✅ Visible (via AirQo Group) | ❌ Hidden | +| **Site Mgmt** | ✅ Visible (via AirQo Group) | ✅ Visible (Org sites only) | + +## Summary +* **External Users**: Strict isolation in `external-org` context. They only see what's in their Org. +* **AirQo Staff**: Exist in `personal` context. They "live" in a Personal Scope but "borrow" powers from the AirQo System Group to manage global resources like Networks and Sites. +* **Context Switching**: Available to any user who belongs to multiple organizations. diff --git a/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md new file mode 100644 index 0000000000..ecc2771ed1 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/RBAC-Feature-Access.md @@ -0,0 +1,104 @@ +# AirQo RBAC Feature Access – Living Document + +--- + +## RBAC Principles + +- **Transparency:** Prefer disabling over hiding, unless security or irrelevance dictates otherwise. +- **Security:** Hide destructive or sensitive actions from all but the highest-level admins. +- **Motivation:** Disabled features should explain what's required to enable them. +- **Clarity:** Users should never wonder if something is missing due to a bug. + +--- + +## Feature Access Matrix + +| **Feature/Action** | **Super Admin** | **Org Admin** | **Technician** | **Analyst** | **Developer** | **Viewer** | **Guest/No Org** | **Disable for** | **Hide for** | +|----------------------------|:--------------:|:-------------:|:--------------:|:-----------:|:-------------:|:----------:|:----------------:|:-------------------------------|:------------------------------------| +| Device Claim | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Deploy | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Device Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Device View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Device Assignment/Share | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Site Create/Update | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin/Tech | Guest/No Org | +| Site Delete | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Site View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Analytics Dashboard | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Data Export | ✔️ | ✔️ | ❌ | ✔️ | ✔️ | ❌ | ❌ | All except Super/Org Admin/Analyst/Dev | Guest/No Org, Technician, Viewer | +| User Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Role Management | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organization Management | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin | +| Settings Edit | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Settings View | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | Guest/No Org | Guest/No Org | +| Premium/Upgrade | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ | All except premium orgs/users | Guest/No Org, non-premium orgs | +| Destructive Actions | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | All except Super Admin | All except Super Admin | + +**Legend:** +- ✔️ = Has access (feature enabled) +- ❌ = No access (feature disabled or hidden) +- **Disable for** = Show feature, but disabled, for these roles +- **Hide for** = Don't show feature at all for these roles + +--- + +## Sidebar & Application Feature Mapping + +### Sidebar Structure (Example) + +| **Sidebar Item** | **Sub-Items** | **Permission** | **Disable for** | **Hide for** | +|-------------------------|------------------------------|-------------------------------|--------------------------------|--------------------------------------| +| Dashboard | — | DEVICE.VIEW | — | Guest/No Org | +| Network Map | — | SITE.VIEW | — | Guest/No Org | +| Devices | Overview, My Devices | DEVICE.VIEW | All except permitted roles | Guest/No Org | +| | Deploy, Claim, Share | DEVICE.DEPLOY/UPDATE/ASSIGN | All except permitted roles | Guest/No Org, Viewer | +| Sites | Overview, Create, Grids | SITE.VIEW, SITE.CREATE | All except permitted roles | Guest/No Org | +| Cohorts | Overview, Create | DEVICE.VIEW, DEVICE.UPDATE | All except permitted roles | Guest/No Org | +| User Management | — | USER.VIEW, USER.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Access Control | Roles, Permissions | ROLE.VIEW, ROLE.MANAGEMENT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organizations | — | ORGANIZATION.VIEW | All except Super Admin | All except Super Admin | +| Team | Overview, Invite, Roles | USER.VIEW, USER.CREATE, ROLE.VIEW | All except permitted roles | Guest/No Org, Technician, Analyst | +| Profile Settings | — | — | — | — | + +**Notes:** +- For each sidebar item, use your `PermissionGuard` or `usePermission` to determine if it should be enabled or disabled. +- If a user is in the "Hide for" group, do not render the sidebar item at all. +- If a user is in the "Disable for" group, render the item but make it non-clickable and show a tooltip explaining why. + +--- + +### Application Features (Major Screens/Actions) + +| **Feature/Screen** | **Permission** | **Disable for** | **Hide for** | +|----------------------------|-------------------------------|--------------------------------|--------------------------------------| +| Claim Device | DEVICE.CLAIM | All except permitted roles | Guest/No Org | +| Deploy Device | DEVICE.DEPLOY | All except permitted roles | Guest/No Org | +| Device Assignment/Share | DEVICE.ASSIGN | All except permitted roles | Guest/No Org, Viewer | +| Delete Device | DEVICE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Create Site | SITE.CREATE | All except permitted roles | Guest/No Org | +| Delete Site | SITE.DELETE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Export Data | ANALYTICS.DATA_EXPORT | All except Analyst/Dev/Admin | Guest/No Org, Technician, Viewer | +| Invite User | USER.INVITE | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Edit Roles | ROLE.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| Organization Management | ORGANIZATION.UPDATE | All except Super Admin | All except Super Admin | +| Edit Settings | SETTINGS.EDIT | All except Super/Org Admin | Guest/No Org, Technician, Analyst | +| View Settings | SETTINGS.VIEW | All except permitted roles | Guest/No Org | +| Destructive Actions | SYSTEM.SUPER_ADMIN | All except Super Admin | All except Super Admin | + +--- + +## How to Maintain This Document + +- **Update** whenever new features or roles are added. +- **Review** with product/design quarterly or after major releases. +- **Link** to this doc from engineering and product wikis. +- **Add** a "Request Access" or "Upgrade" workflow for disabled features as needed. + +--- + +## Future Updates / Open Questions + +- [ ] Are there new roles or custom roles to consider? +- [ ] Should some features be "requestable" (user can click to request access)? +- [ ] Are there features that should be hidden for compliance/security reasons? +- [ ] Should we add analytics to track how often users see disabled features? \ No newline at end of file diff --git a/src/vertex-template/app/_docs/internal/device-status.md b/src/vertex-template/app/_docs/internal/device-status.md new file mode 100644 index 0000000000..570832d312 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/device-status.md @@ -0,0 +1,36 @@ +# Vertex Device Status Guide + +This document defines the current logic used to determine a device's status in Vertex. + +## Core Concept + +Our logic is based on two independent boolean fields from the API: + +### 1. rawOnlineStatus (Raw Data) + +- **What it means:** Is the device sending raw, unprocessed data to the cloud? +- **Values:** Transmitting (true) / Not Transmitting (false) + +### 2. isOnline (Calibrated Data) + +- **What it means:** Is processed, calibrated data available for use? +- **Values:** Data Ready (true) / Processing or No Data (false) + +These fields are independent. For example, a device can be transmitting raw data (`rawOnlineStatus: true`) while the calibrated data is still processing (`isOnline: false`). + +## Status Definitions + +The combination of these two fields determines the device's primary status: + +| Status | Color | rawOnlineStatus (Raw Data) | isOnline (Calibrated Data) | Logic Summary | +|--------|-------|----------------------------|----------------------------|---------------| +| **Operational** | Green | true (Transmitting) | true (Data Ready) | Device transmitting • Data ready for use. | +| **Transmitting** | Blue | true (Transmitting) | false (Processing) | Receiving data • Processing calibration | +| **Data Available** | Yellow | false (Not Transmitting) | true (Data Ready) | Using recent data • Not currently transmitting. | +| **Not Transmitting** | Gray | false (Not Transmitting) | false (Processing) | No recent data from the device. | + +## Special Case: Invalid Date + +| Status | Color | Logic | +|--------|-------|-------| +| **Invalid Date** | Purple | This status appears if the device reports a `lastActive` timestamp that is more than 5 minutes in the future. This indicates a device-level clock or configuration error and overrides the logic above. | diff --git a/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md new file mode 100644 index 0000000000..5384e7e88c --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-adapter-foundation.md @@ -0,0 +1,43 @@ +# Vertex Adapter Foundation + +## Purpose + +This document describes the adapter foundation for `create-vertex-app` v1. The foundation defines the interface that future app data flows should use and provides a mock adapter that can power local evaluation without API credentials. + +## Files + +- `core/adapters/types.ts`: v1 adapter contract. +- `core/adapters/index.ts`: adapter factory selected by `vertex.config.ts`. +- `core/adapters/mock.ts`: mock adapter implementation. +- `core/adapters/mock-fixtures.ts`: typed fixture data. +- `core/adapters/airqo.ts`: AirQo wrapper around the existing service layer. + +## V1 Adapter Choices + +- `mock`: implemented now and intended as the generated template default. +- `airqo`: wraps existing `core/apis/*` services without rewriting endpoint behavior. + +Generic REST remains out of scope for v1. + +## Integration Boundary + +This foundation does not refactor existing React Query hooks yet. The next integration step should route `core/hooks/*` through the adapter while keeping hook return shapes stable for current components. + +## Mock Adapter Coverage + +The mock adapter includes: + +- Seeded user, group, role, and network data. +- Four devices covering online, offline, private, deployed, and undeployed states. +- Three sites with coordinates and status variety. +- Two cohorts with device membership. +- Device and site activity events. +- Latest and historical reading data. +- Successful no-op mutations for claim, deploy, recall, create, update, maintenance, and group assignment flows. + +## Contributor Guidance + +- Keep fixture data realistic but small. +- Add adapter methods only when current v1 screens need them. +- Keep AirQo endpoint mapping out of `mock.ts`; `airqo.ts` owns live API service mapping. +- Do not add REST adapter behavior in v1. diff --git a/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md new file mode 100644 index 0000000000..8a7c2fe7a2 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-config-contract.md @@ -0,0 +1,47 @@ +# Vertex Template Config Contract + +## Purpose + +`vertex.config.ts` is the single deployer-owned configuration file for a Vertex instance. The future `create-vertex-app` CLI should generate this file from wizard answers. + +Shared validation, defaults, and TypeScript types live in `core/config/vertex-config.ts`. + +## V1 Defaults + +V1 supports only: + +- `api.adapter: "mock"` for local evaluation without credentials. +- `api.adapter: "airqo"` for the current AirQo API-backed app. +- `auth.provider: "none"` for mock/local template evaluation. +- `auth.provider: "airqo"` for the existing AirQo auth/session flow. + +Generic REST adapters, plugin systems, deploy wizards, and config admin screens are v2 work. + +## Required Config Groups + +- `org`: visible organization identity, logo, primary color, support email, website, and slug. +- `api`: adapter choice, AirQo API base URL when applicable, and public measurement URL base. +- `auth`: auth provider and system group slug used by AirQo-mode permission logic. +- `features`: v1 feature flags for maps, bulk deploy, sites, CSV export, readings, user management, desktop download, app launcher, shipping, and network requests. +- `map`: default center, zoom, and tile provider. +- `links`: docs, privacy, cookie policy, analytics, and desktop download URLs. + +## Validation Rules + +- Organization name, short name, slug, logo, primary color, and support email must be valid. +- `org.primaryColor` must be a hex color. +- `api.adapter` must be `mock` or `airqo`. +- `auth.provider` must be `none` or `airqo`. +- `api.baseUrl` is required when `api.adapter` is `airqo`. +- `auth.provider: "airqo"` requires `api.adapter: "airqo"`. +- Map latitude must be between -90 and 90. +- Map longitude must be between -180 and 180. +- Map zoom must be between 0 and 22. + +## Contributor Guidance + +- Do not add new top-level config groups without maintainer approval. +- Prefer adding feature-specific options under an existing group. +- Keep `vertex.config.example.ts` mock-first. +- Keep `vertex.config.ts` AirQo-compatible until template extraction is complete. +- Do not implement the generic REST adapter in v1. diff --git a/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md new file mode 100644 index 0000000000..dfeac094d5 --- /dev/null +++ b/src/vertex-template/app/_docs/internal/vertex-template-coupling-audit.md @@ -0,0 +1,137 @@ +# Vertex Template Coupling Audit + +## Purpose + +This audit maps the current Vertex app coupling that must be addressed before extracting a reusable `vertex-template` and publishing `create-vertex-app`. + +The current app is a Next.js App Router project. Routes live in `app/`, feature UI in `components/features/`, layout in `components/layout/`, API services in `core/apis/`, React Query hooks in `core/hooks/`, auth/proxy routes in `app/api/`, and shared state/config in `core/`, `context`, and `lib`. + +## Summary + +V1 should not rewrite Vertex. It should isolate existing behavior behind configuration and adapters. + +Primary coupling areas: + +- AirQo branding in metadata, layout, login, errors, download, footer, topbar, title bar, app launcher, and support links. +- AirQo API access through `NEXT_PUBLIC_API_URL`, proxy routes, `secureApiProxyClient`, and `core/apis/*`. +- AirQo group/network assumptions in auth, Redux user state, permissions, hooks, and device/site filtering. +- Hardcoded public measurement endpoint examples. +- Map defaults and Mapbox-only assumptions. +- Optional AirQo/ops features that should be feature-flagged or excluded from v1 template defaults. + +V1 adapter support should be `mock` and `airqo` only. Generic REST remains v2. + +## Priority Legend + +- `P0`: Required before template extraction. +- `P1`: Required before public v1 release, can follow adapter foundation. +- `P2`: Can remain in AirQo mode or be deferred if hidden by feature flags. +- `Keep`: Intentional dependency or acceptable package/library usage. + +## Coupling Inventory + +| Area | Files | Current coupling | Replacement | Priority | +|---|---|---|---|---| +| App metadata | `app/layout.tsx` | Hardcoded `AirQo Vertex`, AirQo description, `vertex.airqo.net`, AirQo image path | Read org/app metadata from `vertexConfig.org` and template defaults | P0 | +| Page title provider | `context/page-title-context.tsx` | `APP_TITLE = "AirQo Vertex"` | Use configured app/org title | P0 | +| Login branding | `app/login/page.tsx` | AirQo logo and copy about AirQo open data channels | Use configured logo, org name, support/value copy | P0 | +| Auth error page | `app/auth-error/page.tsx` | AirQo logo, `support@airqo.net`, AirQo copyright | Use config support email, logo, org name | P0 | +| Topbar | `components/layout/topbar.tsx` | AirQo logo, account labels | Use configured logo and neutral account labels | P0 | +| Primary sidebar | `components/layout/primary-sidebar.tsx` | AirQo logo, Vertex label, AirQo admin role names | Logo from config; admin visibility from config and permission mode | P1 | +| Desktop titlebar | `components/layout/desktop-titlebar.tsx` | AirQo logo fallback and `AirQo Vertex` text | Use config branding and generic fallback | P1 | +| Footer | `components/layout/Footer.tsx` | AirQo copyright and platform name | Config org name and app name | P1 | +| App launcher | `components/layout/AppDropdown.tsx` | AirQo ecosystem links, AirQo app store copy | Hide by default or make AirQo-mode-only feature | P2 | +| Download page | `app/download/page.tsx`, `components/features/download/*`, `core/constants/app-downloads.ts` | AirQo Vertex Desktop release URL/copy | Feature-flag desktop download; config-driven URL if enabled | P2 | +| Cookie banner | `components/features/auth/cookie-info-banner.tsx`, `lib/envConstants.ts` | AirQo cookie copy and default AirQo policy URL | Configurable policy URL and org name | P1 | +| Feedback | `components/features/feedback/*` | Event key `airqo:feedback:open`, "Send feedback to AirQo" | Configurable org name; event key can be generic | P1 | +| Public support links | `app/not-found.tsx`, `components/ui/permission-tooltip.tsx` | AirQo support/docs links | Configurable support and docs URLs | P1 | +| Theme color | `app/globals.css`, `tailwind.config.ts` | Primary blue is hardcoded in CSS variables | Inject/configure `org.primaryColor`; keep Tailwind variable pattern | P0 | +| API base URLs | `lib/envConstants.ts`, `core/urls.tsx`, `core/config/proxyConfig.ts` | Requires `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_API_TOKEN`; Analytics default is AirQo | Move API base URL/token requirements behind adapter mode | P0 | +| Axios/proxy client | `core/utils/secureApiProxyClient.ts`, `core/utils/proxyClient.ts`, `core/apis/axiosConfig.ts` | AirQo proxy behavior and auth headers | Keep for AirQo adapter; mock adapter bypasses it | P0 | +| Dynamic proxy route | `app/api/[...path]/route.ts` | Proxies to configured AirQo-style backend path | Keep AirQo-mode only; avoid requiring in mock mode | P1 | +| Devices service | `core/apis/devices.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter; do not rewrite endpoints in v1 | P0 | +| Sites service | `core/apis/sites.ts` | Direct AirQo endpoint paths | Wrap via AirQo adapter | P0 | +| Cohorts service | `core/apis/cohorts.ts` | Direct AirQo endpoint paths | Wrap needed methods via adapter | P0 | +| Networks service | `core/apis/networks.ts`, `core/services/network-service.ts` | AirQo users/networks endpoints and `ADMIN_SECRET` flows | AirQo adapter or AirQo-mode-only admin feature | P1 | +| Grids service | `core/apis/grids.ts` | Direct backend paths | Include only if grid feature remains enabled; otherwise flag | P2 | +| Users/auth service | `core/apis/users.ts`, `app/api/auth/[...nextauth]/*` | AirQo login/profile/session shape | Keep for AirQo mode; mock/no-auth path needed for template evaluation | P0 | +| Cloudinary | `core/apis/cloudinary.ts`, `app/api/cloudinary/upload/route.ts` | Requires Cloudinary env vars | Feature-flag uploads or make optional config | P2 | +| Slack logging | `app/api/log-to-slack/route.ts`, `lib/logger.ts` | Slack webhook route | Keep optional; disabled when env missing | P2 | +| Mapbox | `components/features/mini-map/mini-map.tsx`, `components/features/location-autocomplete/LocationAutocomplete.tsx` | Mapbox token, Mapbox geocoding, Kampala default center | Config map provider, default center/zoom, optional Mapbox token | P1 | +| Measurement examples | `components/features/devices/device-measurements-api-card.tsx`, `components/features/sites/site-measurements-api-card.tsx`, `components/features/cohorts/cohort-measurements-api-card.tsx`, `components/features/grids/grid-measurements-api-card.tsx` | Hardcoded `https://api.airqo.net/api/v2/...` examples | Build URLs from config/API docs base; hide in mock mode if misleading | P1 | +| Auth default org | `core/auth/authProvider.tsx` | Prefers group named `airqo` as default organization | Adapter/config-defined system group or AirQo-mode-only logic | P0 | +| Redux user context | `core/redux/slices/userSlice.ts` | Treats `airqo` group as personal/elevated context | Abstract into configured system group or mock local context | P0 | +| Permissions | `core/permissions/*`, `core/hooks/usePermissions.ts` | AirQo RBAC names and AirQo group fallback | Keep AirQo mode; add simple mock/no-auth permissions for v1 evaluation | P0 | +| Device hooks | `core/hooks/useDevices.ts` | Imports `devices` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Site hooks | `core/hooks/useSites.ts` | Imports `sites` API directly and checks `airqo` group | Route through adapter-backed hook layer | P0 | +| Network hooks | `core/hooks/useNetworks.ts` | Sorts `airqo` network first; imports APIs directly | Adapter-backed data and config-defined preferred network | P1 | +| User context hook | `core/hooks/useUserContext.ts` | AirQo-specific personal/org scope comments and assumptions | Configurable auth/permission mode | P1 | +| Device claim/import | `components/features/claim/*`, `components/features/devices/import-device-modal.tsx` | Claim AirQo Device copy, reserved `airqo` cohort, AirQo device placeholders | Configurable device vocabulary; reserved names AirQo-mode only | P1 | +| Device deploy | `components/features/devices/deploy-device-component.tsx` | Direct fetch to `/api/v2/devices/my-devices?claim_status=claimed`; default network `airqo` | Move data call into hook/adapter; default network from config/current context | P0 | +| Device create/admin | `components/features/devices/create-device-modal.tsx`, `app/(authenticated)/admin/networks/[id]/page.tsx` | "Add AirQo Device" copy and AirQo network branch | Configurable copy; AirQo branch only in AirQo mode | P1 | +| Public visibility copy | `components/features/home/network-visibility-card.tsx`, `components/features/cohorts/cohort-detail-card.tsx` | "AirQo Map" copy | Configurable public map/product name | P1 | +| Empty states | `components/features/home/HomeEmptyState.tsx`, `components/features/cohorts/cohorts-empty-state.tsx` | AirQo device copy | Configurable device terminology | P1 | +| Shipping labels | `components/features/shipping/*` | AirQo Air Quality Monitor label text | AirQo-mode only or config-driven label brand | P2 | +| Network request flows | `components/features/networks/*`, `app/api/network/route.ts`, `app/api/devices/network-creation-requests/*` | AirQo backend admin-secret network creation | AirQo-mode admin feature; not required for mock default | P2 | +| Query/cache keys | `core/providers/query-provider.tsx`, `core/utils/clientCache.ts` | `airqo:vertex:*` storage keys | Generic `vertex:*` or config namespace to avoid cross-instance collisions | P1 | +| Internal docs/changelog | `app/changelog.md`, `app/_docs/internal/*`, `app/_docs/deprecated/*` | AirQo internal history and architecture | Exclude or heavily sanitize from `vertex-template` | P2 | + +## Direct API Call Notes + +Most backend access is already centralized, which is good for adapter extraction: + +- `core/apis/devices.ts` +- `core/apis/sites.ts` +- `core/apis/cohorts.ts` +- `core/apis/grids.ts` +- `core/apis/networks.ts` +- `core/apis/users.ts` +- `core/apis/roles.ts` +- `core/apis/permissions.ts` +- `core/apis/organizations.ts` + +The main component-level exceptions found are: + +- `components/features/devices/deploy-device-component.tsx`: direct `fetch("/api/v2/devices/my-devices?claim_status=claimed")` +- `components/features/networks/create-network-form.tsx`: posts to `/api/network` +- `components/features/networks/network-request-dialog.tsx`: posts to `/api/devices/network-creation-requests` +- `components/features/mini-map/mini-map.tsx`: fetches Mapbox reverse geocoding +- `components/features/location-autocomplete/LocationAutocomplete.tsx`: fetches Mapbox suggestions +- Measurement API card components hardcode public AirQo API examples. + +## Recommended Migration Order + +1. Add typed config and defaults. +2. Add adapter interface and mock adapter. +3. Add AirQo adapter wrapping existing `core/apis`. +4. Refactor `core/hooks` to use adapter-backed access. +5. Remove component-level direct backend calls or move them behind hooks/adapters. +6. Config-drive branding, metadata, theme, map defaults, support links, and feature flags. +7. Hide or AirQo-mode-gate admin/network/shipping/download/app-launcher features. +8. Sanitize docs and extract template. + +## Out of Scope For V1 + +- Generic REST adapter. +- Production deploy wizard. +- Plugin system. +- Managed hosting. +- Template upgrade command. +- Admin UI for config. +- Replacing `@airqo/icons-react` solely because of package name; icon imports can stay unless brand/legal review requires removal. + +## Open Questions For Maintainers + +- Should `@airqo/icons-react` remain a public dependency in the generic template? +- Should desktop download and shipping workflows ship disabled by default or be excluded from template v1? +- What is the approved default mock organization name, logo, and primary color? +- Should mock mode use `auth.provider = "none"` with a synthetic local user, or keep NextAuth with seeded credentials? +- Should public measurement API cards appear in mock mode, or only in AirQo mode? + +## Contributor Guidance + +This audit is documentation only. Follow-up implementation tasks should avoid broad file ownership conflicts: + +- Core maintainers should own config, adapter contracts, auth, and hook refactors. +- Open-source contributors can safely work on branding copy, measurement cards, docs, fixture data, and feature-flagged UI after the config contract lands. +- PRs should touch narrow areas and avoid project-wide formatting. diff --git a/src/vertex-template/app/api/[...path]/route.ts b/src/vertex-template/app/api/[...path]/route.ts new file mode 100644 index 0000000000..d923ddff56 --- /dev/null +++ b/src/vertex-template/app/api/[...path]/route.ts @@ -0,0 +1,48 @@ + +import { createProxyHandler } from '@/core/utils/proxyClient'; +import { getAuthOptions } from '@/core/config/proxyConfig'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Dynamic API proxy handler + * + * This file handles all API proxy requests by forwarding them to the actual API + * but without exposing sensitive tokens on the client side. + * + * For routes that need API_TOKEN authentication, the token is added on the server side. + * For routes requiring JWT auth, the authorization header is forwarded from the client. + * + * Path pattern: /api/[...path] + */ + +interface RouteParams { + params: { + path: string[]; + }; +} + +// Determine if the request needs authentication based on the path or explicit header +const handler = (request: NextRequest, { params }: RouteParams) => { + // Extract path segments + const { path } = params; + + // Skip NextAuth routes - they should be handled by NextAuth directly + if (path && path.length > 0 && path[0] === 'auth') { + return NextResponse.json({ error: 'Not Found' }, { status: 404 }); + } + + // Check for explicit auth type header + const authTypeHeader = request.headers.get('x-auth-type'); + + // Get optimized auth options + const options = getAuthOptions(path, authTypeHeader || undefined); + + // Create and execute the appropriate proxy handler + return createProxyHandler(options)(request, { params }); +}; + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const DELETE = handler; +export const PATCH = handler; diff --git a/src/vertex-template/app/api/auth/[...nextauth]/options.ts b/src/vertex-template/app/api/auth/[...nextauth]/options.ts new file mode 100644 index 0000000000..9ee1c7bc11 --- /dev/null +++ b/src/vertex-template/app/api/auth/[...nextauth]/options.ts @@ -0,0 +1,355 @@ +import { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { users } from '@/core/apis/users'; +import { jwtDecode } from 'jwt-decode'; +import type { + LoginCredentials, + LoginResponse, + DecodedToken, +} from '@/app/types/users'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import logger from '@/lib/logger'; +import { getApiBaseUrl, isHCaptchaEnabled } from '@/lib/envConstants'; +import { normalizeOAuthAccessToken } from '@/core/auth/oauth-session'; + +const isProduction = process.env.NODE_ENV === 'production'; + +const isTokenExpired = (exp?: number): boolean => { + if (!exp) return false; + return Date.now() / 1000 > exp; +}; + +const getValidUrl = (value?: string) => { + const url = value?.trim(); + + if (!url) { + return null; + } + + try { + return new URL(url).toString().replace(/\/$/, ''); + } catch { + return null; + } +}; + +const getAzureContainerAppsUrl = () => { + const appName = process.env.CONTAINER_APP_NAME; + const dnsSuffix = process.env.CONTAINER_APP_ENV_DNS_SUFFIX; + + if (!appName || !dnsSuffix || !appName.endsWith('-vertex-preview')) { + return null; + } + + return `https://${appName}.${dnsSuffix}`; +}; + +const azureContainerAppsUrl = getAzureContainerAppsUrl(); +const nextAuthUrl = getValidUrl(process.env.NEXTAUTH_URL); +const nextAuthUrlInternal = getValidUrl(process.env.NEXTAUTH_URL_INTERNAL); + +if (nextAuthUrl) { + process.env.NEXTAUTH_URL = nextAuthUrl; +} else { + delete process.env.NEXTAUTH_URL; +} + +if (nextAuthUrlInternal) { + process.env.NEXTAUTH_URL_INTERNAL = nextAuthUrlInternal; +} else { + delete process.env.NEXTAUTH_URL_INTERNAL; +} + +if (!process.env.NEXTAUTH_URL && azureContainerAppsUrl) { + process.env.NEXTAUTH_URL = azureContainerAppsUrl; + process.env.NEXTAUTH_URL_INTERNAL = + process.env.NEXTAUTH_URL_INTERNAL || azureContainerAppsUrl; + process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true'; +} + +const authSecret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET; +const configuredCookieDomain = + process.env.NEXTAUTH_COOKIE_DOMAIN?.trim() || undefined; +const getCookieDomain = () => { + if (!configuredCookieDomain) { + return undefined; + } + + const referenceUrl = process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL_INTERNAL; + if (!referenceUrl) { + return configuredCookieDomain; + } + + try { + const host = new URL(referenceUrl).hostname.toLowerCase(); + const normalizedDomain = configuredCookieDomain.replace(/^\./, '').toLowerCase(); + const hostMatches = + host === normalizedDomain || host.endsWith(`.${normalizedDomain}`); + + if (hostMatches) { + return configuredCookieDomain; + } + + logger.warn( + '[NextAuth] NEXTAUTH_COOKIE_DOMAIN does not match NEXTAUTH_URL host; disabling cookie domain override.', + { configuredCookieDomain, host } + ); + return undefined; + } catch { + logger.warn( + '[NextAuth] Invalid NEXTAUTH_URL while validating cookie domain; disabling cookie domain override.', + { configuredCookieDomain, referenceUrl } + ); + return undefined; + } +}; +const cookieDomain = getCookieDomain(); +const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, + path: '/', + secure: isProduction, + domain: cookieDomain, +}; + +if (isProduction && !authSecret) { + logger.error('[NextAuth] CRITICAL: NEXTAUTH_SECRET is missing in production environment!'); +} + +if (isProduction && !process.env.NEXTAUTH_URL && !process.env.AUTH_TRUST_HOST) { + process.env.AUTH_TRUST_HOST = 'true'; + logger.warn('[NextAuth] WARNING: NEXTAUTH_URL is missing. Dynamic host detection will be used.'); +} + +interface OAuthProfilePayload { + _id: string; + email: string; + firstName: string; + lastName: string; + userName?: string; + organization?: string; + privilege?: string; + profilePicture?: string; +} + +interface OAuthProfileResponse { + success: boolean; + message?: string; + data?: OAuthProfilePayload; +} + +const fetchOAuthProfile = async ( + accessToken: string +): Promise => { + try { + const profileUrl = `${getApiBaseUrl()}/users/profile/enhanced`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(profileUrl, { + method: 'GET', + cache: 'no-store', + signal: controller.signal, + headers: { + Accept: 'application/json', + Authorization: `JWT ${accessToken}`, + }, + }).finally(() => clearTimeout(timeoutId)); + + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as OAuthProfileResponse; + if (!payload?.success || !payload.data?._id) { + return null; + } + + return payload.data; + } catch (error) { + logger.error('Error fetching OAuth profile', { error }); + return null; + } +}; + +export const options: NextAuthOptions = { + secret: authSecret, + useSecureCookies: isProduction, + providers: [ + CredentialsProvider({ + id: 'credentials', + name: 'credentials', + credentials: { + userName: { label: 'Username', type: 'text' }, + password: { label: 'Password', type: 'password' }, + oauthToken: { label: 'OAuth Token', type: 'text' }, + oauthProvider: { label: 'OAuth Provider', type: 'text' }, + captchaToken: { label: 'Captcha Token', type: 'text' }, + }, + async authorize(credentials) { + const oauthToken = normalizeOAuthAccessToken( + typeof credentials?.oauthToken === 'string' ? credentials.oauthToken : '' + ); + + if (oauthToken) { + const profile = await fetchOAuthProfile(oauthToken); + + if (!profile) { + logger.warn('OAuth authorization failed: profile fetch returned null.'); + return null; + } + + // Use JWT decode to get extra fields if the profile doesn't have them + let decoded: DecodedToken | null = null; + try { + decoded = jwtDecode(oauthToken); + } catch { + decoded = null; + } + + return { + id: profile._id, + email: profile.email, + name: `${profile.firstName} ${profile.lastName}`.trim() || profile.email, + userName: profile.userName || decoded?.userName || profile.email, + accessToken: oauthToken, + organization: profile.organization || decoded?.organization || '', + privilege: profile.privilege || decoded?.privilege || '', + firstName: profile.firstName, + lastName: profile.lastName, + country: decoded?.country || '', + timezone: decoded?.timezone || '', + phoneNumber: decoded?.phoneNumber || '', + exp: decoded?.exp, + }; + } + + if (!credentials?.userName || !credentials?.password) { + logger.warn('Authorize call with missing credentials.'); + throw new Error('Username and password are required.'); + } + + const hcaptchaEnabled = isHCaptchaEnabled(); + const captchaToken = + typeof credentials.captchaToken === 'string' + ? credentials.captchaToken.trim() + : ''; + + if (hcaptchaEnabled && !captchaToken) { + logger.warn('Authorize call with missing CAPTCHA token.'); + throw new Error('CAPTCHA validation is required.'); + } + + try { + const loginResponse = (await users.loginWithDetails({ + userName: credentials.userName, + password: credentials.password, + captchaToken: hcaptchaEnabled ? captchaToken : undefined, + } as LoginCredentials)) as LoginResponse; + + if (loginResponse?.token) { + const decoded = jwtDecode(loginResponse.token); + + return { + id: decoded._id, + email: decoded.email, + name: `${decoded.firstName} ${decoded.lastName}`, + userName: decoded.userName, + accessToken: loginResponse.token, + organization: decoded.organization, + privilege: decoded.privilege, + firstName: decoded.firstName, + lastName: decoded.lastName, + country: decoded.country || '', + timezone: decoded.timezone || '', + phoneNumber: decoded.phoneNumber || '', + exp: decoded.exp, + }; + } + + logger.warn('Login API returned success status but no token.', { + userName: credentials.userName, + }); + throw new Error( + 'Authentication server returned an invalid response.' + ); + } catch (error) { + const errorMessage = getApiErrorMessage(error); + logger.error('Authentication error during login', { + userName: credentials.userName, + error: errorMessage, + }); + throw new Error(errorMessage); + } + }, + }), + ], + + cookies: { + sessionToken: { + name: isProduction + ? '__Secure-next-auth.session-token' + : 'next-auth.session-token', + options: cookieOptions, + }, + }, + + session: { + strategy: 'jwt', + maxAge: 24 * 60 * 60, // 24 hours + }, + + jwt: { + maxAge: 24 * 60 * 60, // 24 hours + }, + + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.accessToken = user.accessToken; + token.userName = user.userName; + token.organization = user.organization; + token.privilege = user.privilege; + token.firstName = user.firstName; + token.lastName = user.lastName; + token.country = user.country; + token.timezone = user.timezone; + token.phoneNumber = user.phoneNumber; + token.exp = user.exp; + } + return token; + }, + + async session({ session, token }) { + if (isTokenExpired(token.exp as number | undefined)) { + return { ...session, user: null }; + } + + if (token) { + session.user = { + ...session.user, + id: token.id as string, + accessToken: token.accessToken as string, + userName: token.userName as string, + organization: token.organization as string, + privilege: token.privilege as string, + firstName: token.firstName as string, + lastName: token.lastName as string, + country: token.country as string, + timezone: token.timezone as string, + phoneNumber: token.phoneNumber as string, + exp: token.exp, + }; + } + return session; + }, + }, + + pages: { + signIn: '/login', + error: '/auth-error', + }, + + debug: false, +}; diff --git a/src/vertex-template/app/api/auth/[...nextauth]/route.ts b/src/vertex-template/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..566e1e9c28 --- /dev/null +++ b/src/vertex-template/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,37 @@ +import NextAuth from 'next-auth'; +import { options } from './options'; +import type { NextRequest } from 'next/server'; + +const getRequestOrigin = (req: NextRequest) => { + const forwardedProto = req.headers.get('x-forwarded-proto') || 'https'; + const forwardedHost = req.headers.get('x-forwarded-host'); + const host = forwardedHost || req.headers.get('host'); + + if (!host) { + return null; + } + + return `${forwardedProto}://${host}`; +}; + +const handler = NextAuth(options); + +const setRuntimeAuthUrls = (req: NextRequest) => { + const requestOrigin = getRequestOrigin(req); + + if (requestOrigin) { + process.env.NEXTAUTH_URL = requestOrigin; + process.env.NEXTAUTH_URL_INTERNAL = requestOrigin; + process.env.AUTH_TRUST_HOST = process.env.AUTH_TRUST_HOST || 'true'; + } +}; + +export async function GET(req: NextRequest, context: { params: Record }) { + setRuntimeAuthUrls(req); + return handler(req, context); +} + +export async function POST(req: NextRequest, context: { params: Record }) { + setRuntimeAuthUrls(req); + return handler(req, context); +} diff --git a/src/vertex-template/app/api/cloudinary/upload/route.ts b/src/vertex-template/app/api/cloudinary/upload/route.ts new file mode 100644 index 0000000000..c441450e42 --- /dev/null +++ b/src/vertex-template/app/api/cloudinary/upload/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; +import { getCloudinaryName, getCloudinaryApiKey, getCloudinaryApiSecret } from '@/lib/envConstants'; + +export async function POST(req: NextRequest) { + try { + const formData = await req.formData(); + const file = formData.get('file') as File | null; + const requestedFolder = formData.get('folder') as string || 'feedback'; + const tags = formData.get('tags') as string || ''; + + const ALLOWED_FOLDERS = new Set(['feedback']); + const folder = ALLOWED_FOLDERS.has(requestedFolder) ? requestedFolder : 'feedback'; + + const VALID_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); + const MAX_BYTES = 2 * 1024 * 1024; // 2MB + + if (!file) { + return NextResponse.json({ success: false, error: 'No file provided' }, { status: 400 }); + } + + if (!VALID_TYPES.has(file.type)) { + return NextResponse.json({ success: false, error: 'Invalid file type' }, { status: 400 }); + } + + if (file.size > MAX_BYTES) { + return NextResponse.json({ success: false, error: 'File too large (max 2MB)' }, { status: 400 }); + } + + let cloudName: string; + let apiKey: string; + let apiSecret: string; + + try { + cloudName = getCloudinaryName(); + apiKey = getCloudinaryApiKey(); + apiSecret = getCloudinaryApiSecret(); + } catch (configError: unknown) { + const errMsg = configError instanceof Error ? configError.message : 'Cloudinary configuration is incomplete on the server.'; + return NextResponse.json({ + success: false, + error: errMsg + }, { status: 500 }); + } + + const timestamp = Math.round(new Date().getTime() / 1000).toString(); + + // Prepare parameters to sign (sorted alphabetically) + const paramsToSign: Record = { + folder, + timestamp, + }; + if (tags) { + paramsToSign.tags = tags; + } + + const sortedKeys = Object.keys(paramsToSign).sort(); + const paramString = sortedKeys + .map(key => `${key}=${paramsToSign[key]}`) + .join('&'); + + const stringToSign = paramString + apiSecret; + const signature = crypto + .createHash('sha1') + .update(stringToSign) + .digest('hex'); + + // Build the request body for Cloudinary + const cloudinaryFormData = new FormData(); + cloudinaryFormData.append('file', file); + cloudinaryFormData.append('api_key', apiKey); + cloudinaryFormData.append('timestamp', timestamp); + cloudinaryFormData.append('signature', signature); + cloudinaryFormData.append('folder', folder); + if (tags) { + cloudinaryFormData.append('tags', tags); + } + + const cloudinaryUrl = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`; + const response = await fetch(cloudinaryUrl, { + method: 'POST', + body: cloudinaryFormData, + signal: AbortSignal.timeout(15000), + }); + + const result = await response.json(); + + if (!response.ok) { + return NextResponse.json({ + success: false, + error: result.error?.message || 'Failed to upload to Cloudinary' + }, { status: response.status }); + } + + return NextResponse.json({ + success: true, + secure_url: result.secure_url, + public_id: result.public_id, + }); + } catch (error: unknown) { + const errMsg = error instanceof Error ? error.message : 'Server error during upload'; + return NextResponse.json({ + success: false, + error: errMsg + }, { status: 500 }); + } +} diff --git a/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts new file mode 100644 index 0000000000..007bf60540 --- /dev/null +++ b/src/vertex-template/app/api/devices/network-creation-requests/[id]/[action]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { networkService } from "@/core/services/network-service"; + +async function getAuthToken(): Promise { + const session = await getServerSession(options); + return (session as { user?: { accessToken?: string } })?.user?.accessToken || null; +} + +export async function PUT( + req: NextRequest, + { params }: { params: { id: string; action: string } } +) { + try { + const { id, action } = params; + const allowedActions = ["approve", "deny", "review"]; + + if (!allowedActions.includes(action)) { + return NextResponse.json({ message: "Invalid action" }, { status: 400 }); + } + + const token = await getAuthToken(); + if (!token) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + return NextResponse.json({ message: "Server configuration error" }, { status: 500 }); + } + + const body = await req.json(); + const notes = body.reviewer_notes || ""; + + const responseData = await networkService.updateNetworkRequestStatus( + id, + action, + notes, + token, + adminSecret + ); + + return NextResponse.json(responseData, { status: 200 }); + } catch (error: unknown) { + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error updating network request status in route handler: ${err.message}`); + + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} diff --git a/src/vertex-template/app/api/devices/network-creation-requests/route.ts b/src/vertex-template/app/api/devices/network-creation-requests/route.ts new file mode 100644 index 0000000000..0d0c742933 --- /dev/null +++ b/src/vertex-template/app/api/devices/network-creation-requests/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "../../auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { networkService } from "@/core/services/network-service"; +import type { NetworkRequestValues } from "@/components/features/networks/schema"; + +export async function GET() { + try { + const session = await getServerSession(options); + const token = (session as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!token) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + return NextResponse.json({ message: "Server configuration error" }, { status: 500 }); + } + + const data = await networkService.getNetworkCreationRequests(token, adminSecret); + + return NextResponse.json({ network_creation_requests: data, success: true }, { status: 200 }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "NOT_FOUND") { + return NextResponse.json({ message: "Resource not found" }, { status: 404 }); + } + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error fetching network requests in route handler: ${err.message}`); + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} + +export async function POST(req: NextRequest) { + try { + let body: NetworkRequestValues; + try { + body = (await req.json()) as NetworkRequestValues; + } catch { + return NextResponse.json({ message: "Invalid JSON payload" }, { status: 400 }); + } + + const data = await networkService.submitNetworkRequest(body); + return NextResponse.json(data, { status: 200 }); + } catch (error: unknown) { + const err = error as { message: string; status?: number; data?: unknown }; + logger.error(`Error submitting network request in route handler: ${err.message}`); + return NextResponse.json( + err.data || { message: err.message || "Internal server error" }, + { status: err.status || 500 } + ); + } +} diff --git a/src/vertex-template/app/api/log-to-slack/route.ts b/src/vertex-template/app/api/log-to-slack/route.ts new file mode 100644 index 0000000000..854585382d --- /dev/null +++ b/src/vertex-template/app/api/log-to-slack/route.ts @@ -0,0 +1,257 @@ +import { NextRequest, NextResponse } from 'next/server'; +import axios, { AxiosError } from 'axios'; + +interface ErrorResponse { + status?: number; + statusText?: string; + data?: unknown; +} + +interface ErrorContext { + status?: number; + method?: string; + url?: string; + error?: { + status?: number; + response?: { + status?: number; + }; + }; + [key: string]: unknown; +} + +interface LogBody { + level: 'info' | 'warn' | 'error'; + message: string; + context: ErrorContext; +} + +interface SlackTextBlock { + type: 'plain_text' | 'mrkdwn'; + text: string; + emoji?: boolean; +} + +interface SlackSectionBlock { + type: 'section'; + text?: SlackTextBlock; + fields?: SlackTextBlock[]; +} + +interface SlackHeaderBlock { + type: 'header'; + text: SlackTextBlock; +} + +interface SlackAttachment { + color: string; + blocks: SlackSectionBlock[]; +} + +interface SlackPayload { + blocks: (SlackHeaderBlock | SlackSectionBlock)[]; + attachments: SlackAttachment[]; +} + +// Get environment info +function getEnvironmentInfo() { + const env = + process.env.NEXT_PUBLIC_ALLOW_DEV_TOOLS ?? + process.env.NODE_ENV ?? + 'development'; + + const environment = + env === 'staging' || env === 'production' ? env : 'development'; + + return { environment }; +} + +// Check if an error should be ignored for Slack notifications +function shouldIgnoreError(context: ErrorContext): boolean { + const ignoredStatusCodes = [400, 404]; + + const status = context.status; + const errorStatus = context.error?.status; + const responseStatus = context.error?.response?.status; + + return ( + ignoredStatusCodes.includes(status ?? -1) || + ignoredStatusCodes.includes(errorStatus ?? -1) || + ignoredStatusCodes.includes(responseStatus ?? -1) + ); +} + +// Simple in-memory cache for deduplication +const errorCache = new Set(); +const ERROR_CACHE_TTL = 1000 * 60 * 15; // 15 minutes + +export async function POST(request: NextRequest) { + try { + const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL; + + if (!SLACK_WEBHOOK_URL) { + console.warn( + 'Slack webhook URL not configured - check your .env file' + ); + console.warn('Expected environment variable: SLACK_WEBHOOK_URL'); + return NextResponse.json( + { + success: true, + skipped: true, + reason: 'Slack webhook URL not configured', + }, + { status: 200 } + ); + } + + const body = (await request.json()) as LogBody; + const { level, message, context } = body; + + if (shouldIgnoreError(context)) { + return NextResponse.json({ + success: true, + skipped: true, + reason: 'Ignored status code', + }); + } + + const fingerprint = `${level}:${message}:${JSON.stringify(context)}`; + if (errorCache.has(fingerprint)) { + return NextResponse.json({ + success: true, + skipped: true, + reason: 'Deduplication', + }); + } + + errorCache.add(fingerprint); + setTimeout(() => { + errorCache.delete(fingerprint); + }, ERROR_CACHE_TTL); + + const { environment } = getEnvironmentInfo(); + + const emoji = + level === 'error' ? '🔴' : level === 'warn' ? '⚠️' : 'ℹ️'; + const color = + level === 'error' + ? '#FF0000' + : level === 'warn' + ? '#FFA500' + : '#36C5F0'; + + const slackPayload: SlackPayload = { + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: `${emoji} ${level.toUpperCase()} [${environment}]: ${message}`, + emoji: true, + }, + }, + ], + attachments: [ + { + color, + blocks: [ + { + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Environment:*\n${environment}`, + }, + { + type: 'mrkdwn', + text: `*Time:*\n${new Date().toISOString()}`, + }, + ], + }, + ], + }, + ], + }; + + // Add URL if available in context + if (context.url) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*URL:*\n${context.url}`, + }, + ], + }); + } + + // Add error details if available + if (context.status || context.method) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + fields: [ + { + type: 'mrkdwn', + text: `*Request Details:*\n${context.method ?? 'Unknown'}${ + context.status ? ` (${context.status})` : '' + }`, + }, + ], + }); + } + + // Add context details if available + const { ...filteredContext } = context; + if (Object.keys(filteredContext).length > 0) { + slackPayload.attachments[0].blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Context:*\n\`\`\`${JSON.stringify( + filteredContext, + null, + 2 + )}\`\`\``, + }, + }); + } + + const response = await axios.post(SLACK_WEBHOOK_URL, slackPayload, { + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.data !== 'ok') { + console.warn('⚠️ [SLACK API] Unexpected response from Slack:', response.data); + } + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + const err = error as AxiosError; + + console.error('Error sending to Slack:', { + message: err.message, + status: err.response?.status, + data: err.response?.data, + config: { + url: err.config?.url?.substring(0, 50) + '...', + method: err.config?.method, + }, + }); + + return NextResponse.json( + { + success: false, + error: err.message, + details: { + status: err.response?.status, + statusText: err.response?.statusText, + }, + }, + { status: 500 } + ); + } +} diff --git a/src/vertex-template/app/api/network/route.ts b/src/vertex-template/app/api/network/route.ts new file mode 100644 index 0000000000..63d8296dac --- /dev/null +++ b/src/vertex-template/app/api/network/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import { options } from "../auth/[...nextauth]/options"; +import logger from "@/lib/logger"; +import { CreateNetworkPayload, CreateNetworkResponse } from "@/core/apis/networks"; +import axios from "axios"; +import { networkFormSchema } from "@/components/features/networks/schema"; + +/** + * Retrieves the access token from the server-side session. + * @returns The access token or null if not found. + */ +async function getAuthToken(): Promise { + const session = await getServerSession(options); + + if (!session) { + logger.warn("getServerSession returned null"); + return null; + } + + const accessToken = (session as unknown as { user?: { accessToken?: string } })?.user?.accessToken; + + if (!accessToken) { + logger.warn(`Session found but no accessToken in session.user. Session keys: ${Object.keys(session).join(', ')}`); + } + + return accessToken || null; +} + +export async function POST(req: NextRequest) { + try { + logger.info("Sensor Manufacturer creation request received"); + + const body = await req.json(); + const validationResult = networkFormSchema.safeParse(body); + + if (!validationResult.success) { + logger.warn(`Invalid payload: ${JSON.stringify(validationResult.error.issues)}`); + return NextResponse.json( + { message: "Invalid payload", errors: validationResult.error.issues }, + { status: 400 } + ); + } + + const networkData = validationResult.data; + + const token = await getAuthToken(); + if (!token) { + logger.error("No JWT token found in session"); + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + } + + const authHeader = token.startsWith("JWT ") ? token : `JWT ${token}`; + + const adminSecret = process.env.ADMIN_SECRET; + if (!adminSecret) { + const envKeys = Object.keys(process.env).filter(key => key.includes('SECRET') || key.includes('ADMIN')); + logger.error(`ADMIN_SECRET is not configured on the server. NODE_ENV: ${process.env.NODE_ENV}, Related env keys: ${envKeys.join(', ')}`); + return NextResponse.json({ message: "Server configuration error." }, { status: 500 }); + } + + logger.debug("ADMIN_SECRET found, constructing payload"); + + const payload: CreateNetworkPayload = { ...networkData, admin_secret: adminSecret }; + + const backendApiUrl = `${process.env.NEXT_PUBLIC_API_URL}/users/networks`; + + const apiResponse = await axios.post(backendApiUrl, payload, { + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + "X-Auth-Type": "JWT", + }, + }); + + logger.info(`Sensor Manufacturer created successfully - ID: ${apiResponse.data.created_network?._id}`); + + return NextResponse.json(apiResponse.data, { status: 200 }); + } catch (error: unknown) { + const err = error as Error; + logger.error(`API route error: ${err.message}`); + + if (axios.isAxiosError(error) && error.response) { + const errorMessage = error.response.data?.message || error.response.data?.errors?.[0]?.message || 'Unknown error'; + logger.error(`Upstream API error - Status: ${error.response.status} ${error.response.statusText}, Message: ${errorMessage}`); + + return NextResponse.json( + error.response.data || { message: "An error occurred" }, + { status: error.response.status } + ); + } + + return NextResponse.json({ message: "An internal server error occurred." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/vertex-template/app/auth-error/page.tsx b/src/vertex-template/app/auth-error/page.tsx new file mode 100644 index 0000000000..551b586c57 --- /dev/null +++ b/src/vertex-template/app/auth-error/page.tsx @@ -0,0 +1,154 @@ +"use client" + +import { Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { AlertCircle, ArrowLeft, ShieldAlert, Settings, Mail, UserPlus, Link as LinkIcon } from 'lucide-react' +import ReusableButton from "@/components/shared/button/ReusableButton" +import { vertexConfig } from "@/vertex.config"; + +type ErrorType = { + title: string; + message: string; + icon: React.ReactNode; + description: string; +} + +const ERROR_MAP: Record = { + Configuration: { + title: 'Configuration Error', + message: 'There is a problem with the server configuration.', + description: 'This is usually caused by a missing environment variable or a mismatch in the NEXTAUTH_URL. Please check the server logs.', + icon: , + }, + AccessDenied: { + title: 'Access Denied', + message: 'You do not have permission to sign in.', + description: 'Your account might not have the necessary privileges, or your sign-in was restricted by a security policy.', + icon: , + }, + Verification: { + title: 'Link Expired', + message: 'The sign-in link is no longer valid.', + description: 'The verification token has either expired or has already been used. Please try signing in again to receive a new link.', + icon: , + }, + OAuthSignin: { + title: 'Sign-in Failed', + message: 'Could not start the external authentication process.', + description: 'An error occurred while trying to connect to the authentication provider. This could be a temporary network issue.', + icon: , + }, + OAuthCallback: { + title: 'Authentication Failed', + message: 'Error during the external authentication callback.', + description: 'The response from the authentication provider was invalid or could not be processed.', + icon: , + }, + OAuthCreateAccount: { + title: 'Account Creation Failed', + message: 'Could not create a new user account via OAuth.', + description: 'There was an issue creating your account profile. This might be due to missing required information from the provider.', + icon: , + }, + EmailCreateAccount: { + title: 'Account Creation Failed', + message: 'Could not create a new user account via email.', + description: 'An error occurred while setting up your account. Please try again or contact support.', + icon: , + }, + Callback: { + title: 'Callback Error', + message: 'An error occurred during the authentication callback.', + description: 'The authentication process failed at the final validation step.', + icon: , + }, + OAuthAccountNotLinked: { + title: 'Account Linked Elsewhere', + message: 'This email is already associated with another provider.', + description: 'To confirm your identity, please sign in using the provider you originally used for this email address.', + icon: , + }, + Default: { + title: 'Authentication Error', + message: 'An unexpected error occurred.', + description: 'Something went wrong during the sign-in process. Please try again or contact our support team if the issue persists.', + icon: , + }, +} + +function AuthErrorContent() { + const router = useRouter(); + const searchParams = useSearchParams() + const errorKey = searchParams.get('error') || 'Default' + const error = ERROR_MAP[errorKey] || ERROR_MAP.Default + + return ( +
+
+ {`${vertexConfig.org.name} +
+ +
+
+
+ {error.icon} +
+

{error.title}

+

{error.message}

+
+

+ {error.description} +

+ {errorKey !== 'Default' && ( +

+ Error Code: {errorKey} +

+ )} +
+
+ +
+ router.push("/login")} + > + Back to Login + + + Contact Support + +
+
+ +
+ © {new Date().getFullYear()} {vertexConfig.org.name}. All rights reserved. +
+
+ ) +} + +export default function AuthErrorPage() { + return ( + +
+
+ }> + +
+ ) +} diff --git a/src/vertex-template/app/changelog.md b/src/vertex-template/app/changelog.md new file mode 100644 index 0000000000..f6af1b535a --- /dev/null +++ b/src/vertex-template/app/changelog.md @@ -0,0 +1,9 @@ +# Vertex Template - Changelog + +## Version 1.0.0 +**Released:** June 2026 + +### Initial Release +- Created standalone boilerplate template for Vertex applications. +- Removed AirQo-specific branding, components, and hardcoded API dependencies. +- Added mock adapter for initial scaffolding. diff --git a/src/vertex-template/app/client-layout.tsx b/src/vertex-template/app/client-layout.tsx new file mode 100644 index 0000000000..0b3ab7eb5b --- /dev/null +++ b/src/vertex-template/app/client-layout.tsx @@ -0,0 +1,31 @@ +'use client'; + +import type React from 'react'; +import dynamic from 'next/dynamic'; + +const Toaster = dynamic( + () => import('@/components/shared/toast/ReusableToast').then(mod => mod.Toaster), + { ssr: false } +); + +import Providers from "./providers" + +import { Session } from "next-auth"; + +export default function ClientLayout({ + children, + session +}: { + children: React.ReactNode; + session: Session | null; +}) { + return ( + + + {children} + + + ) +} diff --git a/src/vertex-template/app/error.tsx b/src/vertex-template/app/error.tsx new file mode 100644 index 0000000000..fb9ca55f27 --- /dev/null +++ b/src/vertex-template/app/error.tsx @@ -0,0 +1,36 @@ +"use client" + +import { useEffect } from "react" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import logger from "@/lib/logger" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const router = useRouter() + useEffect(() => { + logger.error('Application error', error); + }, [error]) + + return ( +
+
+

Something went wrong!

+

+ We apologize for the inconvenience. The application encountered an unexpected error. +

+
+ + +
+
+
+ ) +} diff --git a/src/vertex-template/app/favicon-16x16.png b/src/vertex-template/app/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..7515f3dddcbaf72f5a81906d16010e1eeecd540c GIT binary patch literal 373 zcmV-*0gC>KP)Px$FG)l}R5(w~)4fXqVI08m?{g6np-_^b&{AL_6xs$pJ3|(j7B@+2~5S{Y!fD|H~%=xkk0Zi-Xwhf4c;AkBdXF$_D>Xl&BVfzxU9>J0> zATWr&zXqv!81M+TIVs`8fYk#izq){M5d9?!qru+Wq>$q~*eSSx;Q;!129gQTejDdS zsbJN_%n1~qJ9;n?f#oEGhWxWoK=lo7%JBKs0oY;o==>Ok{Pm=N|3dFsIE(ZRYE7CE Tk;@Bi00000NkvXXu0mjfrw^ZL literal 0 HcmV?d00001 diff --git a/src/vertex-template/app/favicon-32x32.png b/src/vertex-template/app/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..a3fa9b234b69645fdc4418de19850fa9aa4520bb GIT binary patch literal 730 zcmV<00ww*4P)Px%lu1NER9HvtmRm>^aTLaXv+laywgq>Myk!q1l1W7cQIQcslpn(RD4#Tpm?aa=$5d?c+U|<-& z-+bqPzB7uuQT3=$tte#k4@*^<3sG>%D%i6Taxx*^3JXC9%tH5j zxbhHQ4VZHxApo(xG#`%Dz`jk8lL3md*zD;!xZexco@h>lRO4ot1`wTMH#F_mF8T7M z;$bQO4BY8~wny-J6jTEU(*Uj2iUtAzWP#~^$!mXz;>;pXpQvl8^&GOUx zp~wx=ig+au-^Vlv&ol4^V!sg=K*p?UJ)EqCvLcW}sAqk&gaAnYzl=cZJ$USe$(i3F z3;{%Z$67d72W5p|vzikR%Lqg;2z^6v`2lo#VQO|Ugf2ja4R)5psamKgNnAV{LPoTI z2rk}*t~U^vk3hr(5Surwg62kT=Iyq`zI>_C;>6D&ct62~c6iYj396U?%bjrc05oie z%=F~Mqn((UgWKKE+5saI+LM?7t5?8{e_g3k|aop7@YCZ-JlBf1G2i+VH2-$Y7W#D!s zcNSz1_@-cVLJOkk4H-9z#VE;zWJ~Wa$rM1c;zsYM08$R590)t`3w0RR@FJVr7ytkO M07*qoM6N<$f&hp(bN~PV literal 0 HcmV?d00001 diff --git a/src/vertex-template/app/favicon.ico b/src/vertex-template/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..1a323901ae8e468bf12c6459592e887cc5248c9d GIT binary patch literal 15406 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ϣ%UY8`RWp~V-U_7 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 CLkb=M literal 0 HcmV?d00001 diff --git a/src/vertex-template/app/fonts/GeistVF.woff b/src/vertex-template/app/fonts/GeistVF.woff new file mode 100644 index 0000000000000000000000000000000000000000..1b62daacff96dad6584e71cd962051b82957c313 GIT binary patch literal 66268 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 >-Aun5A4Gp>4U0IQJ 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 G)cz05(PHWV literal 0 HcmV?d00001 diff --git a/src/vertex-template/app/globals.css b/src/vertex-template/app/globals.css new file mode 100644 index 0000000000..96cdca0c58 --- /dev/null +++ b/src/vertex-template/app/globals.css @@ -0,0 +1,543 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --vertex-ui-top-offset: 0px; + /* Colors */ + --background: 246 246 247; + --foreground: 28 29 32; + --primary: 20 95 255; + --primary-foreground: 255 255 255; + --secondary: 241 245 249; + --secondary-foreground: 15 23 42; + --muted: 241 245 249; + --muted-foreground: 91 105 124; + --accent: 241 245 249; + --accent-foreground: 15 23 42; + --destructive: 239 68 68; + --destructive-foreground: 255 255 255; + --border: 226 232 240; + --input: 226 232 240; + --ring: 20 95 255; + --card: 255 255 255; + --card-foreground: 15 23 42; + --popover: 255 255 255; + --popover-foreground: 15 23 42; + --text: 31 41 55; + --heading: 16 24 40; + + /* Primary color variations for highlights */ + --primary-50: 235 245 255; + /* Very light blue */ + --primary-100: 219 234 254; + /* Light blue */ + --primary-700: 3 105 161; + /* Dark blue */ + --primary-800: 7 89 133; + /* Darker blue */ + --primary-900: 12 74 110; + /* Darkest blue */ + + /* Layout */ + --radius: 0.5rem; + + /* Scrollbars */ + --scrollbar-track: 241 245 249; + --scrollbar-thumb: 100 116 139; + --scrollbar-hover: 51 65 85; + + /* Typography */ + --font-mono: + 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Source Code Pro', + monospace; + --heading-weight: 500; + --heading-line-height: 1.25; + --heading-letter-spacing: -0.025em; + } + + .dark { + --background: 29 31 32; + --foreground: 237 237 237; + --primary: 20 95 255; + --primary-foreground: 255 255 255; + --secondary: 30 41 59; + --secondary-foreground: 248 250 252; + --muted: 30 41 59; + --muted-foreground: 148 163 184; + --accent: 30 41 59; + --accent-foreground: 248 250 252; + --destructive: 220 38 38; + --destructive-foreground: 255 255 255; + --border: 51 65 85; + --input: 51 65 85; + --ring: 20 95 255; + --card: 29 31 32; + --card-foreground: 248 250 252; + --popover: 29 31 32; + --popover-foreground: 248 250 252; + --text: 249 250 251; + --heading: 255 255 255; + --scrollbar-track: 30 41 59; + --scrollbar-thumb: 148 163 184; + --scrollbar-hover: 51 65 85; + } +} + +/* ========================================================================== + BASE STYLES + ========================================================================== */ +html { + overflow: hidden; +} + +body, +html { + color: rgb(var(--foreground)); + background: rgb(var(--background)); + font-family: var(--font-inter), Arial, Helvetica, sans-serif; +} + +.dark body, +.dark html { + background: rgb(var(--background)); +} + +/* ========================================================================== + TYPOGRAPHY SYSTEM + ========================================================================== */ + +@layer base { + + /* Base heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + color: rgb(var(--heading)); + font-weight: var(--heading-weight); + line-height: var(--heading-line-height); + letter-spacing: var(--heading-letter-spacing); + margin: 0; + } + + /* Individual heading sizes */ + h1 { + font-size: 2.25rem; + line-height: 1.1; + letter-spacing: -0.04em; + } + + h2 { + font-size: 1.875rem; + line-height: 1.2; + letter-spacing: -0.03em; + } + + h3 { + font-size: 1.5rem; + line-height: 1.25; + letter-spacing: -0.02em; + } + + h4 { + font-size: 1.25rem; + line-height: 1.3; + } + + h5 { + font-size: 1.125rem; + line-height: 1.4; + } + + h6 { + font-size: 1rem; + line-height: 1.5; + } + + /* Links */ + a { + color: rgb(var(--primary)); + text-decoration: none; + transition: color 0.2s ease; + } + + button:focus:not(:focus-visible), + input:focus:not(:focus-visible), + textarea:focus:not(:focus-visible), + select:focus:not(:focus-visible) { + outline: 2px solid rgb(var(--ring)); + outline-offset: 2px; + } + + /* Lists */ + ul, + ol { + color: rgb(var(--foreground)); + line-height: 1.6; + } + + ul { + list-style-type: disc; + padding-left: 1.5rem; + } + + ol { + list-style-type: decimal; + padding-left: 1.5rem; + } + + /* Dropdown menus - remove list styles */ + [role='listbox'] { + list-style: none; + padding-left: 0; + } + + /* Toasts and alerts - remove list styles */ + [role='alert'], + [role='status'], + [aria-live='polite'], + [aria-live='assertive'] { + list-style: none !important; + } + + [role='alert'] *, + [role='status'] *, + [aria-live='polite'] *, + [aria-live='assertive'] * { + list-style: none !important; + } + + /* Blockquotes */ + blockquote { + border-left: 4px solid rgb(var(--border)); + padding-left: 1rem; + margin: 1.5rem 0; + color: rgb(var(--muted-foreground)); + font-style: italic; + } + + /* Code blocks */ + code { + font-family: var(--font-mono); + font-size: 0.875em; + background: rgb(var(--muted)); + color: rgb(var(--foreground)); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + border: 1px solid rgb(var(--border)); + } + + pre { + font-family: var(--font-mono); + background: rgb(var(--muted)); + color: rgb(var(--foreground)); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid rgb(var(--border)); + overflow-x: auto; + line-height: 1.5; + } + + pre code { + background: transparent; + border: none; + padding: 0; + } + + /* Form elements */ + input, + textarea, + select { + font-family: inherit; + font-size: inherit; + line-height: inherit; + } + + /* Focus styles - removed blue ring */ + button:focus, + input:focus, + textarea:focus, + select:focus { + outline: none; + } + + /* Alternative focus styles for accessibility */ + textarea:focus { + border-color: rgb(var(--ring)); + box-shadow: 0 0 0 1px rgb(var(--ring)); + } + + a:focus { + background-color: rgb(var(--accent)); + color: rgb(var(--accent-foreground)); + } + + /* Responsive typography */ + @media (max-width: 640px) { + h1 { + font-size: 1.875rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.25rem; + } + } +} + +/* ========================================================================== + UTILITIES + ========================================================================== */ + +@layer utilities { + .text-balance { + text-wrap: balance; + } + + /* Text color utilities */ + .text-primary { + color: rgb(var(--primary)); + } + + .text-primary-foreground { + color: rgb(var(--primary-foreground)); + } + + .text-secondary { + color: rgb(var(--secondary)); + } + + .text-secondary-foreground { + color: rgb(var(--secondary-foreground)); + } + + .text-muted { + color: rgb(var(--muted-foreground)); + } + + .text-accent { + color: rgb(var(--accent)); + } + + .text-accent-foreground { + color: rgb(var(--accent-foreground)); + } + + .text-destructive { + color: rgb(var(--destructive)); + } + + .text-destructive-foreground { + color: rgb(var(--destructive-foreground)); + } + + .text-foreground { + color: rgb(var(--foreground)); + } + + .text-background { + color: rgb(var(--background)); + } + + .text-text { + color: rgb(var(--text)); + } + + .text-heading { + color: rgb(var(--heading)); + } +} + +/* ========================================================================== + COMPONENTS + ========================================================================== */ + +/* Spinner */ +.secondary-main-loader { + width: 50px; + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(farthest-side, rgb(var(--primary)) 94%, #0000) top/8px 8px no-repeat, + conic-gradient(#0000 30%, rgb(var(--primary))); + -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 8px), #000 0); + animation: l13 1s infinite linear; +} + +@keyframes l13 { + 100% { + transform: rotate(1turn); + } +} + +/* ========================================================================== + SCROLLBARS + ========================================================================== */ + +/* Hide scrollbars */ +.custom-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Default scrollbar styles */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgb(var(--scrollbar-track)); +} + +::-webkit-scrollbar-thumb { + background: rgb(var(--scrollbar-thumb)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--scrollbar-hover)); +} + +/* Horizontal scrollbars */ +.country-scroll-bar { + overflow-x: auto; + overflow-y: hidden; + cursor: pointer; +} + +.country-scroll-bar::-webkit-scrollbar { + height: 4px; + display: none; +} + +.country-scroll-bar:hover::-webkit-scrollbar { + display: block; +} + +.map-scrollbar::-webkit-scrollbar { + height: 2px; +} + +/* ========================================================================== + FLOWBITE TOOLTIP STYLES + ========================================================================== */ + +/* Basic tooltip styling for Flowbite tooltips */ +.tooltip { + background-color: rgb(var(--card)) !important; + /* gray-800 */ + color: rgb(var(--card-foreground)) !important; + /* white */ + border-radius: 0.375rem !important; + /* rounded-md */ + padding: 0.5rem 0.75rem !important; + font-size: 0.875rem !important; + /* text-sm */ + font-weight: 500 !important; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1) !important; + z-index: 50 !important; + max-width: 20rem !important; + word-wrap: break-word !important; +} + +/* Arrow styling */ +.tooltip-arrow:before { + background-color: inherit !important; +} + +/* ========================================================================== + PRINT STYLES FOR LABELS (GLOBAL) + ========================================================================== */ + +@media print { + @page { + size: 4in 6in; + margin: 0; + } + + /* Hide everything */ + body * { + visibility: hidden !important; + } + + /* Remove parent containers completely from layout */ + body > *:not(.print-label) { + display: none !important; + } + + /* Make the print label the only element positioned */ + .print-label, .print-label * { + visibility: visible !important; + } + + .print-label { + position: absolute !important; + top: 0 !important; + left: 0 !important; + + width: 4in !important; + height: 6in !important; + padding: 0.15in !important; + + background: white !important; + border: 2px solid black !important; + box-sizing: border-box !important; + overflow: hidden !important; + } + + /* Prevent Tailwind layout constraints */ + html, body { + width: 100% !important; + height: auto !important; + margin: 0 !important; + padding: 0 !important; + background: white !important; + overflow: visible !important; + } +} + +@property --border-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +@keyframes spin-border { + to { --border-angle: 360deg; } +} + +@keyframes coach-fade-out { + 0%, 80% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-4px); pointer-events: none; } +} + +.rainbow-spin-border { + position: relative; + border-radius: 9999px; + padding: 2px; + background: conic-gradient( + from var(--border-angle), + #ef4444, #eab308, #22c55e, #6366f1, #ef4444 + ); + animation: spin-border 2s linear infinite; +} + +.rainbow-spin-border-inner { + border-radius: 9999px; + width: 100%; + height: 100%; +} + +.coach-mark { + animation: coach-fade-out 4s ease-in-out forwards; + animation-delay: 1s; +} \ No newline at end of file diff --git a/src/vertex-template/app/layout.tsx b/src/vertex-template/app/layout.tsx new file mode 100644 index 0000000000..f62aa6bab3 --- /dev/null +++ b/src/vertex-template/app/layout.tsx @@ -0,0 +1,92 @@ +import type React from 'react'; +import type { Metadata } from 'next'; +import './globals.css'; +import ClientLayout from './client-layout'; +import { Inter } from 'next/font/google'; +import { getServerSession } from "next-auth/next"; +import { options } from "@/app/api/auth/[...nextauth]/options"; +import logger from '@/lib/logger'; +import { vertexConfig } from '@/vertex.config'; + +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + +export const metadata: Metadata = { + title: { + template: `%s | ${vertexConfig.org.name}`, + default: vertexConfig.org.name, + }, + description: vertexConfig.org.name + " is a leading air quality monitoring platform.", + keywords: [ + 'air quality', + 'monitoring', + 'analytics', + 'environment', + 'data', + 'management', + 'device', + ], + authors: [{ name: `${vertexConfig.org.shortName} Team` }], + creator: vertexConfig.org.name, + publisher: vertexConfig.org.name, + openGraph: { + title: vertexConfig.org.name, + description: "Leading air quality device management platform", + type: 'website', + url: vertexConfig.org.websiteUrl, + images: [ + { + url: vertexConfig.org.logo, + width: 1200, + height: 630, + alt: vertexConfig.org.name, + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: vertexConfig.org.name, + description: "Leading air quality device management platform", + images: ['/favicon.ico'], + }, + icons: { + icon: '/favicon.ico', + apple: '/favicon.ico', + }, +}; + +const hexToRgbValues = (hex: string) => { + const normalizedHex = hex.replace('#', ''); + const r = parseInt(normalizedHex.slice(0, 2), 16); + const g = parseInt(normalizedHex.slice(2, 4), 16); + const b = parseInt(normalizedHex.slice(4, 6), 16); + return `${r} ${g} ${b}`; +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + let session = null; + try { + session = await getServerSession(options); + } catch (error) { + logger.error("Failed to fetch session:", { error }); + } + + const primaryRgb = hexToRgbValues(vertexConfig.org.primaryColor); + + return ( + + + + + + {children} + + ); +} diff --git a/src/vertex-template/app/login/page.tsx b/src/vertex-template/app/login/page.tsx new file mode 100644 index 0000000000..846ef749cd --- /dev/null +++ b/src/vertex-template/app/login/page.tsx @@ -0,0 +1,407 @@ +"use client" +import { CookieInfoBanner } from '@/components/features/auth/cookie-info-banner'; +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod" +import Link from "next/link" +import Image from "next/image" +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { useSearchParams } from "next/navigation"; +import { getSession, signIn } from "next-auth/react"; +import { Form, FormField } from "@/components/ui/form" +import { signUpUrl, forgotPasswordUrl } from "@/core/urls" +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField" +import ReusableButton from "@/components/shared/button/ReusableButton" +import { useBanner, BannerSlot } from "@/context/banner-context" +import { HCaptchaWidget, type HCaptchaWidgetHandle } from "@/components/ui/hcaptcha-widget" +import logger from "@/lib/logger" +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useAppDispatch } from "@/core/redux/hooks"; +import { isHCaptchaEnabled } from "@/lib/envConstants"; +import { + setLoggingOut, +} from "@/core/redux/slices/userSlice"; +import { getLastActiveModule } from "@/core/utils/userPreferences"; +import { ROUTE_LINKS } from "@/core/routes"; +import SocialAuthSection from "@/components/features/auth/social-auth-section"; +import { motion, AnimatePresence } from "framer-motion"; +import { vertexConfig } from "@/vertex.config"; + + +const loginSchema = z.object({ + userName: z.string().email({ message: "Please enter a valid email address" }), + password: z.string().min(8, { message: "Password must be at least 8 characters long" }), +}) + +export default function LoginPage() { + const { showBanner, hideBanner } = useBanner(); + const [isLoading, setIsLoading] = useState(false) + const [step, setStep] = useState<'email' | 'password'>('email'); + const [captchaToken, setCaptchaToken] = useState(""); + const captchaRef = useRef(null); + const hcaptchaEnabled = useMemo(() => isHCaptchaEnabled(), []); + const searchParams = useSearchParams(); + const callbackUrl = useMemo(() => { + const raw = searchParams.get("callbackUrl"); + if (!raw) return ""; + try { + const parsed = new URL(raw, window.location.origin); + if (parsed.origin !== window.location.origin) return ""; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return ""; + } + }, [searchParams]); + const waitForSession = useCallback(async () => { + const attempts = 8; + const delayMs = 150; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + const session = await getSession(); + if (session?.user) { + return session; + } + + if (attempt < attempts - 1) { + await new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); + } + } + + return null; + }, []); + + const form = useForm>({ + resolver: zodResolver(loginSchema), + defaultValues: { + userName: "", + password: "", + }, + }) + + const dispatch = useAppDispatch(); + const isMounted = useRef(true); + + const [platform, setPlatform] = useState<'win' | 'linux' | 'other' | null>(null); + const [isElectron, setIsElectron] = useState(false); + + useEffect(() => { + isMounted.current = true; + // Reset logout state when login page mounts + dispatch(setLoggingOut(false)); + + // OS Detection for download link and platform check + const userAgent = window.navigator.userAgent.toLowerCase(); + const isWin = userAgent.includes('win'); + const isLinux = userAgent.includes('linux'); + setIsElectron(userAgent.includes('electron')); + + if (isWin) { + setPlatform('win'); + } else if (isLinux) { + setPlatform('linux'); + } else { + setPlatform('other'); + } + + const authError = searchParams.get('error'); + if (authError === 'oauth_failed') { + showBanner({ + severity: 'error', + message: 'Social sign-in failed or was cancelled. Please try again.', + scoped: true, + }); + // Remove only the error flag while preserving callbackUrl and other safe params + const params = new URLSearchParams(searchParams.toString()); + params.delete('error'); + const nextUrl = params.toString() ? `/login?${params.toString()}` : '/login'; + window.history.replaceState({}, '', nextUrl); + } + + return () => { + isMounted.current = false; + }; + }, [dispatch, searchParams, showBanner]); + + + const onSubmit = useCallback(async (values: z.infer) => { + // If we are on the email step, just validate the email and move forward + if (step === 'email') { + const isEmailValid = await form.trigger('userName'); + if (isEmailValid) { + setStep('password'); + } + return; + } + + // On the password step, ensure password is also validated + const isPasswordValid = await form.trigger('password'); + if (!isPasswordValid) return; + + // If the user didn't provide the captchaToken + if (hcaptchaEnabled && captchaToken === "") { + showBanner({ + severity: 'error', + message: 'Please complete the CAPTCHA before signing in.', + scoped: true + }); + return; + } + + setIsLoading(true); + // Record the login start time so the Home page can compute login duration + // for the post-login feedback toast. sessionStorage survives the redirect + // but is cleared automatically when the tab closes. + if (typeof window !== 'undefined') { + sessionStorage.setItem('vertex_login_start_ts', String(Date.now())); + } + + // Read preference BEFORE authentication to avoid timing issues + const lastModule = getLastActiveModule(values.userName); + const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home'; + const isAuthRouteCallback = + callbackUrl.startsWith('/login') || + callbackUrl.startsWith('/auth-error') || + callbackUrl.startsWith('/forgot-password'); + const redirectUrl = + callbackUrl && !isAuthRouteCallback ? callbackUrl : fallbackUrl; + + try { + const result = await signIn("credentials", { + redirect: false, + userName: values.userName, + password: values.password, + captchaToken: hcaptchaEnabled ? captchaToken : undefined, + callbackUrl: redirectUrl, + }); + + if (!isMounted.current) return; + + if (result?.ok) { + const session = await waitForSession(); + if (!session?.user) { + throw new Error("Could not confirm session. Please try again."); + } + showBanner({ severity: 'success', message: 'Welcome back!', scoped: true }); + + window.location.replace(result.url || redirectUrl); + } else { + let message = "Login failed. Please check your credentials."; + if (result?.error) { + if (result.error === 'CredentialsSignin') { + message = "Invalid email or password. Please check your credentials."; + } else if (result.error.toLowerCase().includes('fetch')) { + message = "Network error. Please check your connection and try again."; + } else { + message = result.error; + } + } + throw new Error(message); + } + } catch (error) { + if (!isMounted.current) return; + const message = getApiErrorMessage(error); + logger.error("Sign-in failed", { error: message }); + showBanner({ severity: 'error', message, scoped: true }); + setCaptchaToken(""); + captchaRef.current?.reset(); + setIsLoading(false); + } + }, [callbackUrl, waitForSession, step, form, showBanner, captchaToken, hcaptchaEnabled]); + + return ( +
+ {/* Sticky Topbar */} +
+
+
+ {`${vertexConfig.org.name} +
+ + {!isElectron && platform === 'win' && ( +
+ + + + + Download for Windows + +
+ )} +
+
+ + {/* Main Content Area */} +
+
+
+
+

+ Deploy devices, + Share your data +

+

+ Add your devices and stream live air quality data through {vertexConfig.org.name}'s open data channels. +

+
+ +
+ {step === 'email' && ( + + )} + +
+ { + e.preventDefault(); + onSubmit(form.getValues()); + }} + className="space-y-5" + > + + {step === 'email' ? ( + + ( + + )} + /> + + Continue with email + + + ) : ( + + +
+
+ + Signing in as + + + {form.getValues('userName')} + +
+ +
+ + ( +
+
+ + + Forgot password? + +
+ +
+ )} + /> + {hcaptchaEnabled ? ( + setCaptchaToken(token)} + onExpire={() => setCaptchaToken("")} + /> + ) : null} + + {isLoading ? "Signing in..." : "Login"} + +
+ )} +
+
+ + +
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ +
+ ) +} diff --git a/src/vertex-template/app/not-found.tsx b/src/vertex-template/app/not-found.tsx new file mode 100644 index 0000000000..5883a0fc34 --- /dev/null +++ b/src/vertex-template/app/not-found.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { useAppSelector } from '@/core/redux/hooks'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import OopsIcon from '@/public/icons/Errors/OopsIcon'; + +/** + * 404 Not Found page component + * Automatically rendered by Next.js when a route is not found + */ +export default function NotFound() { + const router = useRouter(); + const activeGroup = useAppSelector((state) => state.user.activeGroup); + + const handleGoHome = () => { + if (activeGroup) { + router.push('/home'); + } else { + router.push('/'); + } + }; + + return ( +
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/app/page.tsx b/src/vertex-template/app/page.tsx new file mode 100644 index 0000000000..ae2e8e482f --- /dev/null +++ b/src/vertex-template/app/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from 'next/navigation'; + +export default function Page() { + // Fallback: This code should theoretically not be reached because middleware + // handles the routing for "/" before this page is rendered. + // We keep this as a safety net. + redirect('/home'); +} \ No newline at end of file diff --git a/src/vertex-template/app/providers.tsx b/src/vertex-template/app/providers.tsx new file mode 100644 index 0000000000..383d25a7a3 --- /dev/null +++ b/src/vertex-template/app/providers.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useEffect, useMemo } from "react"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { persistor, store } from "@/core/redux/store"; +import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; +import { AuthProvider } from "@/core/auth/authProvider"; +import dynamic from 'next/dynamic'; +import { ThemeProvider } from "@/components/theme-provider"; +import SessionLoadingState from "@/components/layout/loading/session-loading"; +import { QueryProvider } from "@/core/providers/query-provider"; +import { runClientCacheMaintenance } from "@/core/utils/clientCache"; +import { BannerProvider } from "@/context/banner-context"; + +const NetworkStatusBanner = dynamic( + () => import('@/components/features/network-status-banner'), + { ssr: false } +); + +import { Session } from "next-auth"; + +export default function Providers({ children, session }: { children: React.ReactNode, session: Session | null }) { + const cacheScope = useMemo(() => { + const user = session?.user as { id?: string; email?: string } | undefined; + const userId = typeof user?.id === "string" ? user.id.trim() : ""; + if (userId) return `id:${userId}`; + + const email = + typeof user?.email === "string" ? user.email.trim().toLowerCase() : ""; + if (email) return `email:${email}`; + + return "anon"; + }, [session?.user]); + + useEffect(() => { + runClientCacheMaintenance(); + }, []); + + return ( + + } persistor={persistor}> + + + + + {children} + + + {process.env.NODE_ENV !== "production" && ( + + )} + + + + + + ); +} diff --git a/src/vertex-template/app/types/clients.ts b/src/vertex-template/app/types/clients.ts new file mode 100644 index 0000000000..88db634699 --- /dev/null +++ b/src/vertex-template/app/types/clients.ts @@ -0,0 +1,26 @@ +import { UserDetails } from './users'; + + export interface AccessToken { + _id: string + permissions: string[] + scopes: string[] + expiredEmailSent: boolean + token: string + client_id: string + name: string + expires: string + createdAt: string + updatedAt: string + __v: number + } + + export interface Client { + _id: string + isActive: boolean + ip_addresses: string[] + name: string + client_secret: string + user: UserDetails + access_token: AccessToken + createdAt: string + } diff --git a/src/vertex-template/app/types/cohorts.ts b/src/vertex-template/app/types/cohorts.ts new file mode 100644 index 0000000000..77717c7276 --- /dev/null +++ b/src/vertex-template/app/types/cohorts.ts @@ -0,0 +1,106 @@ +export interface Cohort { + _id: string; + visibility: boolean; + cohort_tags: string[]; + cohort_codes: string[]; + name: string; + network: string; + groups: string[]; + numberOfDevices: number; + devices: Device[]; + createdAt?: string; +} + +export interface CohortsSummaryResponse { + success: boolean; + message: string; + meta: { + total: number; + limit: number; + skip: number; + page: number; + totalPages: number; + }; + cohorts: Cohort[]; +} + +export interface GroupCohortsResponse { + success: boolean; + message: string; + data: string[]; +} + +export interface PersonalUserCohortsResponse { + success: boolean; + message: string; + cohorts: string[]; +} + +export interface OriginalCohortResponse { + success: boolean; + message: string; + original_cohort: Cohort; +} + +interface Grid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + network: string; + long_name: string; + createdAt: string; + sites: Site[]; +} + +interface Site { + _id: string; + isOnline: boolean; + formatted_name: string; + location_name: string; + search_name: string; + city: string; + district: string; + county: string; + region: string; + country: string; + latitude: number; + longitude: number; + name: string; + approximate_latitude: number; + approximate_longitude: number; + generated_name: string; + data_provider: string; + description: string; + site_category: SiteCategory; + groups: string[]; + grids: Grid[]; + devices: Device[]; + airqlouds: unknown[]; + createdAt: string; + updatedAt?: string; +} + +interface SiteCategory { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + } + +interface Device { + _id: string; + name: string; + network: string; + groups: string[]; + authRequired: boolean; + serial_number: string; + api_code: string; + long_name: string; +} \ No newline at end of file diff --git a/src/vertex-template/app/types/devices.ts b/src/vertex-template/app/types/devices.ts new file mode 100644 index 0000000000..6e8bf3fe2a --- /dev/null +++ b/src/vertex-template/app/types/devices.ts @@ -0,0 +1,461 @@ +export interface DeviceSite { + _id: string; + visibility: boolean; + grids: string[]; + isOnline: boolean; + location_name: string; + search_name: string; + group: string; + name: string; + data_provider: string; + site_category: { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; + groups: string[]; + description?: string; + createdAt?: string; +} + +export interface DeviceGrid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + long_name: string; +} + +export interface DevicePreviousSite { + _id: string; + visibility?: boolean; + rawOnlineStatus?: boolean; + lastRawData?: string | null; + name?: string; + location_name?: string; + search_name?: string; + [key: string]: unknown; +} + +export interface DeviceCategoryHierarchy { + level: string; + category: string; + description: string; +} + +export interface DeviceCategoryRelationships { + type: string; + note: string; + belongs_to_equipment_category: string; + deployment_method: string; +} + +export interface DeviceCategories { + primary_category: string; + deployment_category: string; + is_mobile: boolean; + is_static: boolean; + is_lowcost: boolean; + is_bam: boolean; + is_gas: boolean; + all_categories: string[]; + category_hierarchy: DeviceCategoryHierarchy[]; + category_relationships: DeviceCategoryRelationships; +} + +export interface Device { + _id?: string; + id?: string; + name: string; + alias?: string; + mobility?: boolean; + network: string; + groups?: string[]; + serial_number: string; + authRequired?: boolean; + long_name?: string; + latitude?: number | undefined | null | string; + longitude?: number | undefined | null | string; + approximate_distance_in_km?: number; + bearing_in_radians?: number; + createdAt: string; + visibility?: boolean | undefined; + description?: string | undefined; + isPrimaryInLocation?: boolean; + nextMaintenance?: string; + deployment_date?: string; + mountType?: string; + isActive: boolean; + isOnline: boolean; + pictures?: unknown[]; + site_id?: string; + host_id?: string | null; + height?: number; + device_codes: string[]; + category: string; + device_categories?: DeviceCategories; + cohorts: unknown[]; + device_number?: number | undefined | string; + readKey?: string; + writeKey?: string; + phoneNumber?: string; + generation_version?: number | undefined | string; + generation_count?: number | undefined | string; + previous_sites?: Array; + grids?: DeviceGrid[]; + site?: DeviceSite[] | { + _id: string; + name: string; + }; + status?: "not deployed" | "deployed" | "recalled" | "online" | "offline"; + maintenance_status?: "good" | "due" | "overdue" | -1; + powerType?: "solar" | "alternator" | "mains"; + elapsed_time?: number; + // Additional properties for device ownership and status + owner_id?: string; + assigned_organization_id?: string; + claim_status?: "claimed" | "unclaimed"; + claimed_at?: string; + site_name?: string; // Optional site name for display purposes + [key: string]: unknown; + onlineStatusAccuracy?: { + accuracyPercentage?: number; + correctChecks?: number; + failurePercentage?: number; + lastCheck?: string; + lastCorrectCheck?: string; + lastSuccessfulUpdate?: string; + lastUpdate?: string; + successPercentage?: number; + successfulUpdates?: number; + totalAttempts?: number; + totalChecks?: number; + failedUpdates?: number; + incorrectChecks?: number; + lastFailureReason?: string; + lastIncorrectCheck?: string; + lastIncorrectReason?: string; + }; + api_code?: string; + lastActive?: string; + lastRawData?: string; + rawOnlineStatus?: boolean; + tags?: string[]; +} + +export interface PaginationMeta { + total: number; + totalResults: number; + limit: number; + skip: number; + page: number; + totalPages: number; + detailLevel: string; + usedCache: boolean; +} + +export interface DevicesSummaryResponse { + success: boolean; + message: string; + devices: Device[]; + meta: PaginationMeta; + cache_generated_at?: string; +} + +export interface DeviceAvailabilityResponse { + success: boolean; + message: string; + data: { + available: boolean; + status: "unclaimed" | "claimed" | "deployed"; + }; +} + +export interface DeviceClaimRequest { + device_name: string; + user_id: string; + claim_token?: string; + cohort_id?: string; +} + +export interface DeviceClaimResponse { + success: boolean; + message: string; + device: { + name: string; + long_name: string; + status: string; + claim_status: "claimed"; + claimed_at: string; + }; +} + +export interface BulkDeviceClaimItem { + device_name: string; + claim_token: string; +} + +export interface BulkDeviceClaimRequest { + user_id: string; + devices: BulkDeviceClaimItem[]; + cohort_id?: string; +} + +export interface BulkDeviceClaimResult { + device_name: string; + success?: boolean; + device?: { + name: string; + long_name: string; + status: string; + claim_status: "claimed"; + claimed_at: string; + }; + error?: string; +} + +export interface BulkDeviceClaimResponse { + success?: boolean; + message: string; + data: { + successful_claims: BulkDeviceClaimResult[]; + failed_claims: BulkDeviceClaimResult[]; + }; +} + +export interface MyDevicesResponse { + success: boolean; + message: string; + devices: Device[]; + total_devices: number; + deployed_devices: number; + deployed_devices_count?: number; +} + +export interface DeviceAssignmentRequest { + device_name: string; + organization_id: string; + user_id: string; +} + +export interface DeviceAssignmentResponse { + success: boolean; + message: string; + device: Device; +} + +export interface DeviceCreationResponse { + success: boolean; + message: string; + created_device: Device; +} + +export interface BulkImportDeviceResult { + serial_number: string; + long_name: string; + success: boolean; + created_device?: { + _id: string; + name: string; + network: string; + }; + error?: string; +} + +export interface BulkImportDeviceResponse { + success: boolean; + message: string; + imported: number; + failed: number; + total: number; + results: BulkImportDeviceResult[]; +} + +export interface DeviceUpdateGroupResponse { + success: boolean; + message: string; + updated_device?: Device; +} + +export interface MaintenanceLogData { + date: string; + tags: string[]; + description: string; + userName: string; + maintenanceType: "preventive" | "corrective"; + email: string; + firstName: string; + lastName: string; + user_id: string; +} + +export interface DecryptionRequest { + encrypted_key: string; + device_number: number; +} + +export interface DecryptedKeyResult { + encrypted_key: string; + device_number: string; + decrypted_key: string; +} + +export interface DecryptionResponse { + success: boolean; + message: string; + decrypted_keys: DecryptedKeyResult[]; +} + +export interface DevicePreparation { + device_name: string; + claim_token: string; + token_type: string; + qr_code_data: { + device_id: string; + claim_url: string; + platform: string; + token: string; + generated_at: string; + }; + qr_code_image: string; + label_data: { + device_name: string; + device_id: string; + claim_token: string; + instructions: string[]; + }; + shipping_prepared_at: string; +} + +export interface PrepareDeviceResponse { + success: boolean; + message: string; + device_preparation: DevicePreparation; +} + +export interface BulkPreparationResult { + device_name: string; + claim_token: string; + qr_code_data: unknown; + qr_code_image: string; +} + +export interface BulkPreparationFailure { + device_name: string; + error: string; +} + +export interface BulkPrepareResponse { + success: boolean; + message: string; + bulk_preparation_results: { + successful_preparations: BulkPreparationResult[]; + failed_preparations: BulkPreparationFailure[]; + summary: { + total_requested: number; + successful_count: number; + failed_count: number; + }; + }; +} + +export interface ShippingLabel { + device_name: string; + device_id: string; + device_long_name: string; + claim_token: string; + qr_code_image: string; + qr_code_data: unknown; + instructions: string[]; +} + +export interface GenerateLabelsResponse { + success: boolean; + message: string; + shipping_labels: { + labels: ShippingLabel[]; + total_labels: number; + }; +} + +export interface ShippingStatusDevice { + id?: string; + _id?: string; + name: string; + long_name: string; + claim_status: string; + claim_token: string | null; + shipping_prepared_at: string; + owner_id: string; + claimed_at: string; +} + +export interface ShippingStatusResponse { + success: boolean; + message: string; + shipping_status: { + devices: ShippingStatusDevice[]; + summary: { + total_devices: number; + prepared_for_shipping: number; + claimed_devices: number; + deployed_devices: number; + }; + categorized: { + prepared_for_shipping: unknown[]; + claimed_devices: unknown[]; + deployed_devices: unknown[]; + }; + }; +} + +export interface OrphanedDevice { + _id: string; + name: string; + long_name: string; + status: string; + isActive: boolean; + claim_status: string; + owner_id: string; + cohort_ids: string[]; + claimed_at: string; +} + +export interface OrphanedDevicesResponse { + success: boolean; + message: string; + devices: OrphanedDevice[]; + total_orphaned: number; + recommendation: string; +} + +export interface ShippingBatch { + _id: string; + batch_name: string; + device_count: number; + device_names: string[]; + createdAt: string; + updatedAt: string; +} + +export interface ShippingBatchesResponse { + success: boolean; + message: string; + batches: ShippingBatch[]; + meta: PaginationMeta; +} + +export interface ShippingBatchDetailsResponse { + success: boolean; + message: string; + batch: ShippingBatch & { + devices: ShippingStatusDevice[]; + }; +} diff --git a/src/vertex-template/app/types/export.ts b/src/vertex-template/app/types/export.ts new file mode 100644 index 0000000000..f55a618ba2 --- /dev/null +++ b/src/vertex-template/app/types/export.ts @@ -0,0 +1,17 @@ +import { Option } from "@/components/ui/multi-select" + +export type ExportType = 'sites' | 'devices' | 'airqlouds' | 'regions' + +export interface FormData { + startDateTime: string; + endDateTime: string; + sites?: Option[]; + devices?: Option[]; + cities?: Option[]; + regions?: Option[]; + pollutants?: Option[]; + frequency: string + fileType: string + outputFormat: string + dataType: string +} diff --git a/src/vertex-template/app/types/grids.ts b/src/vertex-template/app/types/grids.ts new file mode 100644 index 0000000000..b918fd882c --- /dev/null +++ b/src/vertex-template/app/types/grids.ts @@ -0,0 +1,40 @@ +import { Position } from "@/core/redux/slices/gridsSlice"; +import { Site } from "./sites"; +import { Group } from "./groups"; + +export interface CreateGrid { + name: string; + admin_level: string; + shape: { + type: "MultiPolygon" | "Polygon"; + coordinates: Position[][] | Position[][][]; + }; + network: string; +} + +export interface Grid { + _id: string; + visibility: boolean; + name: string; + admin_level: string; + network: string; + long_name: string; + createdAt: string; + sites: Site[]; + numberOfSites: number; + groups?: Group[]; +} + +export interface GridsSummaryResponse { + success: boolean; + message: string; + meta: { + total: number; + limit: number; + skip: number; + page: number; + totalPages: number; + nextPage?: string; + }; + grids: Grid[]; +} diff --git a/src/vertex-template/app/types/groups.ts b/src/vertex-template/app/types/groups.ts new file mode 100644 index 0000000000..25516da195 --- /dev/null +++ b/src/vertex-template/app/types/groups.ts @@ -0,0 +1,29 @@ +import { UserDetails } from "@/app/types/users"; + +export interface Group { + _id: string + grp_status: "ACTIVE" | "INACTIVE" + grp_profile_picture: string + grp_title: string + grp_description: string + grp_website: string + grp_industry: string + grp_country: string + grp_timezone: string + createdAt: string + numberOfGroupUsers: number + grp_users: UserDetails[] + grp_manager: UserDetails + cohorts?: string[] + organization_slug?: string + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + } +} + +export interface CohortGroupsResponse { + success: boolean; + message: string; + groups: Group[]; +} diff --git a/src/vertex-template/app/types/layout.ts b/src/vertex-template/app/types/layout.ts new file mode 100644 index 0000000000..8bfcf6e828 --- /dev/null +++ b/src/vertex-template/app/types/layout.ts @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export interface LayoutProps { + children: ReactNode; +} diff --git a/src/vertex-template/app/types/roles.ts b/src/vertex-template/app/types/roles.ts new file mode 100644 index 0000000000..76a1dc4b74 --- /dev/null +++ b/src/vertex-template/app/types/roles.ts @@ -0,0 +1,39 @@ +export interface Permission { + _id: string; + permission: string; + description: string; + } + +export interface Group { + _id: string; + grp_status: string; + grp_profile_picture: string; + grp_title: string; + grp_description: string; + grp_website: string; + grp_industry: string; + grp_country: string; + grp_timezone: string; + grp_manager: string; + grp_manager_username: string; + grp_manager_firstname: string; + grp_manager_lastname: string; + createdAt: string; + updatedAt: string; + __v: number; + } + +export interface Role { + _id: string; + role_status: string; + role_name: string; + role_permissions: Permission[]; + group?: Group; + } + +export interface RolesResponse { + success: boolean; + message: string; + roles: Role[]; + } + \ No newline at end of file diff --git a/src/vertex-template/app/types/sites.ts b/src/vertex-template/app/types/sites.ts new file mode 100644 index 0000000000..7cbd22e0a1 --- /dev/null +++ b/src/vertex-template/app/types/sites.ts @@ -0,0 +1,104 @@ +import {Device as DeviceType} from "./devices"; + +export interface Site { + _id?: string; + nearest_tahmo_station?: { + id: number; + code: string | null; + longitude: number; + latitude: number; + timezone: string | null; + }; + images?: unknown[]; + groups?: string[]; + site_codes?: string[]; + site_tags?: string[]; + isOnline?: boolean; + rawOnlineStatus?: boolean; + formatted_name?: string; + location_name?: string; + search_name?: string; + parish?: string; + village?: string; + sub_county?: string; + city?: string; + district?: string; + county?: string; + region?: string; + country?: string; + latitude?: number; + longitude?: number; + name?: string; + network?: string; + approximate_latitude?: number; + approximate_longitude?: number; + bearing_in_radians?: number; + approximate_distance_in_km?: number; + lat_long?: string; + generated_name?: string; + altitude?: number; + data_provider?: string; + description?: string; + weather_stations?: Array<{ + code: string; + name: string; + country: string; + longitude: number; + latitude: number; + timezone: string; + distance: number; + _id: string; + }>; + createdAt?: string; + lastActive?: string; + grids?: Array<{ + _id: string; + name: string; + admin_level: string; + visibility: boolean; + }>; + devices?: DeviceType[]; + airqlouds?: unknown[]; + site_category?: { + tags: string[]; + area_name: string; + category: string; + highway: string; + landuse: string; + latitude: number; + longitude: number; + natural: string; + search_radius: number; + waterway: string; + }; + geometry?: { + bounds: { + northeast: { lat: number; lng: number }; + southwest: { lat: number; lng: number }; + }; + location: { lat: number; lng: number }; + location_type: string; + viewport: { + northeast: { lat: number; lng: number }; + southwest: { lat: number; lng: number }; + }; + }; + landform_270?: number; + landform_90?: number; + aspect?: number; + distance_to_nearest_road?: number; + distance_to_nearest_secondary_road?: number; + distance_to_nearest_unclassified_road?: number; + distance_to_nearest_residential_road?: number; + share_links?: Record; +} + +export interface SiteDevice { + name: string; + description?: string; + site?: string; + isPrimary: boolean; + isCoLocated: boolean; + registrationDate: string; + deploymentStatus: "Deployed" | "Pending" | "Removed"; +} diff --git a/src/vertex-template/app/types/userStats.ts b/src/vertex-template/app/types/userStats.ts new file mode 100644 index 0000000000..131cbda9b3 --- /dev/null +++ b/src/vertex-template/app/types/userStats.ts @@ -0,0 +1,25 @@ +export interface User { + userName?: string + email?: string + firstName?: string + lastName?: string + _id?: string + } + + export interface UserCategory { + number: number + details: User[] + } + + export interface UserStats { + users: UserCategory + active_users: UserCategory + api_users: UserCategory + } + + export interface UserStatsResponse { + success: boolean + message: string + users_stats: UserStats + } + \ No newline at end of file diff --git a/src/vertex-template/app/types/users.ts b/src/vertex-template/app/types/users.ts new file mode 100644 index 0000000000..6a49064b22 --- /dev/null +++ b/src/vertex-template/app/types/users.ts @@ -0,0 +1,130 @@ +export interface Permission { + _id: string; + permission: string; + network_id?: string; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface Role { + _id: string; + role_name: string; + role_permissions: Permission[]; +} + +export interface Network { + net_name: string; + _id: string; + role: Role; + userType: string; + createdAt?: string; + status?: string; +} + +export interface Client { + _id: string; + name: string; + user_id: string; + client_secret: string; + createdAt: string; + updatedAt: string; + isActive: boolean; +} + +export interface Group { + grp_title: string; + _id: string; + createdAt: string; + status: string; + role: Role; + userType: string; + onboarding_checklist?: { + is_dismissed: boolean; + completed_steps: string[]; + }; +} + +export interface UserDetails { + _id: string; + firstName: string; + lastName: string; + lastLogin: string; + isActive?: boolean; + loginCount?: number; + userName: string; + email: string; + verified?: boolean; + analyticsVersion?: number; + country?: string | null; + privilege?: string; + website?: string | null; + category?: string | null; + organization?: string; + long_organization?: string; + rateLimit: number | null; + jobTitle?: string | null; + description?: string | null; + timezone?: string | null; + profilePicture: string | null; + phoneNumber: string | null; + updatedAt: string; + networks?: Network[]; + clients?: Client[]; + groups?: Group[]; + permissions?: Permission[]; + createdAt: string; + my_networks?: string[]; + my_groups?: string[]; + group_ids?: string[]; + cohort_ids?: string[]; + iat?: number; +} + +export interface LoginResponse { + success: boolean; + message: string; + token: string; + _id: string; + userName: string; + email: string; +} + +export interface UserDetailsResponse { + success: boolean; + message: string; + users: UserDetails[]; +} + +export interface LoginCredentials { + userName: string; + password: string; + captchaToken?: string; +} + +export interface DecodedToken { + _id: string; + firstName: string; + lastName: string; + userName: string; + email: string; + organization: string; + long_organization: string; + privilege: string; + country: string | null; + profilePicture: string | null; + description: string | null; + timezone: string | null; + phoneNumber: string | null; + createdAt: string; + updatedAt: string; + rateLimit: number | null; + lastLogin: string; + iat: number; + exp?: number; +} + +export interface CurrentRole { + role_name: string; + permissions: string[]; +} diff --git a/src/vertex-template/components.json b/src/vertex-template/components.json new file mode 100644 index 0000000000..a2a87a40b0 --- /dev/null +++ b/src/vertex-template/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "core": "@/core" + }, + "iconLibrary": "lucide" +} diff --git a/src/vertex-template/components/features/auth/cookie-info-banner.tsx b/src/vertex-template/components/features/auth/cookie-info-banner.tsx new file mode 100644 index 0000000000..3c67976380 --- /dev/null +++ b/src/vertex-template/components/features/auth/cookie-info-banner.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { COOKIE_POLICY_URL } from '@/lib/envConstants'; +import ReusableButton from '@/components/shared/button/ReusableButton'; + +export function CookieInfoBanner() { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + const cookiesAccepted = localStorage.getItem('vertex_cookies_accepted'); + if (!cookiesAccepted) { + setIsVisible(true); + } + }, []); + + const handleDismiss = () => { + localStorage.setItem('vertex_cookies_accepted', 'true'); + setIsVisible(false); + }; + + if (!isVisible) { + return null; + } + + return ( +
+
+

+ AirQo uses cookies to deliver and enhance the quality of its services and to analyze traffic. + {' '} + + Learn more about our cookie policy + +

+ + OK, got it + +
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/auth/social-auth-section.tsx b/src/vertex-template/components/features/auth/social-auth-section.tsx new file mode 100644 index 0000000000..f00f7c115c --- /dev/null +++ b/src/vertex-template/components/features/auth/social-auth-section.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { FaGithub, FaLinkedinIn } from 'react-icons/fa'; +import { FaXTwitter } from 'react-icons/fa6'; +import { FcGoogle } from 'react-icons/fc'; +import { + buildOAuthInitiationUrl, + getLastUsedOAuthProvider, + resolveOAuthRedirectAfterUrl, + setLastUsedOAuthProvider, + type SupportedSocialAuthProvider, +} from '@/core/auth/oauth-session'; +import { cn } from '@/lib/utils'; +import { useBanner } from '@/context/banner-context'; +import { getLastActiveModule } from '@/core/utils/userPreferences'; + +interface SocialAuthSectionProps { + mode: 'login' | 'register'; + disabled?: boolean; + className?: string; + callbackUrl?: string | null; +} + +const SOCIAL_PROVIDERS: Array<{ + provider: SupportedSocialAuthProvider; + label: string; + Icon: React.ComponentType<{ className?: string }>; + iconClassName?: string; +}> = [ + { + provider: 'google', + label: 'Google', + Icon: FcGoogle, + }, + { + provider: 'github', + label: 'GitHub', + Icon: FaGithub, + iconClassName: 'text-slate-950 dark:text-white', + }, + { + provider: 'linkedin', + label: 'LinkedIn', + Icon: FaLinkedinIn, + iconClassName: 'text-[#0A66C2]', + }, + { + provider: 'twitter', + label: 'X', + Icon: FaXTwitter, + iconClassName: 'text-slate-950 dark:text-white', + }, +]; + +export default function SocialAuthSection({ + mode, + disabled = false, + className, + callbackUrl, +}: SocialAuthSectionProps) { + const { showBanner } = useBanner(); + const actionLabel = mode === 'register' ? 'Continue with' : 'Sign in with'; + const lastModule = getLastActiveModule(); + const fallbackUrl = lastModule === 'admin' ? '/admin/networks' : '/home'; + const redirectPath = callbackUrl || fallbackUrl; + const [lastUsedProvider, setLastUsedProvider] = + useState(null); + + useEffect(() => { + setLastUsedProvider(getLastUsedOAuthProvider()); + }, []); + + const orderedProviders = lastUsedProvider + ? [ + ...SOCIAL_PROVIDERS.filter( + ({ provider }) => provider === lastUsedProvider + ), + ...SOCIAL_PROVIDERS.filter( + ({ provider }) => provider !== lastUsedProvider + ), + ] + : SOCIAL_PROVIDERS; + + const handleSocialAuth = useCallback( + (provider: SupportedSocialAuthProvider) => { + if (typeof window === 'undefined' || disabled) return; + + const redirectAfter = resolveOAuthRedirectAfterUrl(redirectPath); + const queryParams: Record = {}; + + if (redirectAfter) { + queryParams.redirect_after = redirectAfter; + } + + + try { + setLastUsedOAuthProvider(provider); + window.location.replace(buildOAuthInitiationUrl(provider, queryParams)); + } catch (error) { + showBanner({ + severity: 'error', + message: `Social sign-in unavailable. Please try again in a moment.`, + scoped: true, + }); + console.error(`Failed to start ${provider} OAuth flow:`, error); + } + }, + [disabled, redirectPath, showBanner] + ); + + return ( +
+
+ {orderedProviders.map(({ provider, label, Icon, iconClassName }) => { + const isLastUsed = provider === lastUsedProvider; + + return ( + + ); + })} +
+ +
+ + + Or + + +
+
+ ); +} diff --git a/src/vertex-template/components/features/claim/DeviceEntryRow.tsx b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx new file mode 100644 index 0000000000..d991fde523 --- /dev/null +++ b/src/vertex-template/components/features/claim/DeviceEntryRow.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React from 'react'; +import { AqXClose } from '@airqo/icons-react'; +import ReusableInputField from '@/components/shared/inputfield/ReusableInputField'; + +interface DeviceEntryRowProps { + deviceName: string; + claimToken: string; + onDeviceNameChange: (value: string) => void; + onClaimTokenChange: (value: string) => void; + onRemove: () => void; + deviceNameError?: string; + claimTokenError?: string; + index: number; + showRemove?: boolean; +} + +export const DeviceEntryRow: React.FC = ({ + deviceName, + claimToken, + onDeviceNameChange, + onClaimTokenChange, + onRemove, + deviceNameError, + claimTokenError, + index, + showRemove = true, +}) => { + return ( +
+
+ + {index + 1} + +
+
+ onDeviceNameChange(e.target.value)} + error={deviceNameError} + required + /> + onClaimTokenChange(e.target.value)} + error={claimTokenError} + required + /> +
+ {showRemove && ( + + )} +
+ ); +}; diff --git a/src/vertex-template/components/features/claim/FileUploadParser.tsx b/src/vertex-template/components/features/claim/FileUploadParser.tsx new file mode 100644 index 0000000000..a6cb358057 --- /dev/null +++ b/src/vertex-template/components/features/claim/FileUploadParser.tsx @@ -0,0 +1,352 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { AqUploadCloud02 } from '@airqo/icons-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import ReusableToast from '@/components/shared/toast/ReusableToast'; + +export interface ParsedFileData { + headers: string[]; + data: unknown[][]; + fileName: string; +} + +interface BulkClaimColumnMapperProps { + filePreview: ParsedFileData; + onConfirm: (deviceNameColumn: number, claimTokenColumn: number) => void; + onCancel: () => void; +} + +export const BulkClaimColumnMapper: React.FC = ({ + filePreview, + onConfirm, + onCancel, +}) => { + const [deviceNameColumn, setDeviceNameColumn] = useState(() => { + // Auto-detect device name column + const index = filePreview.headers.findIndex(h => + /device.*name|name|device.*id|device/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : 0; + }); + + const [claimTokenColumn, setClaimTokenColumn] = useState(() => { + // Auto-detect claim token column + const index = filePreview.headers.findIndex(h => + /claim.*token|token|claim/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : filePreview.headers.length > 1 ? 1 : 0; + }); + + const handleConfirm = () => { + if (deviceNameColumn === claimTokenColumn) { + ReusableToast({ + message: 'Device name and claim token must be in different columns', + type: 'ERROR', + }); + return; + } + onConfirm(deviceNameColumn, claimTokenColumn); + }; + + return ( +
+
+
+

+ Map Columns for Device Claiming +

+

+ File: {filePreview.fileName} +

+
+ +
+

+ Please select which columns contain the device names and claim tokens: +

+ + {/* Column Selectors */} +
+
+ + +
+
+ + +
+
+ + {/* Preview Table */} +
+ + + + {filePreview.headers.map((header, index) => ( + + ))} + + + + {filePreview.data.slice(1, 6).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} + {index === deviceNameColumn && ( + (Device) + )} + {index === claimTokenColumn && ( + (Token) + )} +
+ {String(cell || '')} +
+
+

+ Showing first 5 rows as preview +

+
+ +
+ + Cancel + + Import Devices +
+
+
+ ); +}; + +interface FileUploadParserProps { + onFilesParsed: (devices: Array<{ device_name: string; claim_token: string }>) => void; + variant?: 'button' | 'dropzone'; +} + +export const FileUploadParser: React.FC = ({ onFilesParsed, variant = 'button' }) => { + const [isImporting, setIsImporting] = useState(false); + const [filePreview, setFilePreview] = useState(null); + const fileInputRef = useRef(null); + + const handleFileImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { + ReusableToast({ + message: 'Invalid file format. Please upload a CSV or Excel file.', + type: 'ERROR', + }); + e.target.value = ''; + return; + } + + if (file.size > 5 * 1024 * 1024) { + ReusableToast({ message: 'File too large. Maximum size is 5MB.', type: 'ERROR' }); + e.target.value = ''; + return; + } + + setIsImporting(true); + + try { + let parsedData: unknown[][] = []; + let headers: string[] = []; + + if (fileExtension === 'csv') { + const Papa = (await import('papaparse')).default; + Papa.parse(file, { + complete: (results) => { + parsedData = results.data as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } + setIsImporting(false); + }, + error: (error) => { + ReusableToast({ + message: `Error parsing CSV: ${error.message}`, + type: 'ERROR', + }); + setIsImporting(false); + }, + }); + } else { + const XLSX = await import('xlsx'); + const reader = new FileReader(); + reader.onload = (evt) => { + const bstr = evt.target?.result; + const workbook = XLSX.read(bstr, { type: 'binary' }); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } + setIsImporting(false); + }; + reader.onerror = () => { + ReusableToast({ message: 'Error reading Excel file', type: 'ERROR' }); + setIsImporting(false); + }; + reader.readAsBinaryString(file); + } + } catch { + ReusableToast({ + message: 'Error importing file. Please ensure papaparse and xlsx libraries are installed.', + type: 'ERROR', + }); + setIsImporting(false); + } + + e.target.value = ''; + }; + + const handleConfirmImport = (deviceNameColumn: number, claimTokenColumn: number) => { + if (!filePreview) return; + + const devices = filePreview.data + .slice(1) + .map((row) => { + const deviceName = row[deviceNameColumn]; + const claimToken = row[claimTokenColumn]; + return { + device_name: + typeof deviceName === 'string' + ? deviceName.trim() + : String(deviceName || '').trim(), + claim_token: + typeof claimToken === 'string' + ? claimToken.trim() + : String(claimToken || '').trim(), + }; + }) + .filter((device) => device.device_name.length > 0 && device.claim_token.length > 0); + + if (devices.length === 0) { + ReusableToast({ + message: 'No valid devices found in the selected columns', + type: 'ERROR', + }); + return; + } + + onFilesParsed(devices); + setFilePreview(null); + }; + + const handleCancelImport = () => { + setFilePreview(null); + }; + + return ( + <> + {filePreview && ( + + )} + + + + {variant === 'dropzone' ? ( +
!isImporting && fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center cursor-pointer transition-colors w-full ${isImporting + ? 'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed' + : 'border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 hover:border-blue-500 dark:hover:border-blue-400' + }`} + > +
+ +
+ + {isImporting ? 'Processing file...' : 'Click to upload or drag and drop'} + + + CSV or Excel files (max 5MB) + +
+ ) : ( + fileInputRef.current?.click()} + disabled={isImporting} + loading={isImporting} + variant="outlined" + > + Import File + + )} + + ); +}; diff --git a/src/vertex-template/components/features/claim/claim-device-modal.tsx b/src/vertex-template/components/features/claim/claim-device-modal.tsx new file mode 100644 index 0000000000..86e8485d85 --- /dev/null +++ b/src/vertex-template/components/features/claim/claim-device-modal.tsx @@ -0,0 +1,984 @@ +// ClaimDeviceModal.tsx +'use client'; + +import React, { useState, useCallback, useEffect } from 'react'; +import { Loader2, AlertCircle } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import ReusableDialog from '@/components/shared/dialog/ReusableDialog'; + +import { useAppSelector } from '@/core/redux/hooks'; +import { useRouter } from 'next/navigation'; +import { useClaimDevice, useBulkClaimDevices } from '@/core/hooks/useDevices'; +import { useUserContext } from '@/core/hooks/useUserContext'; +import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'; +import { + useGroupCohorts, + useVerifyCohort, + useAssignCohortsToGroup, + useAssignCohortsToUser, +} from '@/core/hooks/useCohorts'; +import dynamic from 'next/dynamic'; + +const StepLoader = () => ( +
+
+
+); + +const BulkClaimResults = dynamic(() => import('./steps/BulkClaimResults').then(mod => mod.BulkClaimResults), { loading: StepLoader }); +const CohortImportStep = dynamic(() => import('./steps/CohortImportStep'), { loading: StepLoader }); +const ManualInputStep = dynamic(() => import('./steps/ManualInputStep'), { loading: StepLoader }); +const QRScanStep = dynamic(() => import('./steps/QRScanStep'), { loading: StepLoader }); +const SuccessStep = dynamic(() => import('./steps/SuccessStep'), { loading: StepLoader }); +const BulkConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.BulkConfirmationStep), { loading: StepLoader }); +const CohortConfirmStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.CohortConfirmStep), { loading: StepLoader }); +const ConfirmationStep = dynamic(() => import('./steps/ConfirmationSteps').then(mod => mod.ConfirmationStep), { loading: StepLoader }); +const MethodSelectStep = dynamic(() => import('./steps/MethodSelectStep'), { loading: StepLoader }); +const BulkInputStep = dynamic(() => import('./steps/BulkInputStep'), { loading: StepLoader }); + +import { parseQRCode } from './utils'; + +// ============================================================ +// MODES +// ============================================================ + +export type ClaimFlowMode = 'guided' | 'fast'; + +// ============================================================ +// TYPES +// ============================================================ + +type DialogPrimaryAction = NonNullable< + React.ComponentProps['primaryAction'] +>; + +type DialogSecondaryAction = NonNullable< + React.ComponentProps['secondaryAction'] +>; + +export type FlowStep = + | 'method-select' + | 'manual-input' + | 'qr-scan' + | 'confirmation' + | 'claiming' + | 'success' + | 'bulk-input' + | 'bulk-confirmation' + | 'bulk-claiming' + | 'bulk-results' + | 'cohort-import' + | 'cohort-confirm' + | 'assigning-cohort'; + +export interface ClaimedDeviceInfo { + deviceId: string; + deviceName: string; + cohortId: string; +} + +export interface ClaimDeviceModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess?: (deviceInfo: ClaimedDeviceInfo & { isCohortImport?: boolean }) => void; + initialStep?: FlowStep; + mode?: ClaimFlowMode; +} + +export interface BulkDevice { + device_name: string; + claim_token: string; +} + +export interface VerifiedCohort { + id: string; + name: string; +} + +// ============================================================ +// FORM SCHEMA +// ============================================================ + +const claimDeviceSchema = z.object({ + device_id: z + .string() + .min(1, 'Device ID is required') + .min(3, 'Device ID must be at least 3 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Device ID can only contain letters, numbers, underscores, and hyphens', + ), + claim_token: z + .string() + .min(1, 'Claim token is required') + .min(4, 'Claim token must be at least 4 characters'), +}); + +export type ClaimDeviceFormData = z.infer; + +// ============================================================ +// SHARED COMPONENTS +// ============================================================ + +export const ErrorAlert = ({ message }: { message: string }) => ( +
+ + {message} +
+); + +const LoadingSpinner = ({ + title, + subtitle, +}: { + title: string; + subtitle?: string; +}) => ( +
+ +
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+); + +// ============================================================ +// MAIN COMPONENT +// ============================================================ + +const ClaimDeviceModal: React.FC = ({ + isOpen, + onClose, + onSuccess, + initialStep = 'method-select', + mode = 'fast', +}) => { + const isGuidedMode = mode === 'guided'; + + const router = useRouter(); + const queryClient = useQueryClient(); + + const { data: session } = useSession(); + const user = useAppSelector(state => state.user.userDetails); + + const { isPersonalContext, isExternalOrg, activeGroup, userScope } = + useUserContext(); + + const userId = (session?.user as { id?: string })?.id || user?._id; + + // ========================================================== + // Cohorts + // ========================================================== + + const { data: groupCohortIds } = useGroupCohorts(activeGroup?._id, { + enabled: !isGuidedMode && !isPersonalContext && !!activeGroup?._id, + }); + + const defaultCohort = groupCohortIds?.[0] || null; + + // ========================================================== + // Mutations + // ========================================================== + + const { + mutate: claimDevice, + isPending, + isSuccess, + data: claimData, + error: claimError, + } = useClaimDevice(); + + const { + mutate: bulkClaimDevices, + isPending: isBulkPending, + isSuccess: isBulkSuccess, + data: bulkClaimData, + error: bulkClaimError, + } = useBulkClaimDevices(); + + const { mutateAsync: verifyCohort } = useVerifyCohort(); + const { mutate: assignCohortsToGroup } = useAssignCohortsToGroup(); + const { mutate: assignCohortsToUser } = useAssignCohortsToUser(); + + // ========================================================== + // State + // ========================================================== + + const [step, setStep] = useState(initialStep); + const [error, setError] = useState(null); + const [bulkDevices, setBulkDevices] = useState([]); + const [pendingSingleClaim, setPendingSingleClaim] = useState<{ + deviceId: string; + claimToken: string; + } | null>(null); + const [cohortIdInput, setCohortIdInput] = useState(''); + + // Tracks which step the user came from before confirmation, + // so the Cancel/Back button returns to the right place. + const [previousStep, setPreviousStep] = useState('method-select'); + + const [isImportingCohort, setIsImportingCohort] = useState(false); + const [isCohortAssignmentSuccess, setIsCohortAssignmentSuccess] = + useState(false); + const [verifiedCohort, setVerifiedCohort] = useState( + null, + ); + + // ========================================================== + // Form + // ========================================================== + + const formMethods = useForm({ + resolver: zodResolver(claimDeviceSchema), + defaultValues: { + device_id: '', + claim_token: '', + }, + }); + + // ========================================================== + // Cache Helpers + // ========================================================== + + const invalidatePersonalCaches = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['personalUserCohorts', userId] }); + queryClient.invalidateQueries({ queryKey: ['myDevices'] }); + queryClient.invalidateQueries({ queryKey: ['deviceCount', 'personal'] }); + }, [queryClient, userId]); + + const invalidateGroupCaches = useCallback(() => { + queryClient.invalidateQueries({ + queryKey: ['groupCohorts', activeGroup?._id], + }); + queryClient.invalidateQueries({ + queryKey: ['deviceCount', activeGroup?._id], + }); + queryClient.invalidateQueries({ queryKey: ['devices'] }); + }, [queryClient, activeGroup?._id]); + + // ========================================================== + // Reset + // ========================================================== + + const resetState = useCallback(() => { + formMethods.reset(); + setStep('method-select'); + setError(null); + setBulkDevices([]); + setPendingSingleClaim(null); + setCohortIdInput(''); + setPreviousStep('method-select'); + setIsImportingCohort(false); + setIsCohortAssignmentSuccess(false); + setVerifiedCohort(null); + }, [formMethods]); + + // Always go through resetState so isCohortAssignmentSuccess etc. are cleared + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + // ========================================================== + // Effects + // ========================================================== + + useEffect(() => { + if (isOpen) { + setStep(initialStep); + setError(null); + formMethods.reset(); + } + }, [isOpen, initialStep, formMethods]); + + // Single claim: success + useEffect(() => { + if (!isSuccess || !claimData) return; + + if (isPersonalContext) { + invalidatePersonalCaches(); + } else { + invalidateGroupCaches(); + } + + setStep('success'); + + if (onSuccess && claimData.device) { + onSuccess({ + deviceId: claimData.device.name, + deviceName: claimData.device.long_name || claimData.device.name, + cohortId: '', + }); + } + }, [ + isSuccess, + claimData, + invalidatePersonalCaches, + invalidateGroupCaches, + isPersonalContext, + onSuccess, + ]); + + // Single claim: error + useEffect(() => { + if (claimError) { + setError( + claimError.message || 'Failed to add airqo device. Please try again.', + ); + setStep('manual-input'); + } + }, [claimError]); + + // Single claim: loading + useEffect(() => { + if (isPending && step !== 'claiming') { + setStep('claiming'); + } + }, [isPending, step]); + + // Bulk claim: success + useEffect(() => { + if (isBulkSuccess && bulkClaimData) { + setStep('bulk-results'); + + if (isPersonalContext) { + invalidatePersonalCaches(); + } else { + invalidateGroupCaches(); + } + } + }, [ + isBulkSuccess, + bulkClaimData, + invalidatePersonalCaches, + invalidateGroupCaches, + isPersonalContext, + ]); + + // Bulk claim: error + useEffect(() => { + if (bulkClaimError) { + setError( + bulkClaimError.message || 'Failed to add airqo devices. Please try again.', + ); + setStep('bulk-input'); + } + }, [bulkClaimError]); + + // Bulk claim: loading + useEffect(() => { + if (isBulkPending && step !== 'bulk-claiming') { + setStep('bulk-claiming'); + } + }, [isBulkPending, step]); + + // ========================================================== + // Single Claim Handlers + // ========================================================== + + const handleClaimDevice = (deviceId: string, claimToken: string) => { + if (!userId) { + setError('User session not available. Please try again.'); + return; + } + + // GUIDED MODE — claim only, no cohort assignment + if (isGuidedMode) { + setError(null); + claimDevice({ + device_name: deviceId, + user_id: userId, + claim_token: claimToken, + }); + return; + } + + // FAST MODE — claim + optional auto-cohort assignment + if (!isPersonalContext && !defaultCohort) { + setError('No cohorts found. Please create a cohort first.'); + return; + } + + setError(null); + claimDevice({ + device_name: deviceId, + user_id: userId, + claim_token: claimToken, + ...(defaultCohort && { cohort_id: defaultCohort }), + }); + }; + + const onManualSubmit = (data: ClaimDeviceFormData) => { + setPendingSingleClaim({ + deviceId: data.device_id, + claimToken: data.claim_token, + }); + setPreviousStep('manual-input'); + setStep('confirmation'); + }; + + const handleConfirmSingleClaim = () => { + if (isPending || !pendingSingleClaim) return; + handleClaimDevice( + pendingSingleClaim.deviceId, + pendingSingleClaim.claimToken, + ); + }; + + const handleQRScan = (result: string) => { + const parsed = parseQRCode(result); + + if (!parsed) { + setError('Invalid QR code format. Please try manual entry.'); + setStep('manual-input'); + return; + } + + setPendingSingleClaim({ + deviceId: parsed.deviceId, + claimToken: parsed.claimToken, + }); + setPreviousStep('qr-scan'); + setStep('confirmation'); + }; + + // ========================================================== + // Bulk Handlers (FAST MODE ONLY) + // ========================================================== + + /** Add a blank device row so the user can type into it. */ + const handleBulkAddDevice = () => { + setBulkDevices(prev => [...prev, { device_name: '', claim_token: '' }]); + }; + + /** Remove the device row at the given index. */ + const handleBulkRemoveDevice = (index: number) => { + setBulkDevices(prev => prev.filter((_, i) => i !== index)); + }; + + /** + * Update a single field on a specific device row. + * `field` is either 'device_name' or 'claim_token'. + */ + const handleBulkDeviceChange = ( + index: number, + field: keyof BulkDevice, + value: string, + ) => { + setBulkDevices(prev => + prev.map((device, i) => + i === index ? { ...device, [field]: value } : device, + ), + ); + }; + + /** + * Replace the current device list with devices parsed from an + * imported file (CSV, JSON, etc.). The BulkInputStep is responsible + * for parsing; it hands us a ready-to-use BulkDevice array. + */ + const handleBulkFileImport = (devices: BulkDevice[]) => { + setError(null); + setBulkDevices(devices); + }; + + /** Clear all device rows from the bulk list. */ + const handleBulkClear = () => { + setBulkDevices([]); + setError(null); + }; + + const handleBulkSubmit = () => { + if (!userId) { + setError('User session not available. Please try again.'); + return; + } + + const valid = bulkDevices.filter( + d => d.device_name.trim() && d.claim_token.trim(), + ); + + if (valid.length === 0) { + setError('Please add at least one device with both name and token.'); + return; + } + + setError(null); + setStep('bulk-confirmation'); + }; + + const handleConfirmBulkClaim = () => { + if (!userId) return; + + const valid = bulkDevices.filter( + d => d.device_name.trim() && d.claim_token.trim(), + ); + + bulkClaimDevices({ + user_id: userId, + devices: valid, + ...(defaultCohort && { cohort_id: defaultCohort }), + }); + }; + + // ========================================================== + // Cohort Assignment (FAST MODE ONLY) + // ========================================================== + + const handleVerifyCohort = async () => { + const input = cohortIdInput.trim(); + + if (!input) { + setError('Please enter a valid Cohort ID'); + return; + } + + setError(null); + setIsImportingCohort(true); + + try { + const result = await verifyCohort(input); + + if (!result.success) { + setError(result.message || 'Invalid Cohort ID'); + return; + } + + const cohortName = + (result as { data?: { name?: string } }).data?.name || + result?.cohort?.name || + ''; + + setVerifiedCohort({ id: input, name: cohortName || input }); + setStep('cohort-confirm'); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to verify Cohort ID', + ); + } finally { + setIsImportingCohort(false); + } + }; + + const handleConfirmCohortImport = () => { + if (!verifiedCohort) { + setError('Session expired. Please verify the cohort again.'); + return; + } + + setStep('assigning-cohort'); + + if (isExternalOrg && activeGroup?._id) { + assignCohortsToGroup( + { groupId: activeGroup._id, cohortIds: [verifiedCohort.id] }, + { + onSuccess: () => { + invalidateGroupCaches(); + if (onSuccess) { + onSuccess({ + deviceId: '', + deviceName: '', + cohortId: verifiedCohort.id, + isCohortImport: true, + }); + } + setTimeout(() => { + setIsCohortAssignmentSuccess(true); + setStep('success'); + }, 1500); + }, + onError: err => { + setError(getApiErrorMessage(err)); + setStep('cohort-import'); + }, + }, + ); + return; + } + + if (isPersonalContext && userId) { + assignCohortsToUser( + { userId, cohortIds: [verifiedCohort.id] }, + { + onSuccess: () => { + invalidatePersonalCaches(); + if (onSuccess) { + onSuccess({ + deviceId: '', + deviceName: '', + cohortId: verifiedCohort.id, + isCohortImport: true, + }); + } + setTimeout(() => { + setIsCohortAssignmentSuccess(true); + setStep('success'); + }, 1500); + }, + onError: err => { + setError(getApiErrorMessage(err)); + setStep('cohort-import'); + }, + }, + ); + } + }; + + // ========================================================== + // Dialog Config + // ========================================================== + + const getDialogConfig = () => { + const base = { + title: 'Add AirQo Device', + showFooter: false, + showCloseButton: true, + preventBackdropClose: false, + primaryAction: undefined as DialogPrimaryAction | undefined, + secondaryAction: undefined as DialogSecondaryAction | undefined, + }; + + const back = (to: FlowStep): DialogSecondaryAction => ({ + label: 'Back', + onClick: () => setStep(to), + variant: 'outline', + }); + + switch (step) { + case 'method-select': + return base; + + case 'qr-scan': + return { + ...base, + title: 'Scan QR Code', + showFooter: true, + secondaryAction: back('method-select'), + }; + + case 'manual-input': + return { + ...base, + showFooter: true, + primaryAction: { + label: isPending ? 'Adding...' : 'Add AirQo Device', + onClick: formMethods.handleSubmit(onManualSubmit), + disabled: isPending, + }, + // Back goes to QR scan in fast mode, or method-select in guided mode. + // If the user came from QR scan (previousStep), honour that; otherwise + // fall back to method-select. + secondaryAction: back( + !isGuidedMode && previousStep === 'qr-scan' + ? 'qr-scan' + : 'method-select', + ), + }; + + case 'confirmation': + return { + ...base, + title: 'Confirm Device', + showFooter: true, + primaryAction: { + label: isPending ? 'Claiming...' : 'Confirm & Continue', + onClick: handleConfirmSingleClaim, + disabled: isPending, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep(previousStep), + variant: 'outline' as const, + }, + }; + + case 'claiming': + return { + ...base, + title: 'Claiming Device...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'success': + return { + ...base, + title: 'Success!', + showFooter: true, + primaryAction: { + label: isGuidedMode ? 'Continue Setup' : 'See devices', + onClick: () => { + handleClose(); + if (!isGuidedMode) { + router.push( + userScope === 'personal' + ? '/devices/my-devices' + : '/devices/overview', + ); + } + }, + }, + }; + + // ====================================================== + // FAST MODE ONLY STEPS + // ====================================================== + + case 'cohort-import': + return { + ...base, + title: 'Import Cohort', + showFooter: true, + primaryAction: { + label: isImportingCohort ? 'Verifying...' : 'Import', + onClick: handleVerifyCohort, + disabled: isImportingCohort, + }, + secondaryAction: back('method-select'), + }; + + case 'cohort-confirm': + return { + ...base, + title: 'Confirm Cohort', + showFooter: true, + primaryAction: { + label: 'Confirm & Import', + onClick: handleConfirmCohortImport, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep('cohort-import'), + variant: 'outline' as const, + }, + }; + + case 'assigning-cohort': + return { + ...base, + title: 'Assigning Cohort...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'bulk-input': + return { + ...base, + title: 'Add Multiple Devices', + showFooter: true, + primaryAction: { + label: 'Review & Claim', + onClick: handleBulkSubmit, + }, + secondaryAction: back('method-select'), + }; + + case 'bulk-confirmation': + return { + ...base, + title: 'Confirm Bulk Claim', + showFooter: true, + primaryAction: { + label: isBulkPending ? 'Claiming...' : 'Confirm & Claim', + onClick: handleConfirmBulkClaim, + disabled: isBulkPending, + }, + secondaryAction: { + label: 'Cancel', + onClick: () => setStep('bulk-input'), + variant: 'outline' as const, + }, + }; + + case 'bulk-claiming': + return { + ...base, + title: 'Claiming Devices...', + showCloseButton: false, + preventBackdropClose: true, + }; + + case 'bulk-results': { + const hasSuccess = + (bulkClaimData?.data?.successful_claims?.length ?? 0) > 0; + + return { + ...base, + title: 'Bulk Claim Results', + showFooter: true, + primaryAction: { + label: hasSuccess ? 'Go to Devices' : 'Close', + onClick: () => { + handleClose(); + if (hasSuccess) { + router.push( + userScope === 'personal' + ? '/devices/my-devices' + : '/devices/overview', + ); + } + }, + }, + }; + } + + default: + return base; + } + }; + + const dialogConfig = getDialogConfig(); + + // ========================================================== + // Render + // ========================================================== + + return ( + +
+ {step === 'method-select' && ( + // Pass mode so MethodSelectStep can hide bulk/cohort options + // in guided mode (only manual + QR should appear). + + )} + + {step === 'qr-scan' && ( + { + setPreviousStep('qr-scan'); + setStep('manual-input'); + }} + onError={() => { + setPreviousStep('qr-scan'); + setStep('manual-input'); + setError( + 'QR scanner encountered an issue. Please enter details manually.', + ); + }} + /> + )} + + {step === 'manual-input' && ( + + )} + + {step === 'confirmation' && pendingSingleClaim && } + + {step === 'claiming' && ( + + )} + + {step === 'success' && + (claimData?.device || isCohortAssignmentSuccess) && ( + + )} + + <> + {step === 'bulk-input' && ( + + )} + + {step === 'cohort-import' && ( + { + setCohortIdInput(val); + setError(null); + }} + error={error} + isImporting={isImportingCohort} + /> + )} + + {step === 'cohort-confirm' && verifiedCohort && ( + + )} + + {step === 'bulk-confirmation' && ( + d.device_name.trim() && d.claim_token.trim(), + ).length + } + /> + )} + + {step === 'bulk-claiming' && ( + + )} + + {step === 'assigning-cohort' && ( + + )} + + {step === 'bulk-results' && bulkClaimData?.data && ( + + )} + + +
+
+ ); +}; + +export default ClaimDeviceModal; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx new file mode 100644 index 0000000000..cb757c8e42 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/BulkClaimResults.tsx @@ -0,0 +1,128 @@ +'use client'; + +import React from 'react'; +import { CheckCircle2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import { BulkDeviceClaimResponse } from '@/app/types/devices'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; + +interface BulkClaimResultsProps { + results: BulkDeviceClaimResponse['data']; +} + +export const BulkClaimResults: React.FC = ({ results }) => { + const [showSuccessful, setShowSuccessful] = React.useState(false); + const [showFailed, setShowFailed] = React.useState(false); + + const { successful_claims, failed_claims } = results; + + const successful_count = successful_claims.length; + const failed_count = failed_claims.length; + const total_requested = successful_count + failed_count; + + return ( +
+ {/* Summary Statistics */} +
+
+
Total Requested
+
{total_requested}
+
+
+
Successful
+
{successful_count}
+
+
+
Failed
+
{failed_count}
+
+
+ + {/* Successful Claims */} + {successful_claims.length > 0 && ( + + +
+
+ + + Successfully Claimed Devices ({successful_claims.length}) + +
+ {showSuccessful ? ( + + ) : ( + + )} +
+
+ +
+ {successful_claims.map((result, index) => ( +
+
+ +
+

+ {result.device?.long_name || result.device_name} +

+

+ {result.device?.name || result.device_name} +

+
+
+ Claimed +
+ ))} +
+
+
+ )} + + {/* Failed Claims */} + {failed_claims.length > 0 && ( + + +
+
+ + + Failed Claims ({failed_claims.length}) + +
+ {showFailed ? ( + + ) : ( + + )} +
+
+ +
+ {failed_claims.map((result, index) => ( +
+
+ +
+

+ {result.device_name} +

+

+ {result.error || 'Unknown error occurred'} +

+
+
+
+ ))} +
+
+
+ )} +
+ ); +}; diff --git a/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx new file mode 100644 index 0000000000..5bac6b7cb2 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/BulkInputStep.tsx @@ -0,0 +1,97 @@ +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { BulkDevice, ErrorAlert } from "../claim-device-modal"; +import { FileUploadParser } from "../FileUploadParser"; +import { Plus } from "lucide-react"; +import { DeviceEntryRow } from "../DeviceEntryRow"; +import CohortAssignmentBanner from "./CohortAssignmentBanner"; + +const BulkInputStep = ({ + bulkDevices, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + error, + onAddDevice, + onRemoveDevice, + onDeviceChange, + onFileImport, + onClear, +}: { + bulkDevices: BulkDevice[]; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + error: string | null; + onAddDevice: () => void; + onRemoveDevice: (i: number) => void; + onDeviceChange: (i: number, field: 'device_name' | 'claim_token', val: string) => void; + onFileImport: (devices: BulkDevice[]) => void; + onClear: () => void; +}) => { + if (bulkDevices.length === 0) { + return ( +
+
+ +
+
+
+ +
+
+ Or +
+
+ +
+ ); + } + + const validCount = bulkDevices.filter((d) => d.device_name.trim() && d.claim_token.trim()).length; + + return ( + <> + {!isPersonalContext && defaultCohort && ( + + )} +
+

Review your devices before claiming.

+ +
+
+ {bulkDevices.map((device, index) => ( + onDeviceChange(index, 'device_name', val)} + onClaimTokenChange={(val) => onDeviceChange(index, 'claim_token', val)} + onRemove={() => onRemoveDevice(index)} + showRemove + /> + ))} +
+
+ Add Row +
+ onFileImport([...bulkDevices, ...devices])} /> +
+
+ {error && } +
+ {validCount} device(s) ready to claim +
+ + ); +}; + +export default BulkInputStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx new file mode 100644 index 0000000000..0c024fbd09 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/CohortAssignmentBanner.tsx @@ -0,0 +1,22 @@ +const CohortAssignmentBanner = ({ + isExternalOrg, + isPersonalContext, + activeGroupTitle, +}: { + isExternalOrg: boolean; + isPersonalContext: boolean; + activeGroupTitle?: string; +}) => ( +
+

+ Device will be added to: + {isExternalOrg && activeGroupTitle && ` ${activeGroupTitle}`} + {isPersonalContext && ` as your personal devices`} +

+

+ You can change ownership or share devices later. +

+
+); + +export default CohortAssignmentBanner; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx new file mode 100644 index 0000000000..37dec07cc5 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/CohortImportStep.tsx @@ -0,0 +1,33 @@ +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { Loader2 } from "lucide-react"; + +const CohortImportStep = ({ + cohortIdInput, + onChange, + error, + isImporting, +}: { + cohortIdInput: string; + onChange: (val: string) => void; + error: string | null; + isImporting: boolean; +}) => ( +
+

Enter the Cohort ID to automatically load its devices.

+ onChange(e.target.value)} + error={error || undefined} + /> + {isImporting && ( +
+ + Verifying Cohort ID... +
+ )} +
+); + +export default CohortImportStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx new file mode 100644 index 0000000000..3f89e966b8 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/ConfirmationSteps.tsx @@ -0,0 +1,79 @@ +import { AlertCircle } from "lucide-react"; +import { VerifiedCohort } from "../claim-device-modal"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + + +const DeploymentWarning = ({ isBulk = false }: { isBulk?: boolean }) => ( +

+ + Warning: {isBulk ? 'Any devices currently' : 'If this device is currently'}{' '} + + + + + +

Deployment triggers data transmission for a device

+ + + {isBulk ? ' will be automatically' : ', it will be automatically'}{' '} + + + + + +

Recalling removes a device from a Site (e.g., for repair) without deleting it from your inventory.

+
+
+ {isBulk ? ' and added to your inventory.' : '.'} + +

+); + +export const CohortConfirmStep = ({ + verifiedCohort, + isExternalOrg, +}: { + verifiedCohort: VerifiedCohort; + isExternalOrg: boolean; +}) => ( +
+
+

+ Cohort Name: {verifiedCohort.name} +

+

+ This will add the devices from this cohort to your {isExternalOrg ? 'organization' : 'personal'} assets. +

+
+

Continue to import this cohort?

+
+); + +export const BulkConfirmationStep = ({ count }: { count: number }) => ( +
+
+ +
+
+

Confirm Bulk Claim

+
+ You are about to claim {count} devices. +
+ +
+
+); + +export const ConfirmationStep = () => ( +
+
+ +
+
+

Confirm Device Claim

+

Are you sure you want to claim this device?

+ +
+
+); \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx new file mode 100644 index 0000000000..4b898f18b3 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/ManualInputStep.tsx @@ -0,0 +1,53 @@ +import { useForm } from 'react-hook-form'; +import ReusableInputField from '@/components/shared/inputfield/ReusableInputField'; +import { Form, FormField } from '@/components/ui/form'; +import { ClaimDeviceFormData, ErrorAlert } from '../claim-device-modal'; +import CohortAssignmentBanner from './CohortAssignmentBanner'; + +const ManualInputStep = ({ + formMethods, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + error, +}: { + formMethods: ReturnType>; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + error: string | null; +}) => ( +
+
+ {!isPersonalContext && defaultCohort && ( + + )} +

Enter the device details found on the shipping label.

+
+ ( + + )} + /> + ( + + )} + /> +
+ {error && } +
+
+); + +export default ManualInputStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx new file mode 100644 index 0000000000..c6bf594840 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/MethodSelectStep.tsx @@ -0,0 +1,89 @@ +import { + // Smartphone, + // FileSpreadsheet, + Database } from 'lucide-react'; +import { ClaimFlowMode, FlowStep } from '../claim-device-modal'; + +interface MethodSelectStepProps { + onSelect: (step: FlowStep) => void; + mode?: ClaimFlowMode; +} + +const ALL_METHODS = [ + { + step: 'cohort-import' as FlowStep, + icon: ( + + ), + iconBg: + 'bg-violet-100 dark:bg-violet-900/40 group-hover:bg-violet-200 dark:group-hover:bg-violet-800', + border: + 'hover:border-violet-500 dark:hover:border-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20', + title: 'Import from Cohort', + desc: 'Enter a Cohort ID to prefill devices.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + /* + { + step: 'qr-scan' as FlowStep, + icon: , + iconBg: + 'bg-blue-100 dark:bg-blue-900/40 group-hover:bg-blue-200 dark:group-hover:bg-blue-800', + border: + 'hover:border-blue-500 dark:hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20', + title: 'Add Single Device', + desc: 'Scan a QR code or manually enter a Device ID.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + { + step: 'bulk-input' as FlowStep, + icon: ( + + ), + iconBg: + 'bg-green-100 dark:bg-green-900/40 group-hover:bg-green-200 dark:group-hover:bg-green-800', + border: + 'hover:border-green-500 dark:hover:border-green-400 hover:bg-green-50 dark:hover:bg-green-900/20', + title: 'Add Multiple Devices', + desc: 'Upload a CSV file or enter a list of IDs for bulk setup.', + modes: ['guided', 'fast'] as ClaimFlowMode[], + }, + */ +]; + +const MethodSelectStep = ({ onSelect, mode = 'fast' }: MethodSelectStepProps) => { + const visibleMethods = ALL_METHODS.filter(m => m.modes.includes(mode)); + + return ( +
+

+ Choose how you would like to add your device(s). +

+
+ {visibleMethods.map(({ step, icon, iconBg, border, title, desc }) => ( + + ))} +
+
+ ); +}; + +export default MethodSelectStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/QRScanStep.tsx b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx new file mode 100644 index 0000000000..700f0eae0a --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/QRScanStep.tsx @@ -0,0 +1,98 @@ +import { Component, ReactNode } from "react"; +import { QRScanner } from "../../devices/qr-scanner"; +import CohortAssignmentBanner from "./CohortAssignmentBanner"; +import logger from "@/lib/logger"; + +// ============================================================ +// ERROR BOUNDARY +// ============================================================ + +interface QRScannerErrorBoundaryProps { + children: ReactNode; + isOpen?: boolean; + fallback?: ReactNode; + onError?: () => void; +} + +interface QRScannerErrorBoundaryState { + hasError: boolean; +} + +class QRScannerErrorBoundary extends Component { + constructor(props: QRScannerErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + componentDidUpdate(prevProps: QRScannerErrorBoundaryProps) { + if (!prevProps.isOpen && this.props.isOpen && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + static getDerivedStateFromError(): QRScannerErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + if (process.env.NODE_ENV === 'development') { + logger.warn('QR Scanner error caught by boundary:', error); + } + this.props.onError?.(); + } + + render(): ReactNode { + if (this.state.hasError) return this.props.fallback || null; + return this.props.children; + } +} + +const QRScanStep = ({ + isOpen, + isPersonalContext, + isExternalOrg, + defaultCohort, + activeGroupTitle, + onScan, + onManualEntry, + onError, +}: { + isOpen: boolean; + isPersonalContext: boolean; + isExternalOrg: boolean; + defaultCohort: string | null; + activeGroupTitle?: string; + onScan: (result: string) => void; + onManualEntry: () => void; + onError: () => void; +}) => ( +
+ {!isPersonalContext && defaultCohort && ( + + )} + + Scanner unavailable. Enter details manually + + } + > + {isOpen && } + + +
+); + +export default QRScanStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/steps/SuccessStep.tsx b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx new file mode 100644 index 0000000000..01d027b901 --- /dev/null +++ b/src/vertex-template/components/features/claim/steps/SuccessStep.tsx @@ -0,0 +1,40 @@ +import { CheckCircle2 } from "lucide-react"; + +const SuccessStep = ({ + isCohortAssignmentSuccess, + claimData, +}: { + isCohortAssignmentSuccess: boolean; + claimData?: { device?: { name: string; long_name?: string } }; +}) => ( +
+
+ +
+
+

+ {isCohortAssignmentSuccess ? 'Cohort Assigned Successfully!' : 'Device Claimed Successfully!'} +

+
+ {claimData?.device && ( +
+
+ Device Name: + {claimData.device.long_name || claimData.device.name} +
+
+ Device ID: + {claimData.device.name} +
+
+ Status: + + Claimed + +
+
+ )} +
+); + +export default SuccessStep; \ No newline at end of file diff --git a/src/vertex-template/components/features/claim/utils.ts b/src/vertex-template/components/features/claim/utils.ts new file mode 100644 index 0000000000..2c6ace582d --- /dev/null +++ b/src/vertex-template/components/features/claim/utils.ts @@ -0,0 +1,31 @@ +export function parseQRCode( + qrData: string +): { deviceId: string; claimToken: string } | null { + try { + const url = new URL(qrData); + const deviceId = url.searchParams.get('id'); + const claimToken = url.searchParams.get('token'); + if (deviceId && claimToken) return { deviceId, claimToken }; + } catch { + /* not a URL */ + } + + try { + const parsed = JSON.parse(qrData); + if ( + typeof parsed?.device_id === "string" && + parsed.device_id.trim() && + typeof parsed?.token === "string" && + parsed.token.trim() + ) { + return { + deviceId: parsed.device_id, + claimToken: parsed.token, + }; + } + } catch { + /* not JSON */ + } + + return null; +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx new file mode 100644 index 0000000000..e8b825d68e --- /dev/null +++ b/src/vertex-template/components/features/cohorts/assign-cohort-devices.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useCohorts, useAssignDevicesToCohort, useGroupCohorts } from "@/core/hooks/useCohorts"; +import { useDevices } from "@/core/hooks/useDevices"; +import { ComboBox } from "@/components/ui/combobox"; +import { AqPlus } from "@airqo/icons-react"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { CreateCohortDialog, PreselectedDevice } from "./create-cohort"; +import { Cohort } from "@/app/types/cohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Device } from "@/app/types/devices"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; + +interface AssignCohortDevicesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDevices?: Device[]; + onSuccess?: () => void; + cohortId?: string; + title?: string; +} + +const formSchema = z.object({ + cohortId: z.string().min(1, { + message: "Please select a cohort.", + }), + devices: z.array(z.string()).min(1, { + message: "Please select at least one device.", + }), +}); + +export function AssignCohortDevicesDialog({ + open, + onOpenChange, + selectedDevices, + onSuccess, + cohortId, + title = "Add devices to cohort", +}: AssignCohortDevicesDialogProps) { + const { isExternalOrg, activeGroup } = useUserContext(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const [cohortSearch, setCohortSearch] = useState(""); + const [debouncedCohortSearch, setDebouncedCohortSearch] = useState(""); + const [deviceSearch, setDeviceSearch] = useState(""); + const [debouncedDeviceSearch, setDebouncedDeviceSearch] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedCohortSearch(cohortSearch); + }, 300); + + return () => clearTimeout(timer); + }, [cohortSearch]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedDeviceSearch(deviceSearch); + }, 300); + + return () => clearTimeout(timer); + }, [deviceSearch]); + + const { cohorts: allCohorts, isFetching: isFetchingAllCohorts } = useCohorts({ + enabled: open && !isExternalOrg, + search: debouncedCohortSearch, + limit: 100 + }); + + const { data: groupCohortIds, isFetching: isFetchingCohortIds } = useGroupCohorts( + activeGroup?._id, + { enabled: open && isExternalOrg && !!activeGroup?._id } + ); + + const { cohorts: searchedCohorts, isFetching: isFetchingGroupCohorts } = useCohorts({ + enabled: open && isExternalOrg && !!activeGroup?._id, + search: debouncedCohortSearch, + limit: 100 + }); + + const filteredGroupCohorts = useMemo(() => { + if (!isExternalOrg || !groupCohortIds || groupCohortIds.length === 0) { + return searchedCohorts; + } + const cohortIdSet = new Set(groupCohortIds); + return searchedCohorts.filter(cohort => cohortIdSet.has(cohort._id)); + }, [isExternalOrg, searchedCohorts, groupCohortIds]); + + const cohorts = isExternalOrg ? filteredGroupCohorts : allCohorts; + const isFetchingCohorts = isExternalOrg ? (isFetchingGroupCohorts || isFetchingCohortIds) : isFetchingAllCohorts; + + const { devices: allDevices, isFetching: isFetchingDevices } = useDevices({ + enabled: open, + search: debouncedDeviceSearch, + }); + const { mutate: assignDevices, isPending: isAssigning } = useAssignDevicesToCohort({ + onSuccess: (variables) => { + showBannerWithDelay({ + severity: 'success', + message: `${variables.deviceIds.length} device(s) assigned to cohort successfully`, + scoped: false, + }); + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to assign devices: ${getApiErrorMessage(error)}`, + scoped: true, + }); + }, + }); + + const [createCohortModalOpen, setCreateCohortModalOpen] = useState(false); + const [preselectedForCreate, setPreselectedForCreate] = useState([]); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + cohortId: cohortId || "", + devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [], + }, + }); + + const deviceOptions: Option[] = useMemo(() => { + const combined = [...(selectedDevices ?? []), ...(allDevices ?? [])]; + const unique = new Map(); + + combined.forEach((device) => { + if (!device?._id) return; + unique.set(device._id, { + value: device._id, + label: device.long_name || device.name || `Device ${device._id}`, + }); + }); + + return Array.from(unique.values()); + }, [allDevices, selectedDevices]); + + useEffect(() => { + if (open) { + form.reset({ + cohortId: cohortId || "", + devices: selectedDevices?.map((d) => d._id).filter((id): id is string => !!id) || [], + }); + setCohortSearch(""); + setDebouncedCohortSearch(""); + setDeviceSearch(""); + setDebouncedDeviceSearch(""); + } + }, [open, form, cohortId, selectedDevices]); + + const handleCreateCohortSuccess = () => { + setCreateCohortModalOpen(false); + onOpenChange(false); + }; + + const handleCreateCohortClose = (open: boolean) => { + if (!open) { + setCreateCohortModalOpen(false); + } + }; + + const handleCreateCohortAction = () => { + const selectedIds = form.getValues("devices") || []; + const preselected = deviceOptions + .filter((opt) => selectedIds.includes(opt.value)) + .map((opt) => ({ value: opt.value, label: opt.label })); + + setPreselectedForCreate(preselected); // store in state + onOpenChange(false); + setCreateCohortModalOpen(true); + }; + + + function onSubmit(values: z.infer) { + assignDevices( + { + cohortId: values.cohortId, + deviceIds: values.devices, + }, + { + onSuccess: () => { + onOpenChange(false); + form.reset(); + onSuccess?.(); + }, + } + ); + } + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + form.reset(); + setCreateCohortModalOpen(false); + } + }; + + return ( + <> + handleOpenChange(false)} + title={title} + subtitle={`${form.watch("devices")?.length || 0} device(s) selected`} + size="lg" + maxHeight="max-h-[70vh]" + primaryAction={{ + label: "Add", + onClick: form.handleSubmit(onSubmit), + disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isAssigning, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => onOpenChange(false), + variant: "outline", + disabled: isAssigning, + }} + > +
+ + ( + + + Cohort * + + + ({ + value: cohort._id, + label: cohort.name, + }))} + value={field.value} + onValueChange={field.onChange} + placeholder="Select a cohort" + searchPlaceholder="Search cohorts..." + emptyMessage="No cohorts found" + disabled={!!cohortId} + className="w-full" + allowCustomInput={false} + customActionLabel="Create New Cohort" + customActionIcon={AqPlus} + onCustomAction={handleCreateCohortAction} + onSearchChange={setCohortSearch} + isLoading={isFetchingCohorts} + /> + + + + )} + /> + + ( + + + Devices * + + + + + {isFetchingDevices && ( +

Searching devices...

+ )} + +
+ )} + /> + + +
+ + + + + ); +} diff --git a/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx new file mode 100644 index 0000000000..247a158e5f --- /dev/null +++ b/src/vertex-template/components/features/cohorts/assign-cohorts-to-group.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { useAssignCohortsToGroup, useCohorts } from "@/core/hooks/useCohorts"; +import { useGroups } from "@/core/hooks/useGroups"; +import { ComboBox } from "@/components/ui/combobox"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { Cohort } from "@/app/types/cohorts"; +import { Group } from "@/app/types/groups"; + +interface AssignCohortsToGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialSelectedCohortIds: string[]; + onSuccess?: () => void; +} + +export function AssignCohortsToGroupDialog({ + open, + onOpenChange, + initialSelectedCohortIds, + onSuccess, +}: AssignCohortsToGroupDialogProps) { + const [selectedGroupId, setSelectedGroupId] = useState(""); + const [selectedCohortIds, setSelectedCohortIds] = useState(initialSelectedCohortIds); + const [errors, setErrors] = useState<{ group?: string; cohorts?: string }>({}); + + const { cohorts: allCohorts } = useCohorts(); + const { groups: allGroups, isLoading: isLoadingGroups } = useGroups(); + const { mutate: assignToGroup, isPending } = useAssignCohortsToGroup(); + + useEffect(() => { + if (open) { + setSelectedCohortIds(initialSelectedCohortIds); + } + }, [open, initialSelectedCohortIds]); + + const cohortOptions: Option[] = useMemo(() => { + return (allCohorts || []).map((cohort: Cohort) => ({ + value: cohort._id, + label: cohort.name, + })); + }, [allCohorts]); + + const groupOptions = useMemo(() => { + return (allGroups || []).map((group: Group) => ({ + value: group._id, + label: group.grp_title, + })); + }, [allGroups]); + + const handleClose = () => { + onOpenChange(false); + // Reset state after dialog closes + setTimeout(() => { + setSelectedGroupId(""); + setSelectedCohortIds([]); + setErrors({}); + }, 150); // Delay to allow for closing animation + }; + + const handleSubmit = () => { + const newErrors: { group?: string; cohorts?: string } = {}; + if (!selectedGroupId) { + newErrors.group = "Please select an organization."; + } + if (selectedCohortIds.length === 0) { + newErrors.cohorts = "Please select at least one cohort."; + } + + setErrors(newErrors); + + if (Object.keys(newErrors).length > 0) { + return; + } + + assignToGroup( + { + groupId: selectedGroupId, + cohortIds: selectedCohortIds, + }, + { + onSuccess: () => { + onSuccess?.(); + handleClose(); + }, + } + ); + }; + + return ( + +
+
+ + { + setSelectedGroupId(value); + if (errors.group) setErrors(e => ({...e, group: undefined})); + }} + placeholder="Select an organization" + searchPlaceholder="Search organization..." + emptyMessage={isLoadingGroups ? "Loading organization..." : "No organization found."} + disabled={isPending || isLoadingGroups} + allowCustomInput={false} + /> + {errors.group &&

{errors.group}

} +
+
+ + { + setSelectedCohortIds(values); + if (errors.cohorts) setErrors(e => ({...e, cohorts: undefined})); + }} + placeholder="Select cohorts..." + allowCreate={false} + /> + {errors.cohorts &&

{errors.cohorts}

} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx new file mode 100644 index 0000000000..76a4d0ca87 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-detail-card.tsx @@ -0,0 +1,319 @@ +import { Card } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { AqCopy01, AqEdit01 } from "@airqo/icons-react"; +import { Switch } from "@/components/ui/switch"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import { useEffect, useState } from "react"; +import { useUpdateCohortDetails, useOriginalCohort } from "@/core/hooks/useCohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Badge } from "@/components/ui/badge"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { InfoBanner } from "@/components/ui/banner"; + +interface CohortDetailsCardProps { + name: string; + id: string; + visibility: boolean; + onShowDetailsModal: () => void; + loading: boolean; + cohort_tags?: string[]; +} + +const CohortDetailsCard: React.FC = ({ + name, + id, + visibility, + onShowDetailsModal, + loading, + cohort_tags, +}) => { + const [isVisibilityDialogOpen, setIsVisibilityDialogOpen] = useState(false); + const [isTagsDialogOpen, setIsTagsDialogOpen] = useState(false); + const [targetVisibility, setTargetVisibility] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const pathname = usePathname(); + const isAdminPath = pathname.startsWith("/admin"); + const isDuplicate = (cohort_tags ?? []).some( + (tag) => tag.toLowerCase() === "duplicate" + ); + + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const { mutateAsync: updateCohort, isPending } = useUpdateCohortDetails(); + const { data: originalData } = useOriginalCohort(id, { enabled: !!isDuplicate }); + const originalCohort = isDuplicate && originalData?.success ? originalData.original_cohort : null; + + useEffect(() => { + setSelectedTags(cohort_tags ?? []); + }, [cohort_tags]); + + const handleToggleVisibility = (checked: boolean) => { + setTargetVisibility(checked); + setIsVisibilityDialogOpen(true); + }; + + const handleConfirmVisibilityUpdate = async () => { + try { + await updateCohort({ cohortId: id, data: { visibility: targetVisibility } }); + setIsVisibilityDialogOpen(false); + showBannerWithDelay({ + severity: 'success', + message: `Cohort is now ${targetVisibility ? 'public' : 'private'}`, + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update visibility: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + const handleConfirmTagsUpdate = async () => { + try { + await updateCohort({ cohortId: id, data: { cohort_tags: selectedTags } }); + setIsTagsDialogOpen(false); + showBannerWithDelay({ + severity: 'success', + message: 'Cohort tags updated successfully', + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update tags: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + if (loading) { + return ( + + + + ); + } + + return ( + <> + +
+

Cohort Details

+ +
+
+
+ Cohort Name +
+
+
+ {name || "-"} +
+ +
+
+ +
+
+
+ Tags +
+ setIsTagsDialogOpen(true)} + className="p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full h-fit w-fit" + Icon={AqEdit01} + aria-label="Edit tags" + /> +
+
+ {cohort_tags && cohort_tags.length > 0 ? ( + cohort_tags.map((tag) => ( + + {tag} + + )) + ) : ( + + None + + )} +
+
+ +
+
+ Visibility +
+
+ + Private + + + + Public + +
+
+ +
+
+ Cohort ID +
+
+
+ {id || "-"} +
+ { + if (!id) return; + try { + await navigator.clipboard.writeText(id); + showBanner({ severity: 'success', message: 'Cohort ID copied to clipboard', scoped: false }); + } catch { + showBanner({ severity: 'error', message: 'Failed to copy to clipboard', scoped: false }); + } + }} + className="p-1" + Icon={AqCopy01} + /> +
+
+
+ + {originalCohort && ( + + This cohort is a duplicate. The original cohort is{" "} + + {originalCohort.name} + + + } + /> + )} +
+
+ + !isPending && setIsVisibilityDialogOpen(false)} + title={ + targetVisibility ? "Make cohort public?" : "Make cohort private?" + } + size="md" + customFooter={ +
+ setIsVisibilityDialogOpen(false)} + disabled={isPending} + > + Cancel + + + {isPending + ? "Updating..." + : targetVisibility + ? "Confirm & Publish" + : "Confirm & Make Private"} + +
+ } + > +
+

+ {targetVisibility + ? "You are about to make this cohort visible on the public AirQo Map. This means anyone can see readings from devices in this cohort." + : "You are about to make this cohort private. Data from devices in this cohort will only be visible to your organization and will not appear on the public map."} +

+
+
+ + !isPending && setIsTagsDialogOpen(false)} + title="Edit Cohort Tags" + size="md" + customFooter={ +
+ setIsTagsDialogOpen(false)} + disabled={isPending} + > + Cancel + + + {isPending ? "Updating..." : "Save Changes"} + +
+ } + > +
+ +
+
+ + ); +}; + +export default CohortDetailsCard; diff --git a/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx new file mode 100644 index 0000000000..d158e0e304 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-measurements-api-card.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Copy } from "lucide-react"; +import React from "react"; +import { useClipboard } from "@/core/hooks/useClipboard"; + +interface CohortMeasurementsApiCardProps { + cohortId: string; +} + +const CohortMeasurementsApiCard: React.FC = ({ cohortId }) => { + const { handleCopy } = useClipboard({ successMessage: 'API URL copied to clipboard', errorMessage: 'Failed to copy to clipboard' }); + + return ( + +

Cohort Measurements API

+ {/* Recent Measurements */} +
+
Recent Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/recent?token=YOUR_TOKEN`} +
+ +
+
+ {/* Historical Measurements */} +
+
Historical Measurements API
+
+
+ {`https://api.airqo.net/api/v2/devices/measurements/cohorts/${cohortId}/historical?token=YOUR_TOKEN`} +
+ +
+
+
+ ); +}; + +export default CohortMeasurementsApiCard; diff --git a/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx new file mode 100644 index 0000000000..4275b03187 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohort-organizations-card.tsx @@ -0,0 +1,229 @@ +import { useState, useMemo } from "react"; +import { Card } from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Group } from "@/app/types/groups"; +import { useGroupsByCohort } from "@/core/hooks/useGroups"; +import { UnassignCohortFromGroupDialog } from "./unassign-cohort-from-group"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableTable, { TableColumn, TableItem } from "@/components/shared/table/ReusableTable"; +import ReusableToast from "@/components/shared/toast/ReusableToast"; +import { AqCopy01 } from "@airqo/icons-react"; +import { formatTitle } from "../org-picker/organization-picker"; + +interface CohortOrganizationsCardProps { + cohortId: string; + cohortName: string; + canUnassign?: boolean; +} + +export function CohortOrganizationsCard({ + cohortId, + cohortName, + canUnassign = false, +}: CohortOrganizationsCardProps) { + const { groups: organizations, isLoading, refetch } = useGroupsByCohort(cohortId); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [showUnassignDialog, setShowUnassignDialog] = useState(false); + const [showAllDialog, setShowAllDialog] = useState(false); + + const handleUnassignClick = (org: Group) => { + setSelectedOrg(org); + setShowUnassignDialog(true); + }; + + const handleUnassignSuccess = () => { + setShowUnassignDialog(false); + setSelectedOrg(null); + refetch(); // Refetch the organizations assigned to the cohort + }; + + const handleCopyId = (id: string) => { + navigator.clipboard.writeText(id); + ReusableToast({ message: "Group ID copied to clipboard!", type: "SUCCESS" }); + }; + + const tableData = useMemo(() => { + return organizations.map((org) => ({ ...org, id: org._id })); + }, [organizations]); + + const tableColumns: TableColumn[] = [ + { + key: "grp_title", + label: "Name", + render: (value, item) => {formatTitle(item.grp_title)}, + sortable: true, + }, + { + key: "_id", + label: "Org ID", + render: (value, item) => ( +
+ {item._id} + { + e.stopPropagation(); + handleCopyId(item._id); + }} + Icon={AqCopy01} + aria-label="Copy Group ID" + /> +
+ ), + }, + { + key: "grp_country", + label: "Country", + render: (value, item) => item.grp_country || "-", + sortable: true, + }, + { + key: "actions" as keyof (Group & { id: string }), + label: "Action", + render: (value, item) => ( + + + +
+ handleUnassignClick(item)} + disabled={!canUnassign} + className="data-[state=checked]:bg-red-600" + aria-label="Unassign from cohort" + /> +
+
+ + Remove cohort assignment + +
+
+ ), + }, + ]; + + if (isLoading) { + return ( + + + + ); + } + + const displayOrganizations = organizations.slice(0, 3); + const hasMore = organizations.length > 3; + + return ( + <> + +
+

Assigned Organizations

+ + {organizations.length === 0 ? ( +
+ No organizations assigned to this cohort. +
+ ) : ( +
+ {displayOrganizations.map((org) => ( +
+
+
+

+ {org.grp_title} +

+ +
+
+ Organization ID +
+
+ + {org._id} + + handleCopyId(org._id)} + className="p-1 h-auto text-muted-foreground hover:text-primary" + Icon={AqCopy01} + aria-label="Copy Organization ID" + /> +
+
+
+ +
+ handleUnassignClick(org)} + disabled={!canUnassign} + className="text-xs px-2 py-1 text-red-600 border-red-200 hover:bg-red-500 dark:hover:bg-red-950 dark:border-red-900" + > + Unassign + +
+
+
+ ))} +
+ )} +
+ + {hasMore && ( +
+ setShowAllDialog(true)} + > + View more organizations ({organizations.length - 3}) + +
+ )} +
+ + {/* Dialog for "View More" table */} + setShowAllDialog(false)} + title={`All Assigned Organizations (${organizations.length})`} + size="3xl" + > +
+ []} + searchable={true} + searchableColumns={["grp_title", "grp_country", "_id"]} + filterable={false} + showPagination={true} + pageSize={5} + pageSizeOptions={[5, 10, 20]} + exportable={false} + tableId={`cohort-orgs-${cohortId}`} + /> +
+
+ + {/* Unassign Confirmation Dialog */} + + + ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx new file mode 100644 index 0000000000..8f2dd89c45 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/cohorts-empty-state.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React, { useState } from 'react'; +import { Plus } from 'lucide-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { AqCollocation } from '@airqo/icons-react'; +import dynamic from 'next/dynamic'; + +const ClaimDeviceModal = dynamic( + () => import('../claim/claim-device-modal'), + { ssr: false } +); + +const CohortsEmptyState = () => { + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false); + + return ( +
+
+ +
+ +

+ No Cohorts Found +

+ +

+ Get started by claiming widespread AirQo devices to automatically create your first device cohort. +

+ +
+ setIsClaimModalOpen(true)} Icon={Plus}> + Add AirQo Device + +
+ + setIsClaimModalOpen(false)} + /> +
+ ); +}; + +export default CohortsEmptyState; diff --git a/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx new file mode 100644 index 0000000000..5fd4a420e0 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/create-cohort-from-cohorts.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { useCreateCohortFromCohorts } from "@/core/hooks/useCohorts"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { Label } from "@/components/ui/label"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface CreateCohortFromSelectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedCohortIds: string[]; + onSuccess?: () => void; + andNavigate?: boolean; +} + +export function CreateCohortFromSelectionDialog({ + open, + onOpenChange, + selectedCohortIds, + onSuccess, + andNavigate = true, +}: CreateCohortFromSelectionDialogProps) { + const [city, setCity] = useState(""); + const [projectName, setProjectName] = useState(""); + const [funder, setFunder] = useState(""); + const [name, setName] = useState(""); + const [network, setNetwork] = useState(""); + const [description, setDescription] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [error, setError] = useState(""); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + const router = useRouter(); + const { mutate: createFromCohorts, isPending } = useCreateCohortFromCohorts(); + const { networks, isLoading: isLoadingNetworks } = useNetworks(); + + useEffect(() => { + if (!open) { + setCity(""); + setProjectName(""); + setFunder(""); + setName(""); + setNetwork(""); + setDescription(""); + setNetwork(""); + setDescription(""); + setSelectedTags([]); + setError(""); + } + }, [open]); + + const handleClose = () => { + onOpenChange(false); + }; + + const handleSubmit = () => { + const isOrganizational = selectedTags.includes("organizational"); + if (isOrganizational) { + if (city.trim().length === 0) { + setError("City is required."); + return; + } + if (projectName.trim().length === 0) { + setError("Project name is required."); + return; + } + } else if (name.trim().length === 0) { + setError("Cohort name is required."); + return; + } + if (!network) { + setError("Please select a Sensor Manufacturer."); + return; + } + if (selectedTags.length === 0) { + setError("Please select at least one tag."); + return; + } + setError(""); + + const derivedName = isOrganizational + ? buildCohortName(city, projectName, funder) + : name.trim(); + if (derivedName.length < 2) { + setError("Cohort name must be at least 2 characters."); + return; + } + + createFromCohorts( + { + name: derivedName, + description: description.trim() || undefined, + cohort_ids: selectedCohortIds, + network, + cohort_tags: selectedTags, + }, + { + onSuccess: (response) => { + onSuccess?.(); + if (andNavigate && response?.data?._id) { + router.push(`/admin/cohorts/${response.data._id}`); + } else { + handleClose(); + } + }, + } + ); + }; + + const handleSanitizedInputChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string, + setter: (nextValue: string) => void + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + setter(sanitized); + }; + + return ( + +
+
+ + +
+ + {selectedTags.includes("organizational") ? ( + <> +
+ + + +
+ handleSanitizedInputChange("city", e.target.value, setCity)} placeholder="e.g. Nairobi" required /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("projectName", e.target.value, setProjectName)} placeholder="e.g. WRI" required /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("funder", e.target.value, setFunder)} placeholder="e.g. EPIC" /> +
+
+ +

Special character ignored

+
+
+
+
+ {error && ( +

{error}

+ )} + + ) : ( + setName(e.target.value)} + placeholder="Enter cohort name" + required + /> + )} + {!selectedTags.includes("organizational") && error && ( +

{error}

+ )} + setNetwork(e.target.value)} + required + placeholder={isLoadingNetworks ? "Loading sensor manufacturer..." : "Select a sensor manufacturer"} + disabled={isLoadingNetworks} + > + {networks.map((network) => ( + + ))} + + + setDescription(e.target.value)} placeholder="Describe this combined cohort" rows={3} /> +
+
+ ); +} diff --git a/src/vertex-template/components/features/cohorts/create-cohort.tsx b/src/vertex-template/components/features/cohorts/create-cohort.tsx new file mode 100644 index 0000000000..e159693d97 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/create-cohort.tsx @@ -0,0 +1,587 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { useDevices } from "@/core/hooks/useDevices"; +import { useCreateCohortWithDevices } from "@/core/hooks/useCohorts"; +import { useNetworks } from "@/core/hooks/useNetworks"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import ReusableSelectInput from "@/components/shared/select/ReusableSelectInput"; +import { DeviceNameParser } from "./device-name-parser"; +import { useBanner } from "@/context/banner-context"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; +import { buildCohortName, sanitizeCohortInput } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +export type PreselectedDevice = { value: string; label: string }; + +const EMPTY_PRESELECTED_DEVICES: PreselectedDevice[] = []; + +interface CreateCohortDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: (cohortData?: { cohort: { _id: string; name: string } }) => void; + onError?: (error: unknown) => void; + andNavigate?: boolean; + preselectedDevices?: PreselectedDevice[]; + hideDeviceSelection?: boolean; + preselectedNetwork?: string; +} + +const formSchema = z.object({ + name: z.string().optional(), + city: z.string().optional(), + projectName: z.string().optional(), + funder: z.string().optional(), + network: z.string().min(1, { + message: "Please select a Sensor Manufacturer.", + }), + devices: z.array(z.string()).optional(), + cohort_tags: z.array(z.string()).optional(), +}).superRefine((values, ctx) => { + const isOrganizational = values.cohort_tags?.includes("organizational"); + if (isOrganizational) { + if (!values.city?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "City is required.", path: ["city"] }); + } + if (!values.projectName?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Project name is required.", path: ["projectName"] }); + } + } else { + if (!values.name?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Cohort name is required.", path: ["name"] }); + } + } +}); + +export function CreateCohortDialog({ + open, + onOpenChange, + onSuccess, + onError, + preselectedDevices = EMPTY_PRESELECTED_DEVICES, + hideDeviceSelection = false, + preselectedNetwork, + andNavigate = true, +}: CreateCohortDialogProps) { + const { showBanner } = useBanner(); + const pathname = usePathname(); + const isAdminPage = pathname?.includes('/admin/'); + const { isExternalOrg, activeGroup } = useUserContext(); + const userDetails = useAppSelector((state) => state.user.userDetails); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + city: "", + projectName: "", + funder: "", + network: preselectedNetwork || "", + devices: preselectedDevices.map((d) => d.value), + cohort_tags: [], + }, + }); + + type CohortStep = "form" | "confirmation" | "success"; + const [step, setStep] = useState("form"); + const [createdCohort, setCreatedCohort] = useState<{ _id: string; name: string } | null>(null); + + const selectedNetwork = form.watch("network"); + const [deviceSearch, setDeviceSearch] = useState(""); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + + const { devices, isLoading, error } = useDevices({ + network: selectedNetwork, + search: deviceSearch + }); + + const { networks, isLoading: isLoadingNetworks } = useNetworks(); + + const deviceOptions = useMemo(() => { + return (devices || []).map((d) => ({ + value: d._id || "", + label: d.long_name || d.name || `Device ${d._id}`, + })); + }, [devices]); + + const router = useRouter(); + + const handleSanitizedFieldChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string, + onChange: (nextValue: string) => void + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + onChange(sanitized); + }; + + useEffect(() => { + if (open) { + form.reset({ + name: "", + city: "", + projectName: "", + funder: "", + network: preselectedNetwork || "", + devices: preselectedDevices.map((d) => d.value), + cohort_tags: [], + }); + setDeviceSearch(""); + setStep("form"); + setCreatedCohort(null); + + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // When embedded in another modal (andNavigate=false + hideDeviceSelection=true), + // skip the success step and close immediately so query invalidations don't + // trigger a re-render cascade in the parent modal. + const isEmbeddedMode = !andNavigate && hideDeviceSelection; + + const { mutate: createCohort, isPending } = useCreateCohortWithDevices({ + invalidateOnSuccess: !isEmbeddedMode, + onSuccess: (response) => { + if (isEmbeddedMode) { + onSuccess?.(response); + onOpenChange(false); + } else if (response?.cohort) { + setCreatedCohort(response.cohort); + setStep("success"); + onSuccess?.(response); + } else { + onSuccess?.(response); + onOpenChange(false); + } + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to create cohort: ${getApiErrorMessage(error)}`, + scoped: true, + }); + onError?.(error); + }, + }); + + const handleCancel = () => { + if (step === 'form') { + form.reset(); + setDeviceSearch(""); + onOpenChange(false); + } else if (step === 'success') { + onOpenChange(false); + } else { + setStep('form'); + } + }; + + const handleDeviceImport = (deviceNames: string[]) => { + // Validate that network is selected first (generic block handles this now, but extra safety) + if (!selectedNetwork) return; + + // Convert device names to device IDs by finding matches in deviceOptions + const matchedIds = deviceNames + .map(name => { + const nameLower = name.toLowerCase(); + // Try exact match first + let match = deviceOptions.find(d => d.label.toLowerCase() === nameLower); + + // If no exact match, try contains match + if (!match) { + match = deviceOptions.find(d => + d.label.toLowerCase().includes(nameLower) && nameLower.length >= 3 + ); + } + + return match?.value; + }) + .filter(Boolean) as string[]; + + if (matchedIds.length === 0) { + showBanner({ + severity: 'warning', + message: 'No matching devices found. Please ensure the devices exist in the selected network.', + scoped: true, + }); + return; + } + + // Merge with existing selections + const currentDevices = form.getValues('devices') || []; + const uniqueDevices = Array.from(new Set([...currentDevices, ...matchedIds])); + form.setValue('devices', uniqueDevices); + + const importedCount = matchedIds.length; + const notFoundCount = deviceNames.length - importedCount; + + if (notFoundCount > 0) { + showBanner({ + severity: 'warning', + message: `Imported ${importedCount} device${importedCount !== 1 ? 's' : ''}. ${notFoundCount} not found.`, + scoped: true, + }); + } + }; + + // Intermediate submit handler for form step + const onFormReview = () => { + setStep("confirmation"); + }; + + // Final submit handler + const handleConfirmCreate = () => { + const values = form.getValues(); + const isOrganizational = values.cohort_tags?.includes("organizational"); + const derivedName = isOrganizational + ? buildCohortName(values.city || "", values.projectName || "", values.funder) + : (values.name || "").trim(); + + const payload: Parameters[0] = { + name: derivedName, + network: values.network, + deviceIds: [], + }; + + if (values.devices && values.devices.length > 0) { + payload.deviceIds = values.devices; + } + + if (values.cohort_tags && values.cohort_tags.length > 0) { + payload.cohort_tags = values.cohort_tags; + } + + if (isExternalOrg && activeGroup?._id) { + payload.groupId = activeGroup._id; + } else if (!isExternalOrg && !isAdminPage && userDetails?._id) { + payload.userId = userDetails._id; + } + + createCohort(payload); + }; + + const getDialogConfig = () => { + switch (step) { + case 'confirmation': + return { + title: 'Confirm Cohort Creation', + primaryLabel: isPending ? 'Creating...' : 'Confirm & Create', + primaryAction: handleConfirmCreate, + secondaryLabel: 'Back', + secondaryAction: () => setStep('form'), + showFooter: true + }; + case 'success': + return { + title: 'Success!', + primaryLabel: andNavigate ? 'Go to Cohort Details' : 'Close', + primaryAction: () => { + if (andNavigate && createdCohort?._id) { + router.push(`/admin/cohorts/${createdCohort._id}`); + } else { + onOpenChange(false); + } + }, + secondaryLabel: 'Close', + secondaryAction: () => onOpenChange(false), + showFooter: true + }; + default: // form + return { + title: 'Create Cohort', + primaryLabel: 'Review & Create', + primaryAction: form.handleSubmit(onFormReview), + secondaryLabel: 'Cancel', + secondaryAction: handleCancel, + showFooter: true + }; + } + }; + + const config = getDialogConfig(); + const formValues = form.getValues(); + const isOrganizational = form.watch("cohort_tags")?.includes("organizational"); + const derivedName = isOrganizational + ? buildCohortName(formValues.city || "", formValues.projectName || "", formValues.funder) + : (formValues.name || "").trim(); + + return ( + + {step === 'form' && ( +
+ + {!hideDeviceSelection && ( + ( + + + + + + )} + /> + )} + + {isOrganizational ? ( +
+ ( + + + +
+ handleSanitizedFieldChange("city", e.target.value, field.onChange)} + error={form.formState.errors.city?.message} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> + ( + + + +
+ handleSanitizedFieldChange("projectName", e.target.value, field.onChange)} + error={form.formState.errors.projectName?.message} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> + ( + + + +
+ handleSanitizedFieldChange("funder", e.target.value, field.onChange)} + /> +
+
+ +

Special character ignored

+
+
+
+ )} + /> +
+ ) : ( + ( + + )} + /> + )} + {(!hideDeviceSelection || !preselectedNetwork) && ( + ( + { + field.onChange(e.target.value); + form.setValue("devices", []); + setDeviceSearch(""); + }} + error={form.formState.errors.network?.message} + required + placeholder={isLoadingNetworks ? "Loading networks..." : "Select a network"} + disabled={isLoadingNetworks} + > + {networks.map((network) => ( + + ))} + + )} + /> + )} + {!hideDeviceSelection && ( + ( + +
+ + +
+ + + + {isLoading &&

Loading devices…

} + {error && ( +

Failed to load devices. Please try again.

+ )} + +
+ )} + /> + )} + + + )} + + {step === 'confirmation' && ( +
+
+ +
+
+

+ Review Cohort Details +

+

+ You are about to create a cohort named {derivedName} in the {formValues.network} network. +

+ {!hideDeviceSelection && ( +
+

+ Devices to be added: +

+

+ {formValues.devices?.length || 0} +

+
+ )} +
+
+ )} + + {step === 'success' && createdCohort && ( +
+
+ +
+
+

+ Cohort Created Successfully! +

+

+ Cohort {createdCohort.name} has been created{hideDeviceSelection ? '.' : ` with ${formValues.devices?.length || 0} devices.`} +

+
+
+ )} +
+ ); +} diff --git a/src/vertex-template/components/features/cohorts/device-name-parser.tsx b/src/vertex-template/components/features/cohorts/device-name-parser.tsx new file mode 100644 index 0000000000..5a094870a8 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/device-name-parser.tsx @@ -0,0 +1,320 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { FileSpreadsheet } from 'lucide-react'; +import ReusableButton from '@/components/shared/button/ReusableButton'; +import { useBanner } from '@/context/banner-context'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export interface ParsedFileData { + headers: string[]; + data: unknown[][]; + fileName: string; +} + +interface DeviceNameColumnMapperProps { + filePreview: ParsedFileData; + onConfirm: (deviceNameColumn: number) => void; + onCancel: () => void; +} + +const DeviceNameColumnMapper: React.FC = ({ + filePreview, + onConfirm, + onCancel, +}) => { + const [deviceNameColumn, setDeviceNameColumn] = useState(() => { + // Auto-detect device name column + const index = filePreview.headers.findIndex(h => + /device.*name|name|device.*id|device|long.*name|device_name/i.test(h.toLowerCase()) + ); + return index !== -1 ? index : 0; + }); + + return ( +
+
+
+

+ Map Device Name Column +

+

+ File: {filePreview.fileName} +

+
+ +
+

+ Please select which column contains the device names: +

+ + {/* Column Selector */} +
+ + +
+ + {/* Preview Table */} +
+ + + + {filePreview.headers.map((header, index) => ( + + ))} + + + + {filePreview.data.slice(1, 6).map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} + {index === deviceNameColumn && ( + (Device Name) + )} +
+ {String(cell || '')} +
+
+

+ Showing first 5 rows as preview +

+
+ +
+ + Cancel + + onConfirm(deviceNameColumn)}> + Import Devices + +
+
+
+ ); +}; + +interface DeviceNameCohortParserProps { + onDevicesParsed: (deviceNames: string[]) => void; + shouldBlock?: boolean; + tooltipMessage?: string; + onBlock?: () => void; +} + +export const DeviceNameParser: React.FC = ({ + onDevicesParsed, + shouldBlock = false, + tooltipMessage, + onBlock +}) => { + const [isImporting, setIsImporting] = useState(false); + const [filePreview, setFilePreview] = useState(null); + const fileInputRef = useRef(null); + const { showBanner } = useBanner(); + + const handleLinkClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (shouldBlock) { + onBlock?.(); + return; + } + + if (!isImporting) { + fileInputRef.current?.click(); + } + }; + + const handleFileImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const fileExtension = file.name.split('.').pop()?.toLowerCase(); + if (!['csv', 'xlsx', 'xls'].includes(fileExtension || '')) { + showBanner({ severity: 'error', message: 'Invalid file format. Please upload a CSV or Excel file.', scoped: true }); + e.target.value = ''; + return; + } + + if (file.size > 5 * 1024 * 1024) { + showBanner({ severity: 'error', message: 'File too large. Maximum size is 5MB.', scoped: true }); + e.target.value = ''; + return; + } + + setIsImporting(true); + + try { + let parsedData: unknown[][] = []; + let headers: string[] = []; + + if (fileExtension === 'csv') { + const Papa = (await import('papaparse')).default; + Papa.parse(file, { + complete: (results) => { + parsedData = results.data as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } else { + showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true }); + } + setIsImporting(false); + }, + error: (error) => { + showBanner({ severity: 'error', message: `Error parsing CSV: ${error.message}`, scoped: true }); + setIsImporting(false); + }, + }); + } else { + const XLSX = await import('xlsx'); + const reader = new FileReader(); + reader.onload = (evt) => { + const bstr = evt.target?.result; + const workbook = XLSX.read(bstr, { type: 'binary' }); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + parsedData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as unknown[][]; + if (parsedData.length > 0) { + const firstRow = parsedData[0]; + headers = (firstRow as unknown[]).map((cell: unknown, index: number) => { + const cellStr = String(cell || '').trim(); + return cellStr || `Column ${index + 1}`; + }); + setFilePreview({ headers, data: parsedData, fileName: file.name }); + } else { + showBanner({ severity: 'warning', message: 'The file appears to be empty.', scoped: true }); + } + setIsImporting(false); + }; + reader.onerror = () => { + showBanner({ severity: 'error', message: 'Error reading Excel file.', scoped: true }); + setIsImporting(false); + }; + reader.readAsBinaryString(file); + } + } catch { + showBanner({ severity: 'error', message: 'Error importing file. Please try again.', scoped: true }); + setIsImporting(false); + } + + e.target.value = ''; + }; + + const handleConfirmImport = (deviceNameColumn: number) => { + if (!filePreview) return; + + const deviceNames = filePreview.data + .slice(1) + .map((row) => { + const deviceName = row[deviceNameColumn]; + return typeof deviceName === 'string' + ? deviceName.trim() + : String(deviceName || '').trim(); + }) + .filter((name) => name.length > 0); + + if (deviceNames.length === 0) { + showBanner({ severity: 'error', message: 'No valid device names found in the selected column.', scoped: true }); + return; + } + + onDevicesParsed(deviceNames); + setFilePreview(null); + }; + + const handleCancelImport = () => { + setFilePreview(null); + }; + + return ( + + {filePreview && ( + + )} + + + + {shouldBlock && tooltipMessage ? ( + + + + + Import from CSV + + + +

{tooltipMessage}

+
+
+ ) : ( + + )} +
+ ); +}; diff --git a/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx new file mode 100644 index 0000000000..5c8bbbe95a --- /dev/null +++ b/src/vertex-template/components/features/cohorts/edit-cohort-details-modal.tsx @@ -0,0 +1,312 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import ReusableInputField from "@/components/shared/inputfield/ReusableInputField"; +import { Label } from "@/components/ui/label"; + +import { useUpdateCohortDetails, useUpdateCohortName } from "@/core/hooks/useCohorts"; +import { PERMISSIONS } from "@/core/permissions/constants"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { buildCohortName, sanitizeCohortInput, splitCohortName } from "@/core/utils/cohortName"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { DEFAULT_COHORT_TAGS } from "@/core/constants/devices"; + +interface CohortDetailsModalProps { + open: boolean; + cohortDetails: { + name: string; + id: string; + visibility: boolean; + cohort_tags: string[]; + }; + onClose: () => void; +} + +const isStructuredCohortName = (name: string) => { + const parts = name.split("_").filter(Boolean); + return parts.length >= 2 && parts.length <= 3 && parts.every((part) => /^[a-z0-9]+$/.test(part)); +}; + +const CohortDetailsModal: React.FC = ({ + open, + cohortDetails, + onClose, +}) => { + const [form, setForm] = useState({ + name: "", + city: "", + projectName: "", + funder: "", + updateReason: "", + tags: [] as string[], + }); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const updateCohortName = useUpdateCohortName(); + const updateCohortDetails = useUpdateCohortDetails(); + const [showIgnoredTooltip, setShowIgnoredTooltip] = useState({ + city: false, + projectName: false, + funder: false, + }); + const tooltipTimers = useRef | null>>({}); + + useEffect(() => { + if (!open) return; + const { city, projectName, funder } = splitCohortName(cohortDetails.name || ""); + setForm({ + name: cohortDetails.name || "", + city, + projectName, + funder, + updateReason: "", + tags: cohortDetails.cohort_tags || [], + }); + }, [open, cohortDetails]); + + const handleCancel = () => { + const { city, projectName, funder } = splitCohortName(cohortDetails.name || ""); + setForm({ + name: cohortDetails.name || "", + city, + projectName, + funder, + updateReason: "", + tags: cohortDetails.cohort_tags || [], + }); + onClose(); + }; + + const handleSave = async () => { + const tagsChanged = + JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort()); + const isOrganizational = form.tags.includes("organizational"); + const trimmedName = form.name.trim(); + const trimmedCity = form.city.trim(); + const trimmedProject = form.projectName.trim(); + const trimmedReason = form.updateReason.trim(); + const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName); + const derivedName = isOrganizational + ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder)) + : trimmedName; + const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name; + if (!isOrganizational && trimmedName.length === 0) return; + if (isOrganizational && !legacyOrganizationalName && (trimmedCity.length === 0 || trimmedProject.length === 0)) return; + if (requiresUpdateReason && trimmedReason.length === 0) return; + const nameChanged = derivedName.length > 0 && derivedName !== cohortDetails.name; + + if (!nameChanged && !tagsChanged) return onClose(); + + try { + if (isOrganizational && nameChanged) { + await updateCohortName.mutateAsync({ + cohortId: cohortDetails.id, + name: derivedName, + updateReason: trimmedReason, + }); + if (tagsChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { cohort_tags: form.tags }, + }); + } + } else if (!isOrganizational && nameChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { name: derivedName, cohort_tags: form.tags }, + }); + } else if (tagsChanged) { + await updateCohortDetails.mutateAsync({ + cohortId: cohortDetails.id, + data: { cohort_tags: form.tags }, + }); + } + onClose(); + showBannerWithDelay({ + severity: 'success', + message: 'Cohort details updated successfully', + scoped: false, + }); + } catch (error) { + showBanner({ + severity: 'error', + message: `Failed to update cohort: ${getApiErrorMessage(error)}`, + scoped: true, + }); + } + }; + + const handleSanitizedInputChange = ( + fieldKey: "city" | "projectName" | "funder", + value: string + ) => { + const sanitized = sanitizeCohortInput(value); + if (/[^a-zA-Z0-9]/.test(value)) { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: true })); + if (tooltipTimers.current[fieldKey]) { + clearTimeout(tooltipTimers.current[fieldKey] as ReturnType); + } + tooltipTimers.current[fieldKey] = setTimeout(() => { + setShowIgnoredTooltip((prev) => ({ ...prev, [fieldKey]: false })); + }, 1500); + } + setForm((prev) => ({ ...prev, [fieldKey]: sanitized })); + }; + + const isOrganizational = form.tags.includes("organizational"); + const trimmedName = form.name.trim(); + const trimmedCity = form.city.trim(); + const trimmedProject = form.projectName.trim(); + const trimmedReason = form.updateReason.trim(); + const legacyOrganizationalName = isOrganizational && !isStructuredCohortName(trimmedName); + const derivedName = isOrganizational + ? (legacyOrganizationalName ? trimmedName : buildCohortName(trimmedCity, trimmedProject, form.funder)) + : trimmedName; + const tagsChanged = + JSON.stringify(form.tags.slice().sort()) !== JSON.stringify((cohortDetails.cohort_tags || []).slice().sort()); + const isSaving = updateCohortName.isPending || updateCohortDetails.isPending; + const requiresUpdateReason = isOrganizational && derivedName !== cohortDetails.name; + const canSave = + (isOrganizational + ? (legacyOrganizationalName + ? trimmedName.length > 0 + : trimmedCity.length > 0 && trimmedProject.length > 0) + : trimmedName.length > 0) && + (!requiresUpdateReason || trimmedReason.length > 0) && + derivedName.length > 0 && + (derivedName !== cohortDetails.name || tagsChanged); + + return ( + + Cancel + + {isSaving ? "Saving..." : "Save"} + +
+ } + > +
+
+ + setForm((s) => ({ ...s, tags: values }))} + value={form.tags} + allowCreate={false} + /> +
+ + {isOrganizational ? ( + <> + {legacyOrganizationalName ? ( + setForm((s) => ({ ...s, name: e.target.value }))} + placeholder="Enter cohort name" + disabled={isSaving} + required + description="Legacy cohort name detected. Update will preserve this format." + /> + ) : ( + <> +
+ + + +
+ handleSanitizedInputChange("city", e.target.value)} + placeholder="e.g. Nairobi" + disabled={isSaving} + required + /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("projectName", e.target.value)} + placeholder="e.g. WRI" + disabled={isSaving} + required + /> +
+
+ +

Special character ignored

+
+
+
+ + + +
+ handleSanitizedInputChange("funder", e.target.value)} + placeholder="e.g. EPIC" + disabled={isSaving} + /> +
+
+ +

Special character ignored

+
+
+
+
+
+ Cohort name will be: {derivedName || "-"} +
+ + )} + setForm((s) => ({ ...s, updateReason: e.target.value }))} + placeholder="Why is this name changing?" + disabled={isSaving} + required + /> + + ) : ( + setForm((s) => ({ ...s, name: e.target.value }))} + placeholder="Enter cohort name" + disabled={isSaving} + required + /> + )} +
+ + ); +}; + +export default CohortDetailsModal; diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx new file mode 100644 index 0000000000..84d0cb3918 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/unassign-cohort-devices.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useCohortDetails, useCohorts, useUnassignDevicesFromCohort } from "@/core/hooks/useCohorts"; +import { ComboBox } from "@/components/ui/combobox"; +import { MultiSelectCombobox, Option } from "@/components/ui/multi-select"; +import { Cohort } from "@/app/types/cohorts"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { Device } from "@/app/types/devices"; +import { useBanner } from "@/context/banner-context"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; + +interface UnassignCohortDevicesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDevices?: Device[]; + onSuccess?: () => void; + cohortId?: string; + cohortDevices?: Device[]; +} + +const formSchema = z.object({ + cohortId: z.string().min(1, { + message: "Please select a cohort.", + }), + devices: z.array(z.string()).min(1, { + message: "Please select at least one device.", + }), +}); + +export function UnassignCohortDevicesDialog({ + open, + onOpenChange, + selectedDevices, + onSuccess, + cohortId, + cohortDevices = [], +}: UnassignCohortDevicesDialogProps) { + const { cohorts } = useCohorts(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const { mutate: unassignDevices, isPending: isUnassigning } = useUnassignDevicesFromCohort({ + onSuccess: (variables) => { + showBannerWithDelay({ + severity: 'success', + message: `${variables.device_ids.length} device(s) removed from cohort successfully`, + scoped: false, + }); + }, + onError: (error) => { + showBanner({ + severity: 'error', + message: `Failed to remove devices: ${getApiErrorMessage(error)}`, + scoped: true, + }); + }, + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + cohortId: cohortId || "", + devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [], + }, + }); + + const deviceOptions: Option[] = useMemo(() => { + const devicesToShow = selectedDevices && selectedDevices.length > 0 ? selectedDevices : cohortDevices; + return devicesToShow + .map((device) => ({ + value: device._id || "", + label: device.long_name || device.name || `Device ${device._id}`, + })) + .filter((option) => option.value); + }, [cohortDevices, selectedDevices]); + + const watchedCohortId = form.watch("cohortId"); + const { data: fetchedCohort } = useCohortDetails( + watchedCohortId, + { enabled: !selectedDevices?.length && !cohortDevices.length && !!watchedCohortId } + ); + + const dynamicDeviceOptions: Option[] = useMemo(() => { + if (deviceOptions.length) return deviceOptions; + const list = fetchedCohort?.devices || []; + return list + .filter((d: Device) => !!d._id) + .map((d: Device) => ({ + value: d._id!, + label: d.long_name || d.name || `Device ${d._id}`, + })); + }, [deviceOptions, fetchedCohort]); + + useEffect(() => { + if (open) { + form.reset({ + cohortId: cohortId || "", + devices: selectedDevices?.map(d => d._id).filter((id): id is string => !!id) || [], + }); + } + }, [open, selectedDevices, form, cohortId]); + + function onSubmit(values: z.infer) { + unassignDevices( + { + cohortId: values.cohortId, + device_ids: values.devices, + }, + { + onSuccess: () => { + onOpenChange(false); + form.reset(); + onSuccess?.(); + }, + } + ); + } + + const handleOpenChange = (newOpen: boolean) => { + onOpenChange(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + return ( + handleOpenChange(false)} + title="Remove devices from cohort" + subtitle={`${form.watch("devices")?.length || 0} device(s) selected`} + size="lg" + maxHeight="max-h-[70vh]" + primaryAction={{ + label: "Remove", + onClick: form.handleSubmit(onSubmit), + disabled: !form.watch("cohortId") || !form.watch("devices")?.length || isUnassigning, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => handleOpenChange(false), + variant: "outline", + disabled: isUnassigning, + }} + > +
+ + ( + + + Cohort * + + + ({ + value: cohort._id, + label: cohort.name, + }))} + value={field.value} + onValueChange={field.onChange} + placeholder="Select a cohort" + searchPlaceholder="Search cohorts..." + emptyMessage="No cohorts found" + disabled={!!cohortId} + className="w-full" + allowCustomInput={false} + /> + + + + )} + /> + + ( + + + Devices * + + + + + + + )} + /> + + +
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx new file mode 100644 index 0000000000..8d7a3e1cd9 --- /dev/null +++ b/src/vertex-template/components/features/cohorts/unassign-cohort-from-group.tsx @@ -0,0 +1,83 @@ +"use client"; + +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import ReusableButton from "@/components/shared/button/ReusableButton"; +import { Group } from "@/app/types/groups"; +import { useUnassignCohortsFromGroup } from "@/core/hooks/useCohorts"; + +interface UnassignCohortFromGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organization: Group | null; + cohortId: string; + cohortName: string; + onSuccess?: () => void; +} + +export function UnassignCohortFromGroupDialog({ + open, + onOpenChange, + organization, + cohortId, + cohortName, + onSuccess, +}: UnassignCohortFromGroupDialogProps) { + const { mutate: unassignFromGroup, isPending } = useUnassignCohortsFromGroup({ + onSuccess: () => { + onSuccess?.(); + }, + }); + + const handleConfirm = () => { + if (!organization) return; + + unassignFromGroup( + { + groupId: organization._id, + cohortIds: [cohortId], + }, + { + onSuccess: () => { + onOpenChange(false); + }, + } + ); + }; + + return ( + !isPending && onOpenChange(false)} + title="Unassign Organization" + size="md" + customFooter={ +
+ onOpenChange(false)} + disabled={isPending} + > + Cancel + + + {isPending ? "Unassigning..." : "Confirm Unassign"} + +
+ } + > +
+

+ Are you sure you want to unassign {organization?.grp_title} from cohort {cohortName}? +

+

+ This action will remove the organization's access to this cohort. This cannot be undone. +

+
+
+ ); +} \ No newline at end of file diff --git a/src/vertex-template/components/features/dashboard/stat-card.tsx b/src/vertex-template/components/features/dashboard/stat-card.tsx new file mode 100644 index 0000000000..702ba5cb68 --- /dev/null +++ b/src/vertex-template/components/features/dashboard/stat-card.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Info } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCallback } from "react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + title: string; + value: number | string; + description?: string; + icon: React.ReactNode; + isLoading?: boolean; + variant?: "default" | "success" | "warning" | "destructive" | "info" | "primary"; + size?: "sm" | "md" | "lg"; + onClick?: () => void; + isActive?: boolean; +} + +export const StatCard = ({ + title, + value, + description, + icon, + isLoading, + variant = "default", + size = "md", + onClick, + isActive = false, +}: StatCardProps) => { + const getContainerStyles = useCallback(() => { + const baseStyles = "rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 relative overflow-hidden p-0 transition-all duration-200"; + + if (isLoading) { + return baseStyles; + } + + const interactiveStyles = onClick ? "cursor-pointer hover:shadow-md hover:border-primary/50" : ""; + const activeStyles = isActive ? "ring-2 ring-primary border-primary shadow-md" : ""; + return cn(baseStyles, interactiveStyles, activeStyles); + }, [onClick, isActive, isLoading]); + + const getIconColor = useCallback(() => { + switch (variant) { + case "success": + return "text-green-500 bg-green-50 dark:bg-green-900/10"; + case "warning": + return "text-yellow-500 bg-yellow-50 dark:bg-yellow-900/10"; + case "destructive": + return "text-red-500 bg-red-50 dark:bg-red-900/10"; + case "info": + return "text-blue-500 bg-blue-50 dark:bg-blue-900/10"; + case "primary": + return "text-primary bg-primary/10"; + default: + return "text-gray-500 bg-gray-50 dark:bg-gray-900/10"; + } + }, [variant]); + + // Size configurations + const sizeConfig = { + sm: { + padding: "p-3", + gap: "gap-2", + titleSize: "text-sm", + valueSize: "text-xl", + iconContainer: "p-1.5 rounded-lg", + iconSize: "w-4 h-4", + }, + md: { + padding: "p-4", + gap: "gap-4", + titleSize: "text-md", + valueSize: "text-3xl", + iconContainer: "p-2 rounded-xl", + iconSize: "w-6 h-6", + }, + lg: { + padding: "p-6", + gap: "gap-6", + titleSize: "text-lg", + valueSize: "text-4xl", + iconContainer: "p-3 rounded-2xl", + iconSize: "w-8 h-8", + }, + }; + + const config = sizeConfig[size]; + + if (isLoading) { + return ( +
+ + +
+ + {description && } +
+ +
+
+
+ ); + } + + // Clone icon to apply size classes if it's a valid element + // But since we pass icon as a node, we might need to wrap it or expect correct size + // For flexibility, we'll suggest passing icon with correct size or override here if possible + // Using a wrapper div to constrain size might be safer if the icon is SVG + + return ( +
+ { + if (onClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onClick(); + } + }} + role={onClick ? "button" : undefined} + tabIndex={onClick ? 0 : undefined} + aria-label={onClick ? `View ${title.toLowerCase()}` : undefined} + > + +
+
+
+ {title} +
+ {description && ( + + + + + + +

{description}

+
+
+
+ )} +
+
+
+ + {value} + +
+ {icon} +
+
+
+
+
+ ); +}; diff --git a/src/vertex-template/components/features/dashboard/stats-cards.tsx b/src/vertex-template/components/features/dashboard/stats-cards.tsx new file mode 100644 index 0000000000..9b10f58ffd --- /dev/null +++ b/src/vertex-template/components/features/dashboard/stats-cards.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useDeviceCount } from "@/core/hooks/useDevices"; +import { usePersonalUserCohorts } from "@/core/hooks/useCohorts"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { + AqMonitor, + AqCollocation, + AqWifiOff, + AqData, +} from "@airqo/icons-react"; +import { useMemo } from "react"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { useAppSelector } from "@/core/redux/hooks"; +import { getStatusExplanation } from "@/core/utils/status"; +import { StatCard } from "./stat-card"; + +export const DashboardStatsCards = () => { + const { data: session } = useSession(); + const { userScope } = useUserContext(); + const user = useAppSelector((state) => state.user.userDetails); + + const isPersonalScope = userScope === 'personal'; + const userId = (session?.user as { id?: string })?.id || user?._id; + + // Fetch personal user cohorts + const { data: personalCohortIds = [], isLoading: isLoadingPersonalCohorts } = usePersonalUserCohorts( + userId, + { enabled: !!userId && isPersonalScope } + ); + + const shouldEnable = isPersonalScope ? personalCohortIds.length > 0 : true; + + // Use useDeviceCount for both scopes + // For personal scope, pass the user's cohort IDs + // If personal scope and no cohorts, the hook is disabled + const deviceCountQuery = useDeviceCount({ + enabled: shouldEnable, + cohortIds: isPersonalScope ? personalCohortIds : undefined, + }); + + const isLoading = (isPersonalScope && isLoadingPersonalCohorts) || (shouldEnable && deviceCountQuery.isLoading); + + const metrics = useMemo(() => { + const summary = deviceCountQuery.data?.summary; + if (summary) { + return { + total: summary.total_monitors, + operational: summary.operational, + transmitting: summary.transmitting, + notTransmitting: summary.not_transmitting, + dataAvailable: summary.data_available, + }; + } + + return { + total: 0, + operational: 0, + transmitting: 0, + notTransmitting: 0, + dataAvailable: 0, + }; + }, [deviceCountQuery.data]); + + const router = useRouter(); + + const handleNavigation = (queryString: string) => { + const basePath = isPersonalScope ? "/devices/my-devices" : "/devices/overview"; + router.push(`${basePath}${queryString}`); + }; + + return ( +
+
+ } + isLoading={isLoading} + onClick={() => handleNavigation('')} + variant="primary" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=operational')} + variant="success" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=transmitting')} + variant="info" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=not_transmitting')} + variant="default" + size="md" + /> + + } + isLoading={isLoading} + onClick={() => handleNavigation('?status=data_available')} + variant="warning" + size="md" + /> +
+
+ ); +}; diff --git a/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx new file mode 100644 index 0000000000..16a920c32a --- /dev/null +++ b/src/vertex-template/components/features/devices/add-maintenance-log-modal.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React, { useState } from "react"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { DatePicker } from "@/components/ui/date-picker"; +import { useAddMaintenanceLog } from "@/core/hooks/useDevices"; +import { useUserContext } from "@/core/hooks/useUserContext"; +import { MultiSelectCombobox } from "@/components/ui/multi-select"; +import { Switch } from "@/components/ui/switch"; +import ReusableDialog from "@/components/shared/dialog/ReusableDialog"; +import { useBanner } from "@/context/banner-context"; +import { useBannerWithDelay } from "@/core/hooks/useBannerWithDelay"; +import { getApiErrorMessage } from "@/core/utils/getApiErrorMessage"; + +interface AddMaintenanceLogModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deviceName: string; +} + +interface Option { + value: string + label: string +} + +const createTagOption = (label: string): Option => ({ + value: label.toLowerCase(), + label: label, +}) + +const DEFAULT_TAGS: Option[] = [ + createTagOption("Dust blowing and sensor cleaning"), + createTagOption("Site update check"), + createTagOption("Device equipment check"), + createTagOption("Power circuitry and components works"), + createTagOption("GPS module works/replacement"), + createTagOption("GSM module works/replacement"), + createTagOption("Battery works/replacement"), + createTagOption("Power supply works/replacement"), + createTagOption("Antenna works/replacement"), + createTagOption("Mounts replacement"), + createTagOption("Software checks/re-installation"), + createTagOption("PCB works/replacement"), + createTagOption("Temp/humidity sensor works/replacement"), + createTagOption("Air quality sensor(s) works/replacement"), +] + +const AddMaintenanceLogModal: React.FC = ({ open, onOpenChange, deviceName }) => { + const [date, setDate] = useState(new Date()); + const [description, setDescription] = useState(""); + const [selectedTags, setSelectedTags] = useState([]) + const [maintenanceType, setMaintenanceType] = useState<"preventive" | "corrective">("preventive"); + + const { userDetails } = useUserContext(); + const { showBanner } = useBanner(); + const { showBannerWithDelay } = useBannerWithDelay(); + const addMaintenanceLog = useAddMaintenanceLog(); + + // Reset form when modal opens/closes + React.useEffect(() => { + if (!open) { + setDate(new Date()); + setDescription(""); + setSelectedTags([]); + setMaintenanceType("preventive"); + } + }, [open]); + + const handleSubmit = async () => { + if (!date || selectedTags.length === 0) { + showBanner({ severity: 'error', message: 'Please fill all required fields', scoped: true }); + return; + } + + const logData = { + date: date.toISOString(), + tags: selectedTags.map(tag => tag.toLowerCase()), + description, + userName: userDetails?.email || "", + maintenanceType, + email: userDetails?.email || "", + firstName: userDetails?.firstName || "", + lastName: userDetails?.lastName || "", + user_id: userDetails?._id || "", + }; + + try { + await addMaintenanceLog.mutateAsync({ deviceName, logData }); + showBannerWithDelay({ + severity: 'success', + title: 'Success', + message: `Maintenance log for ${deviceName} has been added successfully.`, + scoped: false + }, 300); + onOpenChange(false); + } catch (error) { + showBanner({ severity: 'error', message: `Failed to Add Maintenance Log: ${getApiErrorMessage(error)}`, scoped: true }); + } + }; + + return ( + onOpenChange(false)} + title={`Add Maintenance Log for ${deviceName}`} + className="w-[70vw] h-[70vh] max-w-none max-h-none m-0 p-0" + primaryAction={{ + label: addMaintenanceLog.isPending ? "Saving..." : "Save Log", + onClick: handleSubmit, + disabled: addMaintenanceLog.isPending, + }} + secondaryAction={{ + label: "Cancel", + onClick: () => onOpenChange(false), + variant: "outline", + disabled: addMaintenanceLog.isPending, + }} + > +
+
+ + Preventive + setMaintenanceType(checked ? 'corrective' : 'preventive')} + /> + Corrective +
+
+ + +
+
+ + +
+
+ +