361 lines
10 KiB
Dart
361 lines
10 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:intl/intl.dart';
|
|
|
|
import 'claims_collection_kpi.dart';
|
|
|
|
/// KPI slugs for `employeeRest/enrollment-collection-v1`.
|
|
abstract final class EnrollmentKpiSlug {
|
|
static const originallyEnrolled = 'originally_enrolled';
|
|
static const addedSubsequently = 'added_subsequently';
|
|
static const genderSplit = 'gender_split';
|
|
static const enrollmentRelationship = 'enrollment_relationship';
|
|
static const averageAgeByEnrollmentMonth =
|
|
'average_age_by_enrollment_month';
|
|
static const originalBasePremium = 'original_base_premium';
|
|
static const overallActive = 'overall_active';
|
|
static const enrollmentAgeGroup = 'enrollment_age_group';
|
|
static const additionsPremium = 'additions_premium';
|
|
static const netPremium = 'net_premium';
|
|
}
|
|
|
|
/// Parsed view-model for the Enrollment tab.
|
|
class EnrollmentOverviewViewData {
|
|
final Map<String, dynamic> kpiBySlug;
|
|
final String? loadError;
|
|
|
|
const EnrollmentOverviewViewData({
|
|
required this.kpiBySlug,
|
|
this.loadError,
|
|
});
|
|
|
|
factory EnrollmentOverviewViewData.empty({String? error}) =>
|
|
EnrollmentOverviewViewData(kpiBySlug: const {}, loadError: error);
|
|
|
|
factory EnrollmentOverviewViewData.fromApiResponse(
|
|
Map<String, dynamic> response,
|
|
) {
|
|
return EnrollmentOverviewViewData(
|
|
kpiBySlug: ClaimsKpiParser.normalizeAllKpis(response),
|
|
);
|
|
}
|
|
|
|
/// Fills membership basics from policy list API when enrollment KPI rows are empty.
|
|
EnrollmentOverviewViewData withPolicyFallback(Map<String, dynamic> policy) {
|
|
final merged = Map<String, dynamic>.from(kpiBySlug);
|
|
final active =
|
|
policy['membersCountOfActive'] ?? policy['totalMembersCount'];
|
|
ClaimsKpiParser.ensureKpiField(
|
|
merged,
|
|
EnrollmentKpiSlug.overallActive,
|
|
'Overall Active',
|
|
active,
|
|
);
|
|
ClaimsKpiParser.ensureKpiField(
|
|
merged,
|
|
EnrollmentKpiSlug.originalBasePremium,
|
|
'opening_premium',
|
|
policy['total_premium'],
|
|
);
|
|
return EnrollmentOverviewViewData(kpiBySlug: merged, loadError: loadError);
|
|
}
|
|
|
|
dynamic _kpi(String slug) => kpiBySlug[slug] ?? kpiBySlug[_idKey(slug)];
|
|
|
|
/// Reads a scalar from KPI rows; supports v1 label-keyed single-value rows.
|
|
static String _scalarFromKpi(
|
|
dynamic kpi,
|
|
List<String> fields, {
|
|
String Function(String raw)? format,
|
|
}) {
|
|
if (kpi == null) return '—';
|
|
|
|
for (final field in fields) {
|
|
final raw = ClaimsKpiParser.kpiOnlyField(kpi, field);
|
|
if (raw != '—') {
|
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
|
}
|
|
}
|
|
|
|
final rows = ClaimsKpiParser.extractRows(kpi);
|
|
if (rows.isEmpty) return '—';
|
|
|
|
if (rows.length == 1) {
|
|
final row = rows.first;
|
|
if (row is Map) {
|
|
for (final field in fields) {
|
|
final value = ClaimsKpiParser.rowValue(row, field);
|
|
if (value != null) {
|
|
final raw = ClaimsKpiParser.cleanDisplay(value);
|
|
if (raw.isNotEmpty) {
|
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
|
}
|
|
}
|
|
}
|
|
if (row.length == 1) {
|
|
final raw = ClaimsKpiParser.cleanDisplay(row.values.first);
|
|
if (raw.isNotEmpty) {
|
|
return format?.call(raw) ?? ClaimsKpiParser.formatDisplay(raw);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return '—';
|
|
}
|
|
|
|
String _scalar(
|
|
List<String> slugs,
|
|
List<String> fields, {
|
|
String Function(String raw)? format,
|
|
}) {
|
|
for (final slug in slugs) {
|
|
final value = _scalarFromKpi(_kpi(slug), fields, format: format);
|
|
if (value != '—') return value;
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
ClaimsChartSeries _chartSeries(
|
|
List<String> slugs, {
|
|
required String labelKey,
|
|
String valueKey = 'member_count',
|
|
}) {
|
|
for (final slug in slugs) {
|
|
final kpi = _kpi(slug);
|
|
if (kpi == null) continue;
|
|
|
|
final explicit = ClaimsKpiParser.labelValueSeries(
|
|
kpi,
|
|
labelKey: labelKey,
|
|
valueKey: valueKey,
|
|
);
|
|
if (!explicit.isEmpty) return explicit;
|
|
|
|
final auto = ClaimsKpiParser.labelValueSeries(kpi);
|
|
if (!auto.isEmpty) return auto;
|
|
}
|
|
return const ClaimsChartSeries(labels: [], values: []);
|
|
}
|
|
|
|
static String formatPremium(String raw) {
|
|
if (raw.isEmpty || raw == '—') return '—';
|
|
if (raw.startsWith('₹')) return raw;
|
|
final n = ClaimsKpiParser.toDouble(raw);
|
|
if (n == null) return raw;
|
|
return formatPremiumAmount(n);
|
|
}
|
|
|
|
static String formatPremiumAmount(double n) {
|
|
if (n.abs() >= 100000) {
|
|
return '₹${(n / 100000).toStringAsFixed(n.abs() >= 1000000 ? 1 : 2)}L';
|
|
}
|
|
if (n.abs() >= 1000) {
|
|
return '₹${(n / 1000).toStringAsFixed(1)}K';
|
|
}
|
|
return '₹${NumberFormat('#,##0').format(n.round())}';
|
|
}
|
|
|
|
String get activeMembers => _scalar(
|
|
[EnrollmentKpiSlug.overallActive],
|
|
const [
|
|
'Overall Active',
|
|
'overall_active',
|
|
'active_members',
|
|
'member_count',
|
|
'count',
|
|
],
|
|
);
|
|
|
|
String get originallyEnrolled => _scalar(
|
|
[EnrollmentKpiSlug.originallyEnrolled],
|
|
const [
|
|
'Originally Enrolled',
|
|
'originally_enrolled',
|
|
'member_count',
|
|
'count',
|
|
],
|
|
);
|
|
|
|
String get addedSubsequently => _scalar(
|
|
[EnrollmentKpiSlug.addedSubsequently],
|
|
const [
|
|
'Subsequent Additions',
|
|
'added_subsequently',
|
|
'member_count',
|
|
'count',
|
|
],
|
|
);
|
|
|
|
String get originalBasePremium => _scalar(
|
|
[EnrollmentKpiSlug.originalBasePremium],
|
|
const [
|
|
'opening_premium',
|
|
'original_base_premium',
|
|
'Original Base Premium',
|
|
'premium',
|
|
'amount',
|
|
],
|
|
format: formatPremium,
|
|
);
|
|
|
|
String get subsequentBasePremium => _scalar(
|
|
[EnrollmentKpiSlug.additionsPremium],
|
|
const [
|
|
'additions_premium',
|
|
'Additions Premium',
|
|
'subsequent_base_premium',
|
|
'premium',
|
|
'amount',
|
|
],
|
|
format: formatPremium,
|
|
);
|
|
|
|
String get netPremium => _scalar(
|
|
[EnrollmentKpiSlug.netPremium],
|
|
const [
|
|
'net_premium',
|
|
'Net Premium',
|
|
'premium',
|
|
'amount',
|
|
],
|
|
format: formatPremium,
|
|
);
|
|
|
|
ClaimsChartSeries get genderDonut {
|
|
final series = _chartSeries(
|
|
[EnrollmentKpiSlug.genderSplit],
|
|
labelKey: 'gender',
|
|
);
|
|
if (series.isEmpty) return series;
|
|
final labels = series.labels
|
|
.map((g) => switch (g.toUpperCase()) {
|
|
'M' => 'M',
|
|
'F' => 'F',
|
|
'MALE' => 'M',
|
|
'FEMALE' => 'F',
|
|
_ => g,
|
|
})
|
|
.toList();
|
|
return ClaimsChartSeries(
|
|
labels: labels,
|
|
values: series.values,
|
|
maxY: series.maxY,
|
|
);
|
|
}
|
|
|
|
ClaimsChartSeries get ageGroupsDonut => _chartSeries(
|
|
[EnrollmentKpiSlug.enrollmentAgeGroup],
|
|
labelKey: 'age_group',
|
|
);
|
|
|
|
ClaimsChartSeries get relationshipDonut => _chartSeries(
|
|
[EnrollmentKpiSlug.enrollmentRelationship],
|
|
labelKey: 'relationship',
|
|
);
|
|
|
|
ClaimsChartSeries get averageAgeTrend {
|
|
final series = _monthAgeSeries(_kpi(EnrollmentKpiSlug.averageAgeByEnrollmentMonth));
|
|
if (!series.isEmpty) return series;
|
|
return const ClaimsChartSeries(labels: [], values: []);
|
|
}
|
|
|
|
ClaimsChartSeries _monthAgeSeries(dynamic kpi) {
|
|
final rows = ClaimsKpiParser.extractRows(kpi);
|
|
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
|
|
|
|
const labelFields = [
|
|
'month_label',
|
|
'month',
|
|
'enrollment_month',
|
|
'claim_month',
|
|
'month_name',
|
|
'period',
|
|
];
|
|
const valueFields = [
|
|
'average_age',
|
|
'avg_age',
|
|
'average_age_value',
|
|
'age',
|
|
'value',
|
|
];
|
|
const sortFields = ['enrollment_month', 'month_sort', 'sort', 'month_order'];
|
|
|
|
final items = <({String sort, String label, double value})>[];
|
|
for (final row in rows) {
|
|
if (row is! Map) continue;
|
|
|
|
String? label;
|
|
for (final field in labelFields) {
|
|
final candidate = ClaimsKpiParser.rowValue(row, field)?.toString();
|
|
if (candidate != null && candidate.isNotEmpty) {
|
|
label = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
double? value;
|
|
for (final field in valueFields) {
|
|
value = ClaimsKpiParser.toDouble(ClaimsKpiParser.rowValue(row, field));
|
|
if (value != null) break;
|
|
}
|
|
|
|
if (label == null || value == null) continue;
|
|
|
|
var sort = '';
|
|
for (final field in sortFields) {
|
|
final candidate = ClaimsKpiParser.rowValue(row, field)?.toString();
|
|
if (candidate != null && candidate.isNotEmpty) {
|
|
sort = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (sort.isEmpty) sort = label;
|
|
|
|
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) => e.value).toList();
|
|
final max = values.reduce(math.max);
|
|
return ClaimsChartSeries(
|
|
labels: labels,
|
|
values: values,
|
|
maxY: _niceAgeMaxY(max),
|
|
);
|
|
}
|
|
|
|
String donutTotal(ClaimsChartSeries series) {
|
|
if (series.isEmpty) return '—';
|
|
final total = series.values.fold<double>(0, (a, b) => a + b);
|
|
return NumberFormat('#,##0').format(total.round());
|
|
}
|
|
|
|
static double _niceAgeMaxY(double max) {
|
|
if (max <= 0) return 40;
|
|
if (max <= 40) return 40;
|
|
final exp = (math.log(max) / math.ln10).floor();
|
|
final magnitude = math.pow(10, exp).toDouble();
|
|
return (max / magnitude).ceil() * magnitude * 1.1;
|
|
}
|
|
|
|
static String? _idKey(String slug) {
|
|
const ids = {
|
|
EnrollmentKpiSlug.originallyEnrolled: '115',
|
|
EnrollmentKpiSlug.addedSubsequently: '116',
|
|
EnrollmentKpiSlug.genderSplit: '117',
|
|
EnrollmentKpiSlug.enrollmentRelationship: '119',
|
|
EnrollmentKpiSlug.averageAgeByEnrollmentMonth: '122',
|
|
EnrollmentKpiSlug.originalBasePremium: '123',
|
|
EnrollmentKpiSlug.overallActive: '124',
|
|
EnrollmentKpiSlug.enrollmentAgeGroup: '125',
|
|
EnrollmentKpiSlug.additionsPremium: '126',
|
|
EnrollmentKpiSlug.netPremium: '127',
|
|
};
|
|
return ids[slug];
|
|
}
|
|
}
|