1437 lines
44 KiB
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;
|
|
}
|
|
}
|