Files
hihala-dashboard/docs/superpowers/plans/2026-03-26-erp-api-migration.md
fahed 9c1552e439 chore: add ERP API migration plan (pre-migration snapshot)
Preserves current NocoDB-based state before switching museum
sales data source to the Hono ERP API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:43:20 +03:00

23 KiB
Raw Blame History

ERP API Migration — Replace NocoDB Museum Data with Hono ERP API

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace NocoDB as the museum sales data source with the Hono ERP API, keeping NocoDB only for PilgrimStats. Add "channel" as a new filterable dimension (replacing "district").

Architecture: The Hono ERP API returns transaction-level sales data (each sale with nested Products[]). We authenticate via JWT (POST /auth/login), then fetch by date range (GET /api/getbydate). Client-side code aggregates transactions into daily/museum/channel records that match the existing MuseumRecord shape. NocoDB remains solely for PilgrimStats.

Tech Stack: React 19, TypeScript (strict), Vite, Chart.js, Hono ERP REST API (Azure-hosted)

Security note: ERP credentials are stored as VITE_* env vars which get bundled into the client-side JS (same pattern as the existing NocoDB token). The ERP account (sales_user) is a read-only reporting account. A server-side proxy can be added later if needed.

Compilation note: Tasks 39 form an atomic migration — the codebase will not compile between them. They must be executed as a single batch on one branch. Individual commits are for traceability, not for producing intermediate working states.


File Structure

Action File Responsibility
Create src/utils/fetchHelpers.ts Shared fetchWithTimeout + fetchWithRetry (extracted from dataService)
Create src/config/museumMapping.ts Product description → museum mapping + channel label mapping
Create src/services/erpService.ts ERP API auth, fetching, transaction → MuseumRecord aggregation
Modify src/types/index.ts Add channel to MuseumRecord, add ERP API types, remove NocoDB museum types, remove DistrictMuseumMap
Modify src/services/dataService.ts Replace NocoDB fetch with ERP fetch, replace district→channel in grouping/filter functions, remove revenue_incl_tax fallbacks
Modify src/components/Dashboard.tsx Replace district filter/chart with channel, update filterKeys array, update all district references
Modify src/components/Comparison.tsx Replace district filter with channel
Modify src/components/Slides.tsx Full refactor: replace DistrictMuseumMap prop threading (10+ call sites), SlideConfig.districtchannel, update SlideEditor/SlidePreview/PreviewMode interfaces, update generateSlideHTML/generateChartScripts
Modify src/locales/en.json Replace district→channel keys, add charts.channel, update error messages
Modify src/locales/ar.json Arabic translations for all channel-related keys
Modify src/App.tsx Update env var check for ERP config
Modify .env.local Add ERP API credentials
Modify .env.example Update to reflect ERP as primary museum data source

Task 1: Environment Configuration

Files:

  • Modify: .env.local

  • Modify: .env.example

  • Step 1: Add ERP env vars to .env.local

# Hono ERP API (museum sales data)
VITE_ERP_API_URL=<see .env.local on machine>
VITE_ERP_API_CODE=<see .env.local on machine>
VITE_ERP_USERNAME=<see .env.local on machine>
VITE_ERP_PASSWORD=<see .env.local on machine>

The actual values are in the Postman collection at ~/Downloads/hono-erp Copy.postman_collection.json. Read that file for the credentials. Keep existing NocoDB vars (needed for PilgrimStats).

  • Step 2: Update .env.example

Update to document both data sources:

# Hono ERP API (museum sales data — primary source)
VITE_ERP_API_URL=https://hono-erp.azurewebsites.net
VITE_ERP_API_CODE=your-api-function-key
VITE_ERP_USERNAME=your-username
VITE_ERP_PASSWORD=your-password

# NocoDB (PilgrimStats only)
VITE_NOCODB_URL=http://localhost:8090
VITE_NOCODB_TOKEN=your-token
VITE_NOCODB_BASE_ID=your-base-id
  • Step 3: Commit (.env.local is gitignored — only commit .env.example)
git add .env.example
git commit -m "feat: update env example for ERP API as primary museum data source"

Task 2: Extract Fetch Helpers

Files:

  • Create: src/utils/fetchHelpers.ts

  • Modify: src/services/dataService.ts (update imports)

  • Step 1: Extract fetchWithTimeout and fetchWithRetry

Move these two functions from dataService.ts into src/utils/fetchHelpers.ts. Export them. Also move the constants FETCH_TIMEOUT_MS and MAX_RETRIES.

  • Step 2: Update dataService.ts imports

Replace the function definitions with:

import { fetchWithTimeout, fetchWithRetry } from '../utils/fetchHelpers';
  • Step 3: Verify build still works
npm run build
  • Step 4: Commit
git add src/utils/fetchHelpers.ts src/services/dataService.ts
git commit -m "refactor: extract fetch helpers to shared util"

Task 3: Museum Mapping Configuration

Files:

  • Create: src/config/museumMapping.ts

Definitive mapping of all 47 known product descriptions to museum names, plus channel label mappings.

  • Step 1: Create museum mapping file

The mapping uses keyword matching with a priority order — this matters for combo tickets. Check keywords in this order (first match wins):

  1. Revelation Exhibition — keywords: "Revelation", "الوحي" (catches combo tickets mentioning both الوحي and القرآن الكريم)
  2. Creation Story Museum — keywords: "Creation Story", "قصة الخلق"
  3. Holy Quraan Museum — keywords: "Holy Quraan", "القرآن الكريم"
  4. Trail To Hira Cave — keywords: "Trail To Hira", "غار حراء"
  5. Makkah Greets Us — keywords: "Makkah Greets"
  6. VIP Experience — keywords: "VIP Experience"

If no match: return "Other".

Channel label mapping:

const CHANNEL_LABELS: Record<string, string> = {
  'B2C': 'HiHala Website/App',
  'B2B': 'B2B',
  'POS': 'POS',
  'Safiyyah POS': 'Safiyyah POS',
  'Standalone': 'Standalone',
  'Mobile': 'Mobile',
  'Viva': 'Viva',
  'IT': 'IT'
};

Exports:

  • getMuseumFromProduct(productDescription: string): string

  • getChannelLabel(operatingAreaName: string): string

  • MUSEUM_NAMES: string[]

  • CHANNEL_LABELS: Record<string, string>

  • Step 2: Commit

git add src/config/museumMapping.ts
git commit -m "feat: add product-to-museum and channel mapping config"

Task 4: TypeScript Types Update

Files:

  • Modify: src/types/index.ts

  • Step 1: Update MuseumRecord

Replace district with channel. Remove museum_code and revenue_incl_tax (legacy).

export interface MuseumRecord {
  date: string;
  museum_name: string;
  channel: string;        // was: district
  visits: number;         // = sum of PeopleCount per product line
  tickets: number;        // = sum of UnitQuantity per product line
  revenue_gross: number;  // = sum of TotalPrice (includes VAT)
  revenue_net: number;    // = revenue_gross - sum of TaxAmount
  year: string;
  quarter: string;
}
  • Step 2: Add ERP API types
export interface ERPProduct {
  ProductDescription: string;
  SiteDescription: string | null;
  UnitQuantity: number;
  PeopleCount: number;
  TaxAmount: number;
  TotalPrice: number;
}

export interface ERPPayment {
  PaymentMethodDescription: string;
}

export interface ERPSaleRecord {
  SaleId: number;
  TransactionDate: string;
  CustIdentification: string;
  OperatingAreaName: string;
  Payments: ERPPayment[];
  Products: ERPProduct[];
}

export interface ERPLoginResponse {
  token: string;
}
  • Step 3: Update Filters interface
export interface Filters {
  year: string;
  channel: string;    // was: district
  museum: string;
  quarter: string;
}

export interface DateRangeFilters {
  channel: string;    // was: district
  museum: string;
}
  • Step 4: Remove obsolete types

Remove: NocoDBDistrict, NocoDBMuseum, NocoDBDailyStat, DistrictMuseumMap.

Update SlideConfig.districtSlideConfig.channel.

  • Step 5: Commit
git add src/types/index.ts
git commit -m "feat: update types for ERP API — channel replaces district"

Task 5: ERP Service

Files:

  • Create: src/services/erpService.ts

  • Step 1: Implement auth + fetch

import { fetchWithRetry } from '../utils/fetchHelpers';
import { getMuseumFromProduct, getChannelLabel } from '../config/museumMapping';
import type { ERPSaleRecord, ERPLoginResponse, MuseumRecord } from '../types';

const ERP_API_URL = import.meta.env.VITE_ERP_API_URL || '';
const ERP_API_CODE = import.meta.env.VITE_ERP_API_CODE || '';
const ERP_USERNAME = import.meta.env.VITE_ERP_USERNAME || '';
const ERP_PASSWORD = import.meta.env.VITE_ERP_PASSWORD || '';

let cachedToken: string | null = null;

async function login(): Promise<string> { /* POST /auth/login, cache token */ }
async function fetchSalesByDateRange(startDate: string, endDate: string): Promise<ERPSaleRecord[]> { /* GET /api/getbydate with Bearer token + code param */ }

Auth: token cached in module-level variable, re-login on 401.

Fetch strategy: generate month boundaries from 2024-01 to current month, fetch all in parallel with Promise.all.

  • Step 2: Implement aggregation function
export function aggregateTransactions(sales: ERPSaleRecord[]): MuseumRecord[]

For each sale:

  1. Extract date from TransactionDate (split on space, take first part → "2025-01-01")
  2. Get channel from OperatingAreaName via getChannelLabel()
  3. For each product in Products[]:
    • Get museum from getMuseumFromProduct(product.ProductDescription)
    • Accumulate into key ${date}|${museum}|${channel}:
      • visits += product.PeopleCount
      • tickets += product.UnitQuantity
      • revenue_gross += product.TotalPrice
      • revenue_net += (product.TotalPrice - product.TaxAmount)
  4. Convert accumulated map to MuseumRecord[] with computed year and quarter

Negative quantities (refunds) sum correctly by default — no special handling needed.

  • Step 3: Export main entry point
export async function fetchFromERP(): Promise<MuseumRecord[]>

This orchestrates: login → fetch all months → aggregate → return.

  • Step 4: Commit
git add src/services/erpService.ts
git commit -m "feat: add ERP service with auth, fetch, and aggregation"

Task 6: Refactor dataService.ts

Files:

  • Modify: src/services/dataService.ts

  • Step 1: Replace NocoDB museum fetch with ERP

  • Remove: discoverTableIds(), fetchNocoDBTable(), fetchFromNocoDB(), MuseumMapEntry, NocoDB museum env var reads

  • Keep: NocoDB config for PilgrimStats path only (fetchPilgrimStats() unchanged)

  • Import fetchFromERP from erpService

  • Update fetchData() and refreshData() to call fetchFromERP() instead of fetchFromNocoDB()

  • Update config check: if (!ERP_API_URL || !ERP_API_CODE || !ERP_USERNAME || !ERP_PASSWORD) → throw DataError('config')

  • Step 2: Replace all district→channel in data functions

  • filterData(): filters.districtfilters.channel, row.districtrow.channel

  • filterDataByDateRange(): same

  • groupByDistrict()→ rename to groupByChannel(), change row.districtrow.channel

  • getUniqueDistricts()→ rename to getUniqueChannels(), change r.districtr.channel

  • getDistrictMuseumMap()delete

  • getMuseumsForDistrict()→ replace with getUniqueMuseums(data: MuseumRecord[]): string[] returning all unique museum names sorted

  • Remove all revenue_incl_tax fallback references (e.g. || row.revenue_incl_tax || 0 in groupBy functions and calculateMetrics)

  • Step 3: Update type imports

Remove unused NocoDB types from the import block. Add imports for new ERP-related types if needed.

  • Step 4: Commit
git add src/services/dataService.ts
git commit -m "refactor: replace NocoDB museum fetch with ERP API, district→channel"

Task 7: Update Dashboard Component

Files:

  • Modify: src/components/Dashboard.tsx

  • Step 1: Update imports

  • getUniqueDistrictsgetUniqueChannels

  • getDistrictMuseumMap → remove

  • getMuseumsForDistrictgetUniqueMuseums

  • groupByDistrictgroupByChannel

  • Step 2: Update filter state and controls

  • defaultFilters: district: 'all'channel: 'all'

  • filterKeys array (line 32): 'district''channel' — this controls URL param serialization

  • All filters.districtfilters.channel

  • District <select> → Channel <select> with t('filters.channel') label

  • Museum <select>: no longer cascaded from district/channel — just show all getUniqueMuseums(data)

  • availableMuseums memo: getMuseumsForDistrict(districtMuseumMap, filters.district)getUniqueMuseums(data)

  • Remove districtMuseumMap memo entirely

  • Step 3: Update charts

  • districtDatachannelData using groupByChannel(filteredData, includeVAT)

  • "District Performance" → "Channel Performance" using t('dashboard.channelPerformance')

  • Chart carousel label t('charts.district')t('charts.channel') (line 88)

  • Capture rate chart: filters.districtfilters.channel, r.districtr.channel

  • Step 4: Update quarterly table

Replace filters.districtfilters.channel and r.districtr.channel

  • Step 5: Commit
git add src/components/Dashboard.tsx
git commit -m "feat: update Dashboard — channel replaces district, new channel chart"

Task 8: Update Comparison Component

Files:

  • Modify: src/components/Comparison.tsx

  • Step 1: Update imports and filter references

  • Replace getUniqueDistrictsgetUniqueChannels

  • Remove getDistrictMuseumMap, getMuseumsForDistrict

  • Add getUniqueMuseums

  • Replace all district filter references with channel (includes URL params, DateRangeFilters usage, <select> elements)

  • Museum filter: use getUniqueMuseums(data) (no longer cascaded)

  • Step 2: Commit

git add src/components/Comparison.tsx
git commit -m "feat: update Comparison — channel replaces district"

Task 9: Update Slides Component (FULL REFACTOR)

Files:

  • Modify: src/components/Slides.tsx

This is a significant change — Slides.tsx has 30+ district references across 10+ call sites including function signatures, prop interfaces, and HTML export generation.

  • Step 1: Update imports

  • Replace getUniqueDistrictsgetUniqueChannels

  • Replace getDistrictMuseumMap → remove

  • Replace getMuseumsForDistrictgetUniqueMuseums

  • Remove import of DistrictMuseumMap type

  • Step 2: Update component interfaces

All three interfaces pass districts: string[] and districtMuseumMap: DistrictMuseumMap:

// SlideEditorProps (line 25): districts→channels, remove districtMuseumMap
interface SlideEditorProps {
  slide: SlideConfig;
  onUpdate: (updates: Partial<SlideConfig>) => void;
  channels: string[];
  museums: string[];   // flat list, independent of channel
  data: MuseumRecord[];
  chartTypes: ChartTypeOption[];
  metrics: MetricOption[];
}

// SlidePreviewProps (line 35): same pattern
interface SlidePreviewProps {
  slide: SlideConfig;
  data: MuseumRecord[];
  channels: string[];
  museums: string[];
  metrics: MetricOption[];
}

// PreviewModeProps (line 43): same pattern
interface PreviewModeProps {
  slides: SlideConfig[];
  data: MuseumRecord[];
  channels: string[];
  museums: string[];
  currentSlide: number;
  setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
  onExit: () => void;
  metrics: MetricOption[];
}
  • Step 3: Update Slides() main function

  • districts memo → channels using getUniqueChannels(data)

  • districtMuseumMap memo → museums using getUniqueMuseums(data)

  • defaultSlide.district: 'all'channel: 'all'

  • Update all prop passing: districts={districts} districtMuseumMap={districtMuseumMap}channels={channels} museums={museums}

  • Step 4: Update SlideEditor function

  • getMuseumsForDistrict(districtMuseumMap, slide.district) → just use museums prop directly

  • Filter label: t('filters.district')t('filters.channel')

  • <select> for district → channel: slide.districtslide.channel, onUpdate({ district: ... })onUpdate({ channel: ... })

  • Museum select: no longer cascaded, just show all museums

  • Step 5: Update SlidePreview function

  • district: slide.districtchannel: slide.channel in filterDataByDateRange calls

  • Step 6: Update generateSlideHTML and generateChartScripts

  • Function signatures: remove districts: string[] and districtMuseumMap: DistrictMuseumMap params, add channels: string[] and museums: string[]

  • Internal references: slide.districtslide.channel

  • filterDataByDateRange calls: district:channel:

  • Step 7: Commit

git add src/components/Slides.tsx
git commit -m "feat: update Slides — full district→channel refactor across all interfaces"

Task 10: Update Locale Files

Files:

  • Modify: src/locales/en.json

  • Modify: src/locales/ar.json

  • Step 1: Update English translations

Replace/add:

{
  "filters": {
    "channel": "Channel",
    "allChannels": "All Channels"
  },
  "charts": {
    "channel": "Channel"
  },
  "dashboard": {
    "subtitle": "Museum analytics from Hono ERP",
    "channelPerformance": "Channel Performance"
  },
  "errors": {
    "config": "The dashboard is not configured. Please set up the ERP API connection."
  }
}

Remove: filters.district, filters.allDistricts, charts.district, dashboard.districtPerformance.

  • Step 2: Update Arabic translations
{
  "filters": {
    "channel": "القناة",
    "allChannels": "جميع القنوات"
  },
  "charts": {
    "channel": "القناة"
  },
  "dashboard": {
    "subtitle": "تحليلات المتاحف من نظام Hono ERP",
    "channelPerformance": "أداء القنوات"
  },
  "errors": {
    "config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API."
  }
}

Remove: filters.district, filters.allDistricts, charts.district, dashboard.districtPerformance.

  • Step 3: Commit
git add src/locales/en.json src/locales/ar.json
git commit -m "feat: update locale files — channel replaces district, ERP error messages"

Task 11: Build Verification & Smoke Test

  • Step 1: Run npm run build to verify TypeScript compiles

All district references should be gone. Any remaining will cause TS errors. Also search for revenue_incl_tax and museum_code — these should be fully removed.

  • Step 2: Run the dev server and verify
npm run dev

Check:

  1. Dashboard loads with data from ERP API (not NocoDB)
  2. Channel filter shows: HiHala Website/App, B2B, POS, Safiyyah POS, Standalone, Mobile, Viva, IT
  3. Museum filter shows: Revelation Exhibition, Creation Story Museum, Holy Quraan Museum, Trail To Hira Cave, Makkah Greets Us, VIP Experience
  4. Museum and channel filters work independently (not cascaded)
  5. All charts render: revenue trend, visitors by museum, revenue by museum, quarterly YoY, channel performance (was district), capture rate
  6. Comparison page works with channel filter
  7. Slides page works — create/preview/export with channel filter
  8. PilgrimStats loads from NocoDB (capture rate chart shows pilgrim data)
  9. Cache works (reload → uses cached data)
  10. Offline fallback works (disconnect → shows cached data with offline badge)
  • Step 3: Commit any fixes

Appendix: All 47 Known Product Descriptions

For reference when building the mapping in Task 3.

Revelation Exhibition (12 products):

  1. Revelation Exhibition - Child
  2. Revelation Exhibition - Groups
  3. Revelation Exhibition - Individuals
  4. Revelation Exhibition - POD
  5. Revelation Exhibition and Trail To Hiraa Cave - Individuals (combo → Revelation)
  6. معرض الوحي - أطفال | Revelation Exhibition - Child
  7. معرض الوحي - أفراد | Revelation Exhibition - Individuals
  8. معرض الوحي - المجموعات | Revelation Exhibition - Group
  9. معرض الوحي - ذوي الإعاقة | Revelation Exhibition - POD
  10. معرض الوحي - مجموعات| Revelation Exhibition - Groups
  11. تذكرة دخول أفراد - معرض الوحي | متحف القرآن الكريم (combo → Revelation, because الوحي matched first)
  12. تذكرة دخول مجموعات - معرض الوحي | متحف القرآن الكريم (combo → Revelation)

Creation Story Museum (21 products):

  1. Creation Story - Groups
  2. Creation Story - Individuals
  3. Creation Story - Groups (extra space variant)
  4. Creation Story - Indviduals - Open Date (typo "Indviduals" is in the source data)
  5. Creation Story Group
  6. Creation Story Individual
  7. Creation Story School
  8. متحف قصة الخلق - أفراد | Creation Story Museum - Individuals
  9. متحف قصة الخلق - مجموعات| Creation Story Museum - Group
  10. متحف قصة الخلق - مدرسة | Creation Story Museum - School
  11. متحف قصة الخلق - أفراد - خصم بولمان زمزم
  12. متحف قصة الخلق - مجموعات - خصم بولمان زمزم
  13. تذكرة دخول متحف قصة الخلق (جامعة) | Creation Story Museum
  14. تذكرة دخول متحف قصة الخلق مخفضة | Creation Story Museum
  15. تذكرة دخول متحف قصة الخلق مخفضة 10 | Creation Story Museum
  16. تذكرة دخول متحف قصة الخلق مخفضة 11.5 | Creation Story Museum
  17. تذكرة دخول متحف قصة الخلق مخفضة 15 | Creation Story Museum
  18. تذكرة دخول متحف قصة الخلق مخفضة 19 | Creation Story Museum
  19. تذكرة مجانية دخول متحف قصة الخلق (ترويجية) | Creation Sto
  20. تذكرة مجانية دخول متحف قصة الخلق (ذوي الهمم) | Creation Sto
  21. تذكرة مجانية دخول متحف قصة الخلق (أطفال) | Creation Story (leading space)

Holy Quraan Museum (8 products):

  1. Holy Quraan Museum - Child
  2. Holy Quraan Museum - Child | متحف القرآن الكريم - أطفال
  3. Holy Quraan Museum - Groups
  4. Holy Quraan Museum - Groups | متحف القرآن الكريم - المجموعات
  5. Holy Quraan Museum - Individu | متحف القرآن الكريم - أفراد
  6. Holy Quraan Museum - Individuals
  7. Holy Quraan Museum - POD
  8. Holy Quraan Museum - POD | متحف القرآن الكريم - ذوي الإعاقة

Trail To Hira Cave (3 products):

  1. Trail To Hira Cave - Car | غار حراء - الصعود بالسيارة
  2. Trail To Hira Cave - Walking
  3. Trail To Hira Cave - Walking | غار حراء - الصعود على الأقدام

Makkah Greets Us (1 product):

  1. Makkah Greets us - Entry Ticket

VIP Experience (1 product):

  1. VIP Experience

Total: 12 + 21 + 8 + 3 + 1 + 1 = 46 products