enrollment-app/lib/presentation/claims_overview/claims_collection_kpi.dart

1437 lines
44 KiB
Dart

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<String> labels;
final List<double> 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<String, dynamic> 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<String, dynamic> response) {
return ClaimsOverviewViewData(
kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response),
);
}
/// Merges extra KPI blocks (e.g. from `/kpi/{slug}`) into this view-model.
ClaimsOverviewViewData withMergedKpis(Map<String, dynamic> extraKpis) {
if (extraKpis.isEmpty) return this;
final merged = Map<String, dynamic>.from(kpiBySlug)..addAll(extraKpis);
return ClaimsOverviewViewData(kpiBySlug: merged, loadError: loadError);
}
/// Fills overview basics from `getPolicyLevelEmployeeSummaryData` when KPI rows are empty.
ClaimsOverviewViewData withPolicyFallback(Map<String, dynamic> policy) {
final merged = Map<String, dynamic>.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<ClaimsOverviewViewData> enrichFromApiResponse(
Map<String, dynamic> allResponse, {
required Future<Map<String, dynamic>> 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<ClaimsRelationshipItem> 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<ClaimsAilmentItem> 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<ClaimsRelationshipItem> _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<ClaimsAilmentItem> _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<ClaimsListItem> 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<ClaimsCityItem> get cities {
final rows = ClaimsKpiParser.extractRows(_kpi(ClaimsKpiSlug.totalIncurredByCity));
final totals = <String, double>{};
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<String, dynamic> a, Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> normalizeAllKpis(Map<String, dynamic> response) {
final map = <String, dynamic>{};
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<String, dynamic>.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<dynamic, dynamic>.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<String, dynamic> data,
Map<String, dynamic> 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<String, dynamic>.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<String, dynamic> normalizeKpiShape(Map<dynamic, dynamic> kpi) {
var normalized = Map<String, dynamic>.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<String, dynamic>.from(nested),
};
break;
}
}
normalized = _normalizeMatrixContainer(normalized);
final data = normalized['data'];
if (data is Map) {
normalized['data'] =
_normalizeMatrixContainer(Map<String, dynamic>.from(data));
}
return normalized;
}
static Map<String, dynamic> _normalizeMatrixContainer(
Map<String, dynamic> container,
) {
final normalized = Map<String, dynamic>.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<Map<String, dynamic>> 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 <String, dynamic>{};
final map = <String, dynamic>{};
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<String, dynamic> 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<String, dynamic>.from(existing);
final rows = extractRows(existingMap);
if (rows.isNotEmpty && rows.first is Map) {
final first = Map<String, dynamic>.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<String, dynamic> 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<dynamic, dynamic> 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<dynamic, dynamic> 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<dynamic, dynamic>? _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<dynamic> 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<dynamic> _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 = <String>[];
final values = <double>[];
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<dynamic, dynamic> row) {
final label = _firstLabel(row);
final value = _firstNumeric(row);
if (label == null || value == null) return null;
return (label, value);
}
static String? _firstLabel(Map<dynamic, dynamic> row) {
for (final entry in row.entries) {
if (!_isNumericKey(entry.key.toString())) {
return entry.value?.toString();
}
}
return null;
}
static dynamic _firstNumeric(Map<dynamic, dynamic> 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;
}
}