import 'dart:convert'; import 'dart:math' as math; import 'package:intl/intl.dart'; /// Metabase KPI slugs for `employeeRest/claims-collection-v2`. abstract final class ClaimsKpiSlug { static const policyExposureSummary = 'policy_exposure_summary'; static const premiumAsOnDate = 'premium_as_on_date'; static const claimsExperienceSummary = 'claims_experience_summary'; static const claimAmountByGender = 'claim_amount_by_gender'; static const ageBand = 'age_band'; static const top5Hospitals = 'top_5_hospitals_by_incurred_amount'; static const claimsIncidenceRate = 'claims_incidence_rate'; static const policyStartDate = 'policy_start_date'; static const policyEndDate = 'policy_end_date'; static const insurer = 'insurer'; static const tpa = 'tpa'; static const earnedPremium = 'earned_premium'; static const totalClaims = 'total_claims'; static const incurredAmount = 'incurred_amount'; static const incurredRatio = 'incurred_ratio'; static const projectedClaims = 'projected_claims'; static const projectedRatio = 'projected_ratio'; static const totalReimbursementAmount = 'total_reimbursement_amount'; static const totalReimbursementPct = 'total_reimbursement_amt_pct'; static const cashlessClaimAmt = 'cashless_claim_amt'; static const cashlessClaimPct = 'cashless_claim_amt_pct'; static const totalIncurredByCity = 'total_incurred_by_city'; static const claimAmountByClaimStatus = 'claim_amount_by_claim_status'; static const hospitalsInDetail = 'hospitals_in_detail'; static const hospitalCitySiPregnancy = 'hospital_city_wise_si_limit_pregnancy'; static const pregnancyNormalExceeded = 's_pregnancy_normal_delivery_exceeded_amt'; static const pregnancyCSecAvgExceeded = 's_pregnancy_c_sec_avg_exceeded_amt'; static const cataractExceededClaim = 'cataract_exceeded_claim_amount'; static const cataractAvgExceeded = 'cataract_avg_exceeded_amount'; static const hospitalCitySiCataract = 'hospital_city_wise_si_limit_cataract'; static const totalIncurredByClaimStatus = 'total_incurred_by_cliam_status'; static const inceptionEmpLives = 'inception_emp_lives'; static const currentEmpLives = 'current_emp_lives'; static const claimValueByMonth = 'claim_value_by_month'; static const topAilments = 'top_ailments'; } class ClaimsChartSeries { final List labels; final List values; final double? maxY; const ClaimsChartSeries({ required this.labels, required this.values, this.maxY, }); bool get isEmpty => labels.isEmpty || values.isEmpty; } class ClaimsListItem { final String title; final String value; const ClaimsListItem({required this.title, required this.value}); } class ClaimsCityItem { final String city; final String amount; final double fraction; const ClaimsCityItem({ required this.city, required this.amount, required this.fraction, }); } class ClaimsScatterPoint { final String label; final double y; const ClaimsScatterPoint({required this.label, required this.y}); } class ClaimsRelationshipItem { final String relation; final int claims; final String amount; const ClaimsRelationshipItem({ required this.relation, required this.claims, required this.amount, }); } class ClaimsAilmentItem { final String title; final String value; const ClaimsAilmentItem({required this.title, required this.value}); } /// Parsed view-model for the Claims Overview dashboard. class ClaimsOverviewViewData { final Map kpiBySlug; final String? loadError; const ClaimsOverviewViewData({ required this.kpiBySlug, this.loadError, }); factory ClaimsOverviewViewData.empty({String? error}) => ClaimsOverviewViewData(kpiBySlug: const {}, loadError: error); factory ClaimsOverviewViewData.fromApiResponse(Map response) { return ClaimsOverviewViewData( kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response), ); } /// Merges extra KPI blocks (e.g. from `/kpi/{slug}`) into this view-model. ClaimsOverviewViewData withMergedKpis(Map extraKpis) { if (extraKpis.isEmpty) return this; final merged = Map.from(kpiBySlug)..addAll(extraKpis); return ClaimsOverviewViewData(kpiBySlug: merged, loadError: loadError); } /// Fills overview basics from `getPolicyLevelEmployeeSummaryData` when KPI rows are empty. ClaimsOverviewViewData withPolicyFallback(Map policy) { final merged = Map.from(kpiBySlug); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.policyStartDate, 'policy_start_date', policy['policy_start_date'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.policyEndDate, 'policy_end_date', policy['policy_expiry_date'] ?? policy['policy_end_date'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.policyExposureSummary, 'policy_start_date', policy['policy_start_date'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.policyExposureSummary, 'policy_end_date', policy['policy_expiry_date'] ?? policy['policy_end_date'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.insurer, 'insurer_name', policy['insurer_name'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.policyExposureSummary, 'insurer_name', policy['insurer_name'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.premiumAsOnDate, 'premium_as_on_date', policy['total_premium'], ); ClaimsKpiParser.ensureKpiField( merged, ClaimsKpiSlug.currentEmpLives, 'current_lives', policy['membersCountOfActive'] ?? policy['totalMembersCount'], ); return ClaimsOverviewViewData(kpiBySlug: merged, loadError: loadError); } /// Loads optional KPIs not always present in `/all` (e.g. top ailments). static Future enrichFromApiResponse( Map allResponse, { required Future> Function(String slug) fetchKpi, }) async { var data = ClaimsOverviewViewData.fromApiResponse(allResponse); if (data.topAilments.isNotEmpty) return data; const optionalSlugs = [ ClaimsKpiSlug.topAilments, 'top_5_ailments', 'top_ailments_by_amount', 'top_ailments_by_incurred_amount', 'claims_by_ailment', ]; for (final slug in optionalSlugs) { if (data.kpiBySlug.containsKey(slug)) continue; try { final response = await fetchKpi(slug); final status = response['status']; final ok = status == true || status == 'success' || status == 'Success' || response['success'] == true; if (!ok) continue; final extra = ClaimsKpiParser.normalizeAllKpis(response); if (extra.isEmpty) continue; data = data.withMergedKpis(extra); if (data.topAilments.isNotEmpty) break; } catch (_) { // Optional KPI — ignore fetch errors. } } return data; } dynamic _kpi(String slug) => kpiBySlug[slug] ?? kpiBySlug[_idKey(slug)]; dynamic get _experience => _kpi(ClaimsKpiSlug.claimsExperienceSummary); String _expField(String field, {String fallback = '—'}) => ClaimsKpiParser.rowField(_experience, field, fallback: fallback); ClaimsChartSeries chartSeries( String slug, { String? labelKey, String? valueKey, bool valuesInLakhs = false, }) { return ClaimsKpiParser.labelValueSeries( _kpi(slug), labelKey: labelKey, valueKey: valueKey, valuesInLakhs: valuesInLakhs, ); } ClaimsChartSeries _seriesSortedByValueDesc(ClaimsChartSeries series) { if (series.isEmpty) return series; final pairs = List.generate( series.labels.length, (i) => (series.labels[i], series.values[i]), )..sort((a, b) => b.$2.compareTo(a.$2)); return ClaimsChartSeries( labels: pairs.map((p) => p.$1).toList(), values: pairs.map((p) => p.$2).toList(), maxY: series.maxY, ); } // --- Overview tab --- String get policyStartDate => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyStartDate), 'policy_start_date', fallback: ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyExposureSummary), 'policy_start_date', ), ); String get policyEndDate => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyEndDate), 'policy_end_date', fallback: ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyExposureSummary), 'policy_end_date', ), ); String get premiumAsOnDate => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.premiumAsOnDate), 'premium_as_on_date', ), ); String get earnedPremium => ClaimsKpiParser.formatDisplay(_expField('earned_premium')); String get incurredClaims => ClaimsKpiParser.formatDisplay(_expField('incurred_amount')); String get incurredRatio => _expField('incurred_ratio'); String get projectedClaims => ClaimsKpiParser.formatDisplay(_expField('projected_claims')); String get projectedRatio => _expField('projected_ratio'); String get claimsIncidenceRate => ClaimsKpiParser.formatPercent( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.claimsIncidenceRate), 'claims_incidence_rate', fallback: '', ), ); /// Last refresh time from `claims_incidence_rate.created_at` (dashboard KPI). String? get liveUpdatedAt { final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.claimsIncidenceRate)); if (rows.isEmpty || rows.first is! Map) return null; final raw = ClaimsKpiParser.rowValue(rows.first as Map, 'created_at'); if (raw == null) return null; final text = raw.toString().trim(); if (text.isEmpty) return null; return ClaimsKpiParser.formatDate(text); } String get tpaName => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.tpa), 'tpa_name', fallback: ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyExposureSummary), 'tpa_name', ), ); String get insurerName => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.insurer), 'insurer_name', fallback: ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.policyExposureSummary), 'insurer_name', ), ); String get policyRunDays => _expField('run_days'); String get currentEmployee => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.currentEmpLives), 'current_emp', ), ); String get currentLives => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.currentEmpLives), 'current_lives', ), ); String get inceptionEmployee => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.inceptionEmpLives), 'inception_emp', ), ); String get inceptionLives => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.inceptionEmpLives), 'inception_lives', ), ); String get avgFamilySize { final raw = ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.currentEmpLives), 'avg_family_size', fallback: '', ); if (raw.isEmpty || raw == '—') return '—'; final n = ClaimsKpiParser.toDouble(raw); if (n == null) return raw; return NumberFormat('#,##0.##').format(n); } // --- Policy experience --- ClaimsChartSeries get incurredByClaimStatus => _seriesSortedByValueDesc(chartSeries( ClaimsKpiSlug.totalIncurredByClaimStatus, labelKey: 'Claim Status', valueKey: 'Total Incurred Amount', valuesInLakhs: true, )); ClaimsChartSeries get claimsCountByStatus => _seriesSortedByValueDesc(chartSeries( ClaimsKpiSlug.claimAmountByClaimStatus, labelKey: 'claim_status', valueKey: 'claim_count', )); String _kpiScalar(String slug, String field) => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.kpiOnlyField(_kpi(slug), field), ); /// Metabase KPI 204 — `claims_reported` only (never experience-summary fallback). String get sidebarTotalClaims => _kpiScalar(ClaimsKpiSlug.totalClaims, 'claims_reported'); /// Metabase KPI 206 — `incurred_amount` only. String get sidebarIncurredAmount => _kpiScalar(ClaimsKpiSlug.incurredAmount, 'incurred_amount'); /// Metabase KPI 213 — Total Reimbursement Amount. String get sidebarReimbursement => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.totalReimbursementAmount), 'claim_value', ), ); /// Metabase KPI 214 — Total Reimbursement Amt %. String get sidebarReimbursementPct => ClaimsKpiParser.formatPercent( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.totalReimbursementPct), 'value_pct', ), ); /// Metabase KPI 215 — Cashless Claim Amt. String get sidebarCashless => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.cashlessClaimAmt), 'claim_value', ), ); /// Metabase KPI 216 — Cashless Claim Amt %. String get sidebarCashlessPct => ClaimsKpiParser.formatPercent( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.cashlessClaimPct), 'value_pct', ), ); /// `claims_experience_summary` — rejection rate. String get sidebarRejectionRate => ClaimsKpiParser.formatPercent(_expField('rejection_rate')); List get relationshipClaims { const knownSlugs = [ 'claim_amount_by_relationship', 'claims_by_relationship', 'relationship_wise_claims', 'relationship_wise_claim_details', 'claim_amount_by_relation', ]; for (final slug in knownSlugs) { final items = _relationshipFromKpi(_kpi(slug)); if (items.isNotEmpty) return items; } for (final key in kpiBySlug.keys) { final k = key.toString().toLowerCase(); if (!k.contains('relation') || k.contains('gender')) continue; final items = _relationshipFromKpi(kpiBySlug[key]); if (items.isNotEmpty) return items; } return const []; } List get topAilments { const knownSlugs = [ ClaimsKpiSlug.topAilments, 'top_5_ailments', 'top_ailments_by_amount', 'top_ailments_by_incurred_amount', 'claims_by_ailment', 'claim_by_ailment', ]; for (final slug in knownSlugs) { final items = _ailmentsFromKpi(_kpi(slug)); if (items.isNotEmpty) return items; } for (final entry in kpiBySlug.entries) { final kpi = entry.value; if (kpi is! Map) continue; final slug = entry.key.toString().toLowerCase(); final label = kpi['label']?.toString().toLowerCase() ?? ''; final matchesSlug = slug.contains('ailment') || slug.contains('diagnosis'); final matchesLabel = label.contains('ailment') || label.contains('diagnosis'); if (!matchesSlug && !matchesLabel) continue; final items = _ailmentsFromKpi(kpi); if (items.isNotEmpty) return items; } return const []; } static const _relationshipOrder = [ 'self', 'spouse', 'son', 'daughter', 'father', 'mother', 'father-in-law', 'mother-in-law', ]; static String _normalizeRelationshipKey(String relation) { return relation .trim() .toLowerCase() .replaceAll('_', '-') .replaceAll(RegExp(r'\s+'), '-') .replaceAll(RegExp(r'-+'), '-'); } static int _relationshipSortIndex(String relation) { final key = _normalizeRelationshipKey(relation); final idx = _relationshipOrder.indexOf(key); return idx >= 0 ? idx : _relationshipOrder.length; } static String formatRelationshipName(String relation) { final key = _normalizeRelationshipKey(relation); const labels = { 'self': 'Self', 'spouse': 'Spouse', 'son': 'Son', 'daughter': 'Daughter', 'father': 'Father', 'mother': 'Mother', 'father-in-law': 'Father-in-Law', 'mother-in-law': 'Mother-in-Law', }; if (labels.containsKey(key)) return labels[key]!; if (key.contains('-')) { return key.split('-').map(_capitalizeWord).join('-'); } return _capitalizeWord(key); } static String _capitalizeWord(String word) { if (word.isEmpty) return word; if (word.length == 1) return word.toUpperCase(); return word[0].toUpperCase() + word.substring(1).toLowerCase(); } List _relationshipFromKpi(dynamic kpi) { final rows = ClaimsKpiParser.extractRows(kpi); if (rows.isEmpty) return const []; final parsed = <({String raw, ClaimsRelationshipItem item})>[]; for (final row in rows) { if (row is! Map) continue; final relation = ClaimsKpiParser.rowValue(row, 'relationship') ?.toString() ?? ClaimsKpiParser.rowValue(row, 'relation')?.toString() ?? ClaimsKpiParser.rowValue(row, 'relationship_name')?.toString(); if (relation == null || relation.isEmpty) continue; final count = ClaimsKpiParser.toDouble( ClaimsKpiParser.rowValue(row, 'claim_count'), ) ?? ClaimsKpiParser.toDouble( ClaimsKpiParser.rowValue(row, 'claims'), ) ?? ClaimsKpiParser.toDouble(ClaimsKpiParser.rowValue(row, 'count')) ?? 0; final amountRaw = ClaimsKpiParser.rowValue(row, 'claim_value') ?? ClaimsKpiParser.rowValue(row, 'claim_amount') ?? ClaimsKpiParser.rowValue(row, 'amount') ?? ClaimsKpiParser.rowValue(row, 'incurred_amount'); parsed.add(( raw: relation, item: ClaimsRelationshipItem( relation: formatRelationshipName(relation), claims: count.round(), amount: ClaimsKpiParser.formatDisplay(amountRaw), ), )); } parsed.sort((a, b) { final order = _relationshipSortIndex(a.raw).compareTo( _relationshipSortIndex(b.raw), ); if (order != 0) return order; return a.raw.compareTo(b.raw); }); return parsed.map((e) => e.item).toList(); } List _ailmentsFromKpi(dynamic kpi) { final rows = ClaimsKpiParser.extractRows(kpi); if (rows.isEmpty) return const []; final items = <({double sortValue, ClaimsAilmentItem item})>[]; for (final row in rows) { if (row is! Map) continue; var title = ClaimsKpiParser.rowValue(row, 'ailment')?.toString() ?? ClaimsKpiParser.rowValue(row, 'ailment_name')?.toString() ?? ClaimsKpiParser.rowValue(row, 'diagnosis')?.toString() ?? ClaimsKpiParser.rowValue(row, 'diagnosis_description')?.toString() ?? ClaimsKpiParser.rowValue(row, 'icd_description')?.toString() ?? ClaimsKpiParser.rowValue(row, 'primary_diagnosis')?.toString() ?? ClaimsKpiParser.rowValue(row, 'disease')?.toString() ?? ClaimsKpiParser.rowValue(row, 'title')?.toString() ?? ClaimsKpiParser.rowValue(row, 'name')?.toString(); var valueRaw = ClaimsKpiParser.rowValue(row, 'claim_value') ?? ClaimsKpiParser.rowValue(row, 'claim_amount') ?? ClaimsKpiParser.rowValue(row, 'amount') ?? ClaimsKpiParser.rowValue(row, 'incurred_amount') ?? ClaimsKpiParser.rowValue(row, 'total_incurred_amount') ?? ClaimsKpiParser.rowValue(row, 'SUM OF CLAIMED AMOUNT'); final fallback = ClaimsKpiParser.rowLabelAndValue(row); title ??= fallback?.$1; valueRaw ??= fallback?.$2; if (title == null || title.isEmpty || valueRaw == null) continue; title = _stripLeadingDash(title); final sortValue = ClaimsKpiParser.toDouble(valueRaw) ?? 0; items.add(( sortValue: sortValue, item: ClaimsAilmentItem( title: title, value: ClaimsKpiParser.formatDisplay(valueRaw), ), )); } items.sort((a, b) => b.sortValue.compareTo(a.sortValue)); return items.map((e) => e.item).toList(); } static String _stripLeadingDash(String text) { return text.replaceFirst(RegExp(r'^[\s\-–—]+'), '').trim(); } // --- Claims analysis --- ClaimsChartSeries get claimAmountByStatusDonut => chartSeries( ClaimsKpiSlug.claimAmountByClaimStatus, labelKey: 'claim_status', valueKey: 'value_pct', ); /// Reimbursement vs cashless amounts (API has no per-type claim counts). ClaimsChartSeries get claimTypeCountSeries { final reimb = ClaimsKpiParser.rowDouble( _kpi(ClaimsKpiSlug.totalReimbursementAmount), 'claim_value', ); final cashless = ClaimsKpiParser.rowDouble( _kpi(ClaimsKpiSlug.cashlessClaimAmt), 'claim_value', ); if (reimb == null && cashless == null) { return const ClaimsChartSeries(labels: [], values: []); } final series = ClaimsKpiParser.labelValueSeriesFromPairs([ ('Reimbursement', reimb ?? 0), ('Cashless', cashless ?? 0), ]); if (series.values.isEmpty) return series; final inLakhs = series.values.map((v) => v / 100000).toList(); final max = inLakhs.reduce(math.max); return _seriesSortedByValueDesc( ClaimsChartSeries( labels: series.labels, values: inLakhs, maxY: ClaimsKpiParser._niceMaxY(max), ), ); } ClaimsChartSeries get claimTypeIncurredDonut { final reimb = ClaimsKpiParser.rowDouble( _kpi(ClaimsKpiSlug.totalReimbursementPct), 'value_pct', ); final cashless = ClaimsKpiParser.rowDouble( _kpi(ClaimsKpiSlug.cashlessClaimPct), 'value_pct', ); if (reimb == null && cashless == null) { return const ClaimsChartSeries(labels: [], values: []); } return ClaimsKpiParser.labelValueSeriesFromPairs([ ('Reimbursement', reimb ?? 0), ('Cashless', cashless ?? 0), ]); } ClaimsChartSeries get amountByMonth => ClaimsKpiParser.monthValueSeries( _kpi(ClaimsKpiSlug.claimValueByMonth), valuesInLakhs: true, ); // --- Demographics --- ClaimsChartSeries get ageBandSeries => chartSeries( ClaimsKpiSlug.ageBand, labelKey: 'age_band', valueKey: 'claim_value', ); ClaimsChartSeries get genderDonut { final series = chartSeries( ClaimsKpiSlug.claimAmountByGender, labelKey: 'gender', valueKey: 'value_pct', ); if (series.isEmpty) return series; final labels = series.labels .map((g) => switch (g.toUpperCase()) { 'M' => 'Male', 'F' => 'Female', _ => g, }) .toList(); return ClaimsChartSeries( labels: labels, values: series.values, maxY: series.maxY, ); } // --- Hospitals --- List get topHospitals { final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.top5Hospitals)); final items = <({double value, ClaimsListItem item})>[]; for (final row in rows) { if (row is! Map) continue; final name = ClaimsKpiParser.rowValue(row, 'hospital_name')?.toString(); if (name == null || name.isEmpty) continue; final raw = ClaimsKpiParser.rowValue(row, 'claim_value') ?? ClaimsKpiParser.rowValue(row, 'incurred_amount') ?? ClaimsKpiParser.rowValue(row, 'total_incurred_amount'); final value = ClaimsKpiParser.toDouble(raw); if (value == null) continue; items.add(( value: value, item: ClaimsListItem( title: name, value: ClaimsKpiParser.formatDisplay(value), ), )); } items.sort((a, b) => b.value.compareTo(a.value)); return items.take(5).map((e) => e.item).toList(); } List get cities { final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.totalIncurredByCity)); final totals = {}; for (final row in rows) { if (row is! Map) continue; final city = ClaimsKpiParser.rowValue(row, 'hospital_city')?.toString(); if (city == null || city.isEmpty || city == '-') continue; final amount = ClaimsKpiParser.toDouble( ClaimsKpiParser.rowValue(row, 'claim_value'), ) ?? 0; totals[city] = (totals[city] ?? 0) + amount; } if (totals.isEmpty) return const []; final sorted = totals.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); final top = sorted.take(5).toList(); final max = top.first.value; return top .map( (e) => ClaimsCityItem( city: _titleCaseCity(e.key), amount: ClaimsKpiParser.formatDisplay(e.value), fraction: max > 0 ? e.value / max : 0, ), ) .toList(); } ClaimsChartSeries get claimsByMonthSeries => ClaimsKpiParser.monthValueSeries( _kpi(ClaimsKpiSlug.claimValueByMonth), valuesInLakhs: true, ); /// Incidence rate (%) when monthly series is unavailable. ClaimsScatterPoint? get claimsIncidencePoint { final rate = ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.claimsIncidenceRate), 'claims_incidence_rate', fallback: '', ); if (rate.isEmpty || rate == '—') return null; final y = ClaimsKpiParser.toDouble(rate) ?? 0; return ClaimsScatterPoint(label: 'Claims incidence', y: y); } // --- Specialty --- String get pregnancyNormalExceeded => ClaimsKpiParser.formatDisplay( ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.pregnancyNormalExceeded), 'exceeded_claim_amount', fallback: '0', ), ); String get pregnancyCSecExceeded => '0'; String get pregnancyCSecAvg => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.pregnancyCSecAvgExceeded), 'avg_exceeded_amount', fallback: '0', ); String get pregnancyNormalDeliveryExceeded => pregnancyNormalExceeded; String get cataractExceededClaim => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.cataractExceededClaim), 'exceeded_claim_amount', fallback: '0', ); String get cataractAvgExceeded => ClaimsKpiParser.rowField( _kpi(ClaimsKpiSlug.cataractAvgExceeded), 'avg_exceeded_claim_amount', fallback: '0', ); String get cataractWithinLimit { final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.hospitalCitySiCataract)); var sum = 0.0; for (final row in rows) { if (row is! Map) continue; final limit = ClaimsKpiParser.rowValue(row, 'CLAIM STATUS LIMIT') ?.toString() .toLowerCase(); if (limit != null && !limit.contains('within')) continue; sum += ClaimsKpiParser.toDouble( ClaimsKpiParser.rowValue(row, 'SUM OF CLAIMED AMOUNT'), ) ?? 0; } return sum > 0 ? ClaimsKpiParser.formatDisplay(sum) : '0'; } String get totalCataractClaims { final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.hospitalCitySiCataract)); var sum = 0.0; for (final row in rows) { if (row is! Map) continue; sum += ClaimsKpiParser.toDouble( ClaimsKpiParser.rowValue(row, 'claim_count'), ) ?? 0; } return sum > 0 ? NumberFormat('#,##0').format(sum) : '0'; } static String _titleCaseCity(String city) { if (city.length <= 3) return city.toUpperCase(); return city[0].toUpperCase() + city.substring(1).toLowerCase(); } static String? _idKey(String slug) { const ids = { ClaimsKpiSlug.policyExposureSummary: '181', ClaimsKpiSlug.premiumAsOnDate: '185', ClaimsKpiSlug.claimsExperienceSummary: '186', ClaimsKpiSlug.claimAmountByGender: '190', ClaimsKpiSlug.ageBand: '191', ClaimsKpiSlug.top5Hospitals: '194', ClaimsKpiSlug.claimsIncidenceRate: '197', ClaimsKpiSlug.policyStartDate: '198', ClaimsKpiSlug.policyEndDate: '199', ClaimsKpiSlug.insurer: '200', ClaimsKpiSlug.tpa: '201', ClaimsKpiSlug.earnedPremium: '203', ClaimsKpiSlug.totalClaims: '204', ClaimsKpiSlug.incurredAmount: '206', ClaimsKpiSlug.incurredRatio: '207', ClaimsKpiSlug.projectedClaims: '208', ClaimsKpiSlug.projectedRatio: '209', ClaimsKpiSlug.totalReimbursementAmount: '213', ClaimsKpiSlug.totalReimbursementPct: '214', ClaimsKpiSlug.cashlessClaimAmt: '215', ClaimsKpiSlug.cashlessClaimPct: '216', ClaimsKpiSlug.totalIncurredByCity: '217', ClaimsKpiSlug.claimAmountByClaimStatus: '219', ClaimsKpiSlug.hospitalsInDetail: '220', ClaimsKpiSlug.hospitalCitySiPregnancy: '221', ClaimsKpiSlug.pregnancyNormalExceeded: '224', ClaimsKpiSlug.pregnancyCSecAvgExceeded: '225', ClaimsKpiSlug.cataractExceededClaim: '228', ClaimsKpiSlug.cataractAvgExceeded: '229', ClaimsKpiSlug.hospitalCitySiCataract: '230', ClaimsKpiSlug.totalIncurredByClaimStatus: '231', ClaimsKpiSlug.inceptionEmpLives: '232', ClaimsKpiSlug.currentEmpLives: '233', ClaimsKpiSlug.claimValueByMonth: '234', }; return ids[slug]; } } abstract final class ClaimsKpiParser { static bool mapsEqual(Map a, Map b) { if (a.length != b.length) return false; for (final key in a.keys) { if (!b.containsKey(key) || !identical(a[key], b[key])) return false; } return true; } static bool isSuccessResponse(Map response) { final data = response['data'] ?? response['result']; if (data is Map && data.isNotEmpty) return true; final status = response['status']; return status == true || status == 1 || status == 'true' || status == 'True' || status == 'success' || status == 'Success' || response['success'] == true || response['code'] == 200; } static Map normalizeAllKpis(Map response) { final map = {}; if (!isSuccessResponse(response) && response.containsKey('status')) { return map; } var data = response['data'] ?? response['result']; if (data is String) { try { data = jsonDecode(data); } catch (_) { data = null; } } if (data is Map) { _ingestKpiMap(Map.from(data), map); } else if (data is List) { _ingest(data, map); } else if (!response.containsKey('status') && !response.containsKey('code')) { _ingest(response, map); } for (final key in map.keys.toList()) { final kpi = map[key]; if (kpi is Map) { map[key] = normalizeKpiShape(Map.from(kpi)); } } return map; } static bool _isMetadataKey(String key) { final n = key.toLowerCase(); return n == 'policy_id' || n == 'client_policy' || n == 'client_policy_id' || n == 'policyid'; } static void _ingestKpiMap( Map data, Map map, ) { for (final entry in data.entries) { final key = entry.key.toString(); if (_isMetadataKey(key)) continue; final value = entry.value; if (value is! Map) continue; final kpi = normalizeKpiShape(Map.from(value)); map[key] = kpi; final slug = _slugOf(kpi) ?? key; map[slug] = kpi; final id = kpi['id']?.toString(); if (id != null) map[id] = kpi; } } static Map normalizeKpiShape(Map kpi) { var normalized = Map.from(kpi); for (final key in ['result', 'response', 'payload']) { final nested = normalized[key]; if (nested is Map && (nested.containsKey('rows') || nested.containsKey('cols') || nested.containsKey('value'))) { normalized = { ...normalized, ...Map.from(nested), }; break; } } normalized = _normalizeMatrixContainer(normalized); final data = normalized['data']; if (data is Map) { normalized['data'] = _normalizeMatrixContainer(Map.from(data)); } return normalized; } static Map _normalizeMatrixContainer( Map container, ) { final normalized = Map.from(container); final rows = normalized['rows']; if (rows is! List || rows.isEmpty || rows.first is! List) { return normalized; } final cols = normalized['cols'] ?? (normalized['data'] is Map ? (normalized['data'] as Map)['cols'] : null); if (cols is List) { normalized['rows'] = matrixRowsToMaps(cols, rows); } return normalized; } static List> matrixRowsToMaps( List cols, List rows, ) { final names = cols.map((col) { if (col is Map) { return (col['name'] ?? col['display_name'] ?? col['field_ref']) ?.toString(); } return col.toString(); }).toList(); return rows.map((row) { if (row is! List) return {}; final map = {}; for (var i = 0; i < row.length && i < names.length; i++) { final name = names[i]; if (name != null && name.isNotEmpty) { map[name] = row[i]; } } return map; }).toList(); } static void ensureKpiField( Map kpiBySlug, String slug, String field, dynamic value, ) { if (value == null || value.toString().trim().isEmpty) return; final existing = kpiBySlug[slug]; if (existing != null) { final current = kpiOnlyField(existing, field); if (current != '—') return; } if (existing is Map) { final existingMap = Map.from(existing); final rows = extractRows(existingMap); if (rows.isNotEmpty && rows.first is Map) { final first = Map.from(rows.first as Map); first[field] = value; existingMap['rows'] = [first, ...rows.skip(1)]; kpiBySlug[slug] = existingMap; return; } } kpiBySlug[slug] = { 'slug': slug, 'rows': [ {field: value}, ], }; } static void _ingest(dynamic node, Map map) { if (node == null) return; if (node is List) { for (final item in node) { if (item is! Map) continue; final slug = _slugOf(item); if (slug != null) { map[slug] = item; final id = item['id']?.toString(); if (id != null) map[id] = item; } } return; } if (node is Map) { final selfSlug = _slugOf(node); if (selfSlug != null && (node.containsKey('rows') || node.containsKey('value') || node.containsKey('data'))) { map[selfSlug] = node; final id = node['id']?.toString(); if (id != null) map[id] = node; return; } for (final entry in node.entries) { final key = entry.key.toString(); final value = entry.value; if (value is Map && (value.containsKey('rows') || value.containsKey('value') || value.containsKey('data') || value.containsKey('result'))) { map[key] = value; final slug = value['slug']?.toString(); if (slug != null) map[slug] = value; final id = value['id']?.toString(); if (id != null) map[id] = value; continue; } if (value is Map) { _ingest(value, map); } else if (value is List) { _ingest(value, map); } } } } static String? _slugOf(Map item) { return (item['slug'] ?? item['method_slug'] ?? item['methodSlug'] ?? item['kpi_slug']) ?.toString(); } static String _normalizeKey(String key) => key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); static dynamic rowValue(Map row, String field) { final target = _normalizeKey(field); for (final entry in row.entries) { if (_normalizeKey(entry.key.toString()) == target) { return entry.value; } } return null; } static Map? _asRowMap(dynamic row) { if (row is Map) return row; return null; } static String rowField( dynamic kpi, String field, { String fallback = '—', }) { if (kpi == null) return fallback; if (kpi is Map) { final direct = rowValue(kpi, field); if (direct != null) return cleanDisplay(direct); if (extractRows(kpi).isEmpty && kpi['value'] != null) { return formatDisplay(kpi['value']); } } final rows = extractRows(kpi); if (rows.isEmpty) return fallback; final first = _asRowMap(rows.first); if (first == null) return fallback; final value = rowValue(first, field); if (value == null) return fallback; return cleanDisplay(value); } /// Read a scalar from one KPI block only — no cross-KPI fallback. static String kpiOnlyField(dynamic kpi, String field) { if (kpi == null) return '—'; if (kpi is Map) { final direct = rowValue(kpi, field); if (direct != null) return cleanDisplay(direct); if (kpi['value'] != null && _normalizeKey(field) == 'value') { return formatDisplay(kpi['value']); } } final rows = extractRows(kpi); if (rows.isEmpty) return '—'; final first = _asRowMap(rows.first); if (first == null) return '—'; final value = rowValue(first, field); if (value == null) return '—'; return cleanDisplay(value); } static double? rowDouble(dynamic kpi, String field) { final rows = extractRows(kpi); if (rows.isEmpty) return null; final first = rows.first; if (first is! Map) return null; return toDouble(rowValue(first, field)); } static String cleanDisplay(dynamic value) { return value .toString() .replaceAll(RegExp(r'[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]', unicode: true), '') .trim(); } static String formatPercent(String value, {String fallback = '—'}) { if (value.isEmpty || value == fallback) return fallback; final text = cleanDisplay(value); if (text.isEmpty) return fallback; return text.contains('%') ? text : '$text%'; } static String? formatDate(String? raw) { if (raw == null) return null; final text = cleanDisplay(raw); if (text.isEmpty || text == '—' || text.toLowerCase() == 'null') return null; try { return DateFormat('d MMM yyyy').format(DateTime.parse(text).toLocal()); } catch (_) { return text; } } static String formatDisplay(dynamic value) { if (value == null) return '—'; if (value is num) { if (value == value.roundToDouble()) { return NumberFormat('#,##0').format(value); } return NumberFormat('#,##0.##').format(value); } final text = cleanDisplay(value); if (text.isEmpty) return '—'; final n = toDouble(text); if (n != null && !text.contains('%') && !text.contains(',')) { return formatDisplay(n); } return text; } static List extractRows(dynamic kpi) { if (kpi == null) return const []; if (kpi is List) { if (kpi.isEmpty) return const []; if (kpi.first is Map) return kpi; if (kpi.first is List) return _matrixRowsFromLists(kpi, null); return const []; } if (kpi is! Map) return const []; for (final key in ['rows', 'data', 'result', 'values']) { final candidate = kpi[key]; if (candidate is List) { if (candidate.isEmpty) continue; if (candidate.first is Map) return candidate; if (candidate.first is List) { final cols = kpi['cols'] ?? (kpi['data'] is Map ? (kpi['data'] as Map)['cols'] : null); return _matrixRowsFromLists(candidate, cols is List ? cols : null); } } if (candidate is Map) { final nested = extractRows(candidate); if (nested.isNotEmpty) return nested; } } return const []; } static List _matrixRowsFromLists(List rows, List? cols) { if (cols == null) return const []; return matrixRowsToMaps(cols, rows); } static ClaimsChartSeries labelValueSeries( dynamic kpi, { String? labelKey, String? valueKey, bool valuesInLakhs = false, }) { final rows = extractRows(kpi); if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); final labels = []; final values = []; for (final row in rows) { if (row is! Map) continue; final label = labelKey != null ? rowValue(row, labelKey)?.toString() : _firstLabel(row); final rawValue = valueKey != null ? rowValue(row, valueKey) : _firstNumeric(row); final value = toDouble(rawValue); if (label == null || value == null) continue; labels.add(label); values.add(valuesInLakhs ? value / 100000 : value); } double? maxY; if (values.isNotEmpty) { final max = values.reduce((a, b) => a > b ? a : b); maxY = _niceMaxY(max); } return ClaimsChartSeries(labels: labels, values: values, maxY: maxY); } /// Sorted monthly claim values (`month_sort`, `claim_month`, `claim_value`). static ClaimsChartSeries monthValueSeries( dynamic kpi, { bool valuesInLakhs = false, }) { final rows = extractRows(kpi); if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); final items = <({String sort, String label, double value})>[]; for (final row in rows) { if (row is! Map) continue; final sort = rowValue(row, 'month_sort')?.toString() ?? ''; final label = rowValue(row, 'claim_month')?.toString(); final value = toDouble(rowValue(row, 'claim_value')); if (label == null || label.isEmpty || value == null) continue; items.add((sort: sort, label: label, value: value)); } if (items.isEmpty) return const ClaimsChartSeries(labels: [], values: []); items.sort((a, b) => a.sort.compareTo(b.sort)); final labels = items.map((e) => e.label).toList(); final values = items .map((e) => valuesInLakhs ? e.value / 100000 : e.value) .toList(); final max = values.reduce(math.max); return ClaimsChartSeries( labels: labels, values: values, maxY: _niceMaxY(max), ); } static ClaimsChartSeries labelValueSeriesFromPairs( List<(String, double)> pairs, ) { final labels = pairs.map((p) => p.$1).toList(); final values = pairs.map((p) => p.$2).toList(); double? maxY; if (values.isNotEmpty) { maxY = _niceMaxY(values.reduce(math.max)); } return ClaimsChartSeries(labels: labels, values: values, maxY: maxY); } /// First text-like column + first numeric column (generic KPI rows). static (String, dynamic)? rowLabelAndValue(Map row) { final label = _firstLabel(row); final value = _firstNumeric(row); if (label == null || value == null) return null; return (label, value); } static String? _firstLabel(Map row) { for (final entry in row.entries) { if (!_isNumericKey(entry.key.toString())) { return entry.value?.toString(); } } return null; } static dynamic _firstNumeric(Map row) { for (final entry in row.entries) { if (_isNumericKey(entry.key.toString())) { return entry.value; } } return null; } static bool _isNumericKey(String key) { final n = _normalizeKey(key); if (n.contains('name') || n.contains('status') || n.contains('gender')) { return false; } return RegExp( r'amount|count|value|total|incurred|pct|percent|claims|amt|premium|ratio|rate|days', caseSensitive: false, ).hasMatch(key); } static double? toDouble(dynamic v) { if (v == null) return null; if (v is num) return v.toDouble(); final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); return double.tryParse(cleaned); } static double sumRowField(dynamic kpi, String field) { final rows = extractRows(kpi); var sum = 0.0; for (final row in rows) { if (row is! Map) continue; sum += toDouble(rowValue(row, field)) ?? 0; } return sum; } static double _niceMaxY(double max) { if (max <= 0) return 10; final exp = (math.log(max) / math.ln10).floor(); final magnitude = math.pow(10, exp).toDouble(); final normalized = (max / magnitude).ceil() * magnitude; return normalized * 1.1; } }