dashboard changes done

This commit is contained in:
Surendiran 2026-06-05 12:28:57 +05:30
parent c66c39e57c
commit 64d0b0b7d9
8 changed files with 679 additions and 547 deletions

View File

@ -231,13 +231,13 @@ class ClaimsOverviewViewData {
String slug, { String slug, {
String? labelKey, String? labelKey,
String? valueKey, String? valueKey,
bool valuesInMillions = false, bool valuesInLakhs = false,
}) { }) {
return ClaimsKpiParser.labelValueSeries( return ClaimsKpiParser.labelValueSeries(
_kpi(slug), _kpi(slug),
labelKey: labelKey, labelKey: labelKey,
valueKey: valueKey, valueKey: valueKey,
valuesInMillions: valuesInMillions, valuesInLakhs: valuesInLakhs,
); );
} }
@ -280,6 +280,14 @@ class ClaimsOverviewViewData {
String get projectedRatio => _expField('projected_ratio'); String get projectedRatio => _expField('projected_ratio');
String get claimsIncidenceRate => ClaimsKpiParser.formatPercent(
ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.claimsIncidenceRate),
'claims_incidence_rate',
fallback: '',
),
);
String get tpaName => ClaimsKpiParser.rowField( String get tpaName => ClaimsKpiParser.rowField(
_kpi(ClaimsKpiSlug.tpa), _kpi(ClaimsKpiSlug.tpa),
'tpa_name', 'tpa_name',
@ -345,7 +353,7 @@ class ClaimsOverviewViewData {
ClaimsKpiSlug.totalIncurredByClaimStatus, ClaimsKpiSlug.totalIncurredByClaimStatus,
labelKey: 'Claim Status', labelKey: 'Claim Status',
valueKey: 'Total Incurred Amount', valueKey: 'Total Incurred Amount',
valuesInMillions: true, valuesInLakhs: true,
); );
ClaimsChartSeries get claimsCountByStatus => chartSeries( ClaimsChartSeries get claimsCountByStatus => chartSeries(
@ -446,11 +454,63 @@ class ClaimsOverviewViewData {
return const []; 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 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();
}
static int _relationshipSortIndex(String relation) {
final key = _normalizeRelationshipKey(relation);
final idx = _relationshipOrder.indexOf(key);
return idx >= 0 ? idx : _relationshipOrder.length;
}
List<ClaimsRelationshipItem> _relationshipFromKpi(dynamic kpi) { List<ClaimsRelationshipItem> _relationshipFromKpi(dynamic kpi) {
final rows = ClaimsKpiParser.extractRows(kpi); final rows = ClaimsKpiParser.extractRows(kpi);
if (rows.isEmpty) return const []; if (rows.isEmpty) return const [];
final items = <ClaimsRelationshipItem>[]; final parsed = <({String raw, ClaimsRelationshipItem item})>[];
for (final row in rows) { for (final row in rows) {
if (row is! Map) continue; if (row is! Map) continue;
final relation = ClaimsKpiParser.rowValue(row, 'relationship') final relation = ClaimsKpiParser.rowValue(row, 'relationship')
@ -473,15 +533,24 @@ class ClaimsOverviewViewData {
ClaimsKpiParser.rowValue(row, 'amount') ?? ClaimsKpiParser.rowValue(row, 'amount') ??
ClaimsKpiParser.rowValue(row, 'incurred_amount'); ClaimsKpiParser.rowValue(row, 'incurred_amount');
items.add( parsed.add((
ClaimsRelationshipItem( raw: relation,
relation: relation, item: ClaimsRelationshipItem(
relation: formatRelationshipName(relation),
claims: count.round(), claims: count.round(),
amount: ClaimsKpiParser.formatDisplay(amountRaw), amount: ClaimsKpiParser.formatDisplay(amountRaw),
), ),
); ));
} }
return items;
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) { List<ClaimsAilmentItem> _ailmentsFromKpi(dynamic kpi) {
@ -514,6 +583,8 @@ class ClaimsOverviewViewData {
if (title == null || title.isEmpty || valueRaw == null) continue; if (title == null || title.isEmpty || valueRaw == null) continue;
title = _stripLeadingDash(title);
items.add( items.add(
ClaimsAilmentItem( ClaimsAilmentItem(
title: title, title: title,
@ -524,6 +595,10 @@ class ClaimsOverviewViewData {
return items; return items;
} }
static String _stripLeadingDash(String text) {
return text.replaceFirst(RegExp(r'^[\s\-–—]+'), '').trim();
}
// --- Claims analysis --- // --- Claims analysis ---
ClaimsChartSeries get claimAmountByStatusDonut => chartSeries( ClaimsChartSeries get claimAmountByStatusDonut => chartSeries(
ClaimsKpiSlug.claimAmountByClaimStatus, ClaimsKpiSlug.claimAmountByClaimStatus,
@ -549,11 +624,11 @@ class ClaimsOverviewViewData {
('Cashless', cashless ?? 0), ('Cashless', cashless ?? 0),
]); ]);
if (series.values.isEmpty) return series; if (series.values.isEmpty) return series;
final inMillions = series.values.map((v) => v / 1000000).toList(); final inLakhs = series.values.map((v) => v / 100000).toList();
final max = inMillions.reduce(math.max); final max = inLakhs.reduce(math.max);
return ClaimsChartSeries( return ClaimsChartSeries(
labels: series.labels, labels: series.labels,
values: inMillions, values: inLakhs,
maxY: ClaimsKpiParser._niceMaxY(max), maxY: ClaimsKpiParser._niceMaxY(max),
); );
} }
@ -578,7 +653,7 @@ class ClaimsOverviewViewData {
ClaimsChartSeries get amountByMonth => ClaimsKpiParser.monthValueSeries( ClaimsChartSeries get amountByMonth => ClaimsKpiParser.monthValueSeries(
_kpi(ClaimsKpiSlug.claimValueByMonth), _kpi(ClaimsKpiSlug.claimValueByMonth),
valuesInMillions: true, valuesInLakhs: true,
); );
// --- Demographics --- // --- Demographics ---
@ -658,7 +733,7 @@ class ClaimsOverviewViewData {
ClaimsChartSeries get claimsByMonthSeries => ClaimsKpiParser.monthValueSeries( ClaimsChartSeries get claimsByMonthSeries => ClaimsKpiParser.monthValueSeries(
_kpi(ClaimsKpiSlug.claimValueByMonth), _kpi(ClaimsKpiSlug.claimValueByMonth),
valuesInMillions: true, valuesInLakhs: true,
); );
/// Incidence rate (%) when monthly series is unavailable. /// Incidence rate (%) when monthly series is unavailable.
@ -1167,7 +1242,7 @@ abstract final class ClaimsKpiParser {
dynamic kpi, { dynamic kpi, {
String? labelKey, String? labelKey,
String? valueKey, String? valueKey,
bool valuesInMillions = false, bool valuesInLakhs = false,
}) { }) {
final rows = extractRows(kpi); final rows = extractRows(kpi);
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
@ -1186,7 +1261,7 @@ abstract final class ClaimsKpiParser {
final value = toDouble(rawValue); final value = toDouble(rawValue);
if (label == null || value == null) continue; if (label == null || value == null) continue;
labels.add(label); labels.add(label);
values.add(valuesInMillions ? value / 1000000 : value); values.add(valuesInLakhs ? value / 100000 : value);
} }
double? maxY; double? maxY;
@ -1201,7 +1276,7 @@ abstract final class ClaimsKpiParser {
/// Sorted monthly claim values (`month_sort`, `claim_month`, `claim_value`). /// Sorted monthly claim values (`month_sort`, `claim_month`, `claim_value`).
static ClaimsChartSeries monthValueSeries( static ClaimsChartSeries monthValueSeries(
dynamic kpi, { dynamic kpi, {
bool valuesInMillions = false, bool valuesInLakhs = false,
}) { }) {
final rows = extractRows(kpi); final rows = extractRows(kpi);
if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []); if (rows.isEmpty) return const ClaimsChartSeries(labels: [], values: []);
@ -1220,7 +1295,7 @@ abstract final class ClaimsKpiParser {
items.sort((a, b) => a.sort.compareTo(b.sort)); items.sort((a, b) => a.sort.compareTo(b.sort));
final labels = items.map((e) => e.label).toList(); final labels = items.map((e) => e.label).toList();
final values = items final values = items
.map((e) => valuesInMillions ? e.value / 1000000 : e.value) .map((e) => valuesInLakhs ? e.value / 100000 : e.value)
.toList(); .toList();
final max = values.reduce(math.max); final max = values.reduce(math.max);
return ClaimsChartSeries( return ClaimsChartSeries(

View File

@ -29,10 +29,20 @@ List<double> _yTicksForMaxY(double maxY) {
return List<double>.generate(5, (i) => i == 4 ? maxY : (maxY / 4) * i); return List<double>.generate(5, (i) => i == 4 ? maxY : (maxY / 4) * i);
} }
String _formatMillionsAxisTick(double v) { String _formatLakhsAxisTick(double v) {
if (v == 0) return '0'; if (v == 0) return '0';
if (v >= 10) return '${v.round()}M'; if (v >= 100) return '${v.round()}L';
return '${v.toStringAsFixed(1)}M'; return '${v.toStringAsFixed(1)}L';
}
/// Lakhs display for KPI tiles (e.g. incurred-by-status grid).
String formatClaimsLakhsDisplay(double lakhs) => _formatLakhsAxisTick(lakhs);
String _formatLakhsAmountTick(double amount) {
if (amount == 0) return '0';
final lakhs = amount / 100000;
if (lakhs >= 100) return '${lakhs.round()}L';
return '${lakhs.toStringAsFixed(1)}L';
} }
/// Colors aligned with the Nhance reference design (top bottom). /// Colors aligned with the Nhance reference design (top bottom).
@ -46,19 +56,19 @@ final incurredByStatusColors = [
ClaimsOverviewTheme.cyan, ClaimsOverviewTheme.cyan,
]; ];
/// Horizontal incurred-by-status chart with hover tooltip and track highlight. /// Horizontal incurred-by-status chart with value labels at bar front (right end).
class ClaimsHorizontalIncurredBarChart extends StatefulWidget { class ClaimsHorizontalIncurredBarChart extends StatefulWidget {
final List<String> labels; final List<String> labels;
final List<double> valuesMillions; final List<double> valuesLakhs;
final int animationKey; final int animationKey;
final double maxMillions; final double maxLakhs;
ClaimsHorizontalIncurredBarChart({ ClaimsHorizontalIncurredBarChart({
super.key, super.key,
required this.labels, required this.labels,
required this.valuesMillions, required this.valuesLakhs,
required this.animationKey, required this.animationKey,
this.maxMillions = 18, this.maxLakhs = 180,
}); });
@override @override
@ -68,49 +78,63 @@ class ClaimsHorizontalIncurredBarChart extends StatefulWidget {
class _ClaimsHorizontalIncurredBarChartState class _ClaimsHorizontalIncurredBarChartState
extends State<ClaimsHorizontalIncurredBarChart> { extends State<ClaimsHorizontalIncurredBarChart> {
static const Color _hoverTrackColor = Color(0xFFE9EDF2);
static const double _labelColumnWidth = 112; static const double _labelColumnWidth = 112;
static const double _bottomAxisHeight = 36; static const double _bottomAxisHeight = 36;
static const double _xAxisRightInset = 32; static const double _xAxisRightInset = 32;
static const double _barThickness = 26; static const double _barThickness = 26;
int _hoveredIndex = -1; double get _maxAmount => widget.maxLakhs * 100000;
Offset? _tooltipAnchor;
double get _maxAmount => widget.maxMillions * 1000000;
List<double> get _xTicks { List<double> get _xTicks {
final max = _maxAmount; final max = _maxAmount;
if (max <= 0) { if (max <= 0) {
return const [0, 4500000, 9000000, 13500000, 18000000]; return const [0, 450000, 900000, 1350000, 1800000];
} }
final step = max / 4; final step = max / 4;
return [0, step, step * 2, step * 3, max]; return [0, step, step * 2, step * 3, max];
} }
double _toAmount(double millions) => millions * 1000000; double _toAmount(double lakhs) => lakhs * 100000;
void _onPlotHover(Offset local, Size plotSize, int rowCount, List<double> barValues) { List<Widget> _barValueOverlays({
if (rowCount == 0) return; required Size plotSize,
final rowH = plotSize.height / rowCount; required List<double> barValues,
final index = (local.dy / rowH).floor().clamp(0, rowCount - 1); required double animProgress,
if (index == _hoveredIndex) return; }) {
final n = barValues.length;
if (n == 0) return const [];
final barW = _maxAmount > 0 final rowH = plotSize.height / n;
? (barValues[index] / _maxAmount) * plotSize.width const labelH = 22.0;
: 0.0;
setState(() { return List.generate(n, (i) {
_hoveredIndex = index; if (barValues[i] <= 0 || animProgress <= 0) {
_tooltipAnchor = Offset( return const SizedBox.shrink();
barW.clamp(48.0, plotSize.width - 120), }
(index + 0.5) * rowH, final centerY = i * rowH + rowH / 2;
final barW = _maxAmount > 0
? (barValues[i] / _maxAmount) * plotSize.width * animProgress
: 0.0;
if (barW <= 0) return const SizedBox.shrink();
const labelW = 52.0;
const edgePad = 6.0;
// Always outside the bar tip (front / right end), vertically centered.
final left = (barW + edgePad).clamp(4.0, plotSize.width - labelW - 4);
final top =
(centerY - labelH / 2).clamp(2.0, plotSize.height - labelH - 2);
return Positioned(
left: left,
top: top,
child: _BarValueLabel(value: _formatLakhsAmountTick(barValues[i])),
); );
}); });
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final barValues = widget.valuesMillions.map(_toAmount).toList(); final barValues = widget.valuesLakhs.map(_toAmount).toList();
final rowCount = widget.labels.length; final rowCount = widget.labels.length;
return LayoutBuilder( return LayoutBuilder(
@ -167,53 +191,24 @@ class _ClaimsHorizontalIncurredBarChartState
child: Stack( child: Stack(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
children: [ children: [
MouseRegion( CustomPaint(
onHover: (e) => _onPlotHover( size: plotSize,
e.localPosition, painter: _IncurredHorizontalPlotPainter(
plotSize, values: barValues,
rowCount, maxValue: _maxAmount,
barValues, colors: incurredByStatusColors,
), animProgress: animProgress,
onExit: (_) => setState(() { barThickness: _barThickness,
_hoveredIndex = -1; axisColor: _chartAxisColor,
_tooltipAnchor = null; gridColor: ClaimsOverviewTheme.border,
}),
cursor: SystemMouseCursors.click,
child: CustomPaint(
size: plotSize,
painter:
_IncurredHorizontalPlotPainter(
values: barValues,
maxValue: _maxAmount,
colors: incurredByStatusColors,
animProgress: animProgress,
hoveredIndex: _hoveredIndex,
barThickness: _barThickness,
hoverTrackColor: _hoverTrackColor,
axisColor: _chartAxisColor,
gridColor:
ClaimsOverviewTheme.border,
),
), ),
), ),
if (_hoveredIndex >= 0 && ..._barValueOverlays(
_tooltipAnchor != null) plotSize: plotSize,
Positioned( barValues: barValues,
left: (_tooltipAnchor!.dx + 8) animProgress: animProgress,
.clamp(8.0, plotSize.width - 160), ),
top: (_tooltipAnchor!.dy - 52).clamp( ],
4.0,
plotSize.height - 72,
),
child: IgnorePointer(
child: _IncurredTooltip(
title: widget.labels[_hoveredIndex],
value: barValues[_hoveredIndex]
.round(),
),
),
),
],
), ),
), ),
), ),
@ -330,7 +325,7 @@ class _IncurredBottomAxis extends StatelessWidget {
required bool isLast, required bool isLast,
}) { }) {
final label = Text( final label = Text(
t.toInt().toString(), _formatLakhsAmountTick(t),
style: GoogleFonts.poppins( style: GoogleFonts.poppins(
fontSize: 9, fontSize: 9,
color: ClaimsOverviewTheme.textSecondary, color: ClaimsOverviewTheme.textSecondary,
@ -370,45 +365,26 @@ class _IncurredBottomAxis extends StatelessWidget {
} }
} }
class _IncurredTooltip extends StatelessWidget { class _BarValueLabel extends StatelessWidget {
final String title; final String value;
final int value;
const _IncurredTooltip({required this.title, required this.value}); const _BarValueLabel({required this.value});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Container(
elevation: 4, padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
shadowColor: Colors.black26, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: Colors.white.withValues(alpha: 0.92),
child: Container( borderRadius: BorderRadius.circular(4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), border: Border.all(color: ClaimsOverviewTheme.border),
decoration: BoxDecoration( ),
color: Colors.white, child: Text(
borderRadius: BorderRadius.circular(10), value,
border: Border.all(color: ClaimsOverviewTheme.border), style: GoogleFonts.poppins(
), fontSize: 10,
child: Column( fontWeight: FontWeight.w600,
crossAxisAlignment: CrossAxisAlignment.start, color: ClaimsOverviewTheme.textPrimary,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
),
),
Text(
'value : $value',
style: GoogleFonts.poppins(
fontSize: 12,
color: ClaimsOverviewTheme.textSecondary,
),
),
],
), ),
), ),
); );
@ -420,9 +396,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
final double maxValue; final double maxValue;
final List<Color> colors; final List<Color> colors;
final double animProgress; final double animProgress;
final int hoveredIndex;
final double barThickness; final double barThickness;
final Color hoverTrackColor;
final Color axisColor; final Color axisColor;
final Color gridColor; final Color gridColor;
@ -431,9 +405,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
required this.maxValue, required this.maxValue,
required this.colors, required this.colors,
required this.animProgress, required this.animProgress,
required this.hoveredIndex,
required this.barThickness, required this.barThickness,
required this.hoverTrackColor,
required this.axisColor, required this.axisColor,
required this.gridColor, required this.gridColor,
}); });
@ -485,14 +457,6 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
final barTop = centerY - barThickness / 2; final barTop = centerY - barThickness / 2;
final barBottom = centerY + barThickness / 2; final barBottom = centerY + barThickness / 2;
if (i == hoveredIndex) {
final track = RRect.fromRectAndRadius(
Rect.fromLTWH(0, rowTop, size.width, rowH),
const Radius.circular(2),
);
canvas.drawRRect(track, Paint()..color = hoverTrackColor);
}
final barW = (values[i] / maxValue) * size.width * animProgress; final barW = (values[i] / maxValue) * size.width * animProgress;
if (barW <= 0) continue; if (barW <= 0) continue;
@ -510,9 +474,7 @@ class _IncurredHorizontalPlotPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _IncurredHorizontalPlotPainter old) { bool shouldRepaint(covariant _IncurredHorizontalPlotPainter old) {
return old.animProgress != animProgress || return old.animProgress != animProgress || old.values != values;
old.hoveredIndex != hoveredIndex ||
old.values != values;
} }
} }
@ -551,7 +513,6 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
final double bottomAxisHeight; final double bottomAxisHeight;
final double plotRightInset; final double plotRightInset;
final String Function(double value)? formatYTick; final String Function(double value)? formatYTick;
final String tooltipValueLabel;
const ClaimsCustomVerticalBarChart({ const ClaimsCustomVerticalBarChart({
super.key, super.key,
@ -567,7 +528,6 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
this.bottomAxisHeight = 52, this.bottomAxisHeight = 52,
this.plotRightInset = 0, this.plotRightInset = 0,
this.formatYTick, this.formatYTick,
this.tooltipValueLabel = 'count',
}); });
@override @override
@ -577,41 +537,48 @@ class ClaimsCustomVerticalBarChart extends StatefulWidget {
class _ClaimsCustomVerticalBarChartState class _ClaimsCustomVerticalBarChartState
extends State<ClaimsCustomVerticalBarChart> { extends State<ClaimsCustomVerticalBarChart> {
static const Color _hoverTrackColor = Color(0xFFE9EDF2);
int _hoveredIndex = -1;
Offset? _tooltipAnchor;
String _formatTick(double v) => String _formatTick(double v) =>
widget.formatYTick?.call(v) ?? v.toInt().toString(); widget.formatYTick?.call(v) ?? v.toInt().toString();
String _formatTooltipValue(double v) { String _formatBarValue(double v) {
if (widget.formatYTick != null) { if (widget.formatYTick != null) {
return widget.formatYTick!(v); return widget.formatYTick!(v);
} }
return v.round().toString(); return v.round().toString();
} }
void _onPlotHover(Offset local, Size plotSize, int count) { List<Widget> _barValueOverlays({
if (count == 0) return; required Size plotSize,
final chartW = plotSize.width - widget.plotRightInset; required double animProgress,
final slotW = chartW / count; }) {
final index = (local.dx / slotW).floor().clamp(0, count - 1); final n = widget.values.length;
if (index == _hoveredIndex) return; if (n == 0 || widget.maxY <= 0) return const [];
final value = widget.values[index]; final chartW = plotSize.width - widget.plotRightInset;
final barTop = widget.maxY > 0 final slotW = chartW / n;
? plotSize.height * (1 - value / widget.maxY) const labelH = 22.0;
: plotSize.height;
setState(() { return List.generate(n, (i) {
_hoveredIndex = index; if (widget.values[i] <= 0 || animProgress <= 0) {
_tooltipAnchor = Offset((index + 0.5) * slotW, barTop); return const SizedBox.shrink();
}
final centerX = (i + 0.5) * slotW;
final barH =
(widget.values[i] / widget.maxY) * plotSize.height * animProgress;
final barTop = plotSize.height - barH;
return Positioned(
left: (centerX - 38).clamp(2.0, chartW - 74),
width: 76,
top: (barTop - labelH - 4).clamp(2.0, plotSize.height - labelH),
child: Center(
child: _BarValueLabel(value: _formatBarValue(widget.values[i])),
),
);
}); });
} }
Widget _buildChartBody(double animProgress) { Widget _buildChartBody(double animProgress) {
final count = widget.labels.length;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -640,52 +607,25 @@ class _ClaimsCustomVerticalBarChartState
child: Stack( child: Stack(
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
children: [ children: [
MouseRegion( CustomPaint(
onHover: (e) => _onPlotHover( size: plotSize,
e.localPosition, painter: _VerticalBarPlotPainter(
plotSize, values: widget.values,
count, maxY: widget.maxY,
), yTicks: widget.yTicks,
onExit: (_) => setState(() { colors: widget.colors,
_hoveredIndex = -1; animProgress: animProgress,
_tooltipAnchor = null; barWidth: widget.barWidth,
}), barSlotFraction: widget.barSlotFraction,
cursor: SystemMouseCursors.click, plotRightInset: widget.plotRightInset,
child: CustomPaint( axisColor: _chartAxisColor,
size: plotSize, gridColor: ClaimsOverviewTheme.border,
painter: _VerticalBarPlotPainter(
values: widget.values,
maxY: widget.maxY,
yTicks: widget.yTicks,
colors: widget.colors,
animProgress: animProgress,
hoveredIndex: _hoveredIndex,
barWidth: widget.barWidth,
barSlotFraction: widget.barSlotFraction,
plotRightInset: widget.plotRightInset,
hoverTrackColor: _hoverTrackColor,
axisColor: _chartAxisColor,
gridColor: ClaimsOverviewTheme.border,
),
), ),
), ),
if (_hoveredIndex >= 0 && ..._barValueOverlays(
_tooltipAnchor != null) plotSize: plotSize,
Positioned( animProgress: animProgress,
left: (_tooltipAnchor!.dx - 70) ),
.clamp(4.0, plotSize.width - 140),
top: (_tooltipAnchor!.dy - 56)
.clamp(4.0, plotSize.height - 72),
child: IgnorePointer(
child: _VerticalBarTooltip(
title: widget.labels[_hoveredIndex],
valueDisplay: _formatTooltipValue(
widget.values[_hoveredIndex],
),
valueLabel: widget.tooltipValueLabel,
),
),
),
], ],
), ),
), ),
@ -872,67 +812,15 @@ class _VerticalCategoryAxis extends StatelessWidget {
} }
} }
class _VerticalBarTooltip extends StatelessWidget {
final String title;
final String valueDisplay;
final String valueLabel;
const _VerticalBarTooltip({
required this.title,
required this.valueDisplay,
this.valueLabel = 'count',
});
@override
Widget build(BuildContext context) {
return Material(
elevation: 4,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ClaimsOverviewTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
),
),
Text(
'$valueLabel : $valueDisplay',
style: GoogleFonts.poppins(
fontSize: 12,
color: ClaimsOverviewTheme.textSecondary,
),
),
],
),
),
);
}
}
class _VerticalBarPlotPainter extends CustomPainter { class _VerticalBarPlotPainter extends CustomPainter {
final List<double> values; final List<double> values;
final double maxY; final double maxY;
final List<double> yTicks; final List<double> yTicks;
final List<Color> colors; final List<Color> colors;
final double animProgress; final double animProgress;
final int hoveredIndex;
final double barWidth; final double barWidth;
final double barSlotFraction; final double barSlotFraction;
final double plotRightInset; final double plotRightInset;
final Color hoverTrackColor;
final Color axisColor; final Color axisColor;
final Color gridColor; final Color gridColor;
@ -942,11 +830,9 @@ class _VerticalBarPlotPainter extends CustomPainter {
required this.yTicks, required this.yTicks,
required this.colors, required this.colors,
required this.animProgress, required this.animProgress,
required this.hoveredIndex,
required this.barWidth, required this.barWidth,
required this.barSlotFraction, required this.barSlotFraction,
required this.plotRightInset, required this.plotRightInset,
required this.hoverTrackColor,
required this.axisColor, required this.axisColor,
required this.gridColor, required this.gridColor,
}); });
@ -1000,14 +886,6 @@ class _VerticalBarPlotPainter extends CustomPainter {
final centerX = chartLeft + (i + 0.5) * slotW; final centerX = chartLeft + (i + 0.5) * slotW;
final left = centerX - effectiveBarW / 2; final left = centerX - effectiveBarW / 2;
if (i == hoveredIndex) {
final track = RRect.fromRectAndRadius(
Rect.fromLTWH(left, 0, effectiveBarW, size.height),
const Radius.circular(2),
);
canvas.drawRRect(track, Paint()..color = hoverTrackColor);
}
final barH = (values[i] / maxY) * size.height * animProgress; final barH = (values[i] / maxY) * size.height * animProgress;
if (barH <= 0) continue; if (barH <= 0) continue;
@ -1026,9 +904,7 @@ class _VerticalBarPlotPainter extends CustomPainter {
@override @override
bool shouldRepaint(covariant _VerticalBarPlotPainter old) { bool shouldRepaint(covariant _VerticalBarPlotPainter old) {
return old.animProgress != animProgress || return old.animProgress != animProgress || old.values != values;
old.hoveredIndex != hoveredIndex ||
old.values != values;
} }
} }
@ -1043,7 +919,7 @@ final claimsCountByStatusColors = [
ClaimsOverviewTheme.cyan, ClaimsOverviewTheme.cyan,
]; ];
/// Vertical claims-count chart: wide bars, hover tooltip, slow rise animation. /// Vertical claims-count chart: wide bars, value labels on top, rise animation.
class ClaimsCountByStatusBarChart extends StatefulWidget { class ClaimsCountByStatusBarChart extends StatefulWidget {
final List<String> labels; final List<String> labels;
final List<double> values; final List<double> values;
@ -1120,8 +996,7 @@ class _ClaimsCountByClaimTypeChartState
colors: claimTypeCountColors, colors: claimTypeCountColors,
animationKey: widget.animationKey, animationKey: widget.animationKey,
yTicks: _yTicksForMaxY(maxY), yTicks: _yTicksForMaxY(maxY),
formatYTick: _formatMillionsAxisTick, formatYTick: _formatLakhsAxisTick,
tooltipValueLabel: 'amount',
leftAxisWidth: 48, leftAxisWidth: 48,
barWidth: 56, barWidth: 56,
bottomAxisHeight: 52, bottomAxisHeight: 52,
@ -1169,7 +1044,6 @@ class ClaimsVerticalBarChart extends StatelessWidget {
bottomAxisHeight: 48, bottomAxisHeight: 48,
plotRightInset: 8, plotRightInset: 8,
formatYTick: (v) => v.toInt().toString(), formatYTick: (v) => v.toInt().toString(),
tooltipValueLabel: 'value',
); );
} }
} }
@ -1759,14 +1633,13 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
if (_hoveredIndex >= 0 && if (_hoveredIndex >= 0 &&
_tooltipAnchor != null) _tooltipAnchor != null)
Positioned( Positioned(
left: (_tooltipAnchor!.dx + 8) left: (_tooltipAnchor!.dx - 38)
.clamp(8.0, plotSize.width - 150), .clamp(4.0, plotSize.width - 76),
top: (_tooltipAnchor!.dy - 56) top: (_tooltipAnchor!.dy - 26)
.clamp(4.0, plotSize.height - 72), .clamp(4.0, plotSize.height - 24),
child: IgnorePointer( child: _BarValueLabel(
child: _AreaChartTooltip( value: _formatLakhsAxisTick(
title: widget.labels[_hoveredIndex], widget.values[_hoveredIndex],
value: widget.values[_hoveredIndex],
), ),
), ),
), ),
@ -1792,57 +1665,6 @@ class _ClaimsAreaChartState extends State<ClaimsAreaChart> {
} }
} }
class _AreaChartTooltip extends StatelessWidget {
final String title;
final double value;
const _AreaChartTooltip({required this.title, required this.value});
String get _valueText {
if (value == value.roundToDouble()) return value.round().toString();
return value.toStringAsFixed(1);
}
@override
Widget build(BuildContext context) {
return Material(
elevation: 4,
shadowColor: Colors.black26,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: ClaimsOverviewTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: GoogleFonts.poppins(
fontSize: 14,
fontWeight: FontWeight.w700,
color: ClaimsOverviewTheme.textPrimary,
),
),
Text(
'value : $_valueText',
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w500,
color: ClaimsOverviewTheme.teal,
),
),
],
),
),
);
}
}
class _AreaLinePlotPainter extends CustomPainter { class _AreaLinePlotPainter extends CustomPainter {
final List<double> values; final List<double> values;
final double maxY; final double maxY;

View File

@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import '../../customAppBar/base_layout.dart'; import '../../customAppBar/base_layout.dart';
import '../../customAppBar/toastHelper.dart'; import '../../customAppBar/toastHelper.dart';
@ -44,15 +47,16 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
String? _clientBranchId; String? _clientBranchId;
String? _selectedPolicyId; String? _selectedPolicyId;
List<Map<String, dynamic>> _activePolicies = []; List<Map<String, dynamic>> _activePolicies = [];
Uint8List? _clientLogoBytes;
static const _tabs = [ static const _tabs = [
(Icons.dashboard_outlined, 'Overview'), (Icons.dashboard_outlined, 'Overview'),
(Icons.timeline, 'Policy Experience'), (Icons.timeline, 'Claims Overview'),
(Icons.analytics_outlined, 'Claims Analysis'), (Icons.analytics_outlined, 'Claims Analysis'),
(Icons.people_outline, 'Demographics'), (Icons.people_outline, 'Demographics'),
(Icons.location_city_outlined, 'Hospitals & Geography'), (Icons.location_city_outlined, 'Hospitals & Geography'),
(Icons.medical_services_outlined, 'Specialty Analysis'), (Icons.medical_services_outlined, 'Specialty Analysis'),
(Icons.group_add_outlined, 'Enrollment'), (Icons.group_add_outlined, 'Insurred'),
]; ];
@override @override
@ -231,8 +235,10 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
if (!stillValid) { if (!stillValid) {
_selectedPolicyId = firstId; _selectedPolicyId = firstId;
} }
await _loadClientLogoForSelectedPolicy();
} else { } else {
_selectedPolicyId = null; _selectedPolicyId = null;
_clientLogoBytes = null;
} }
} }
@ -258,9 +264,30 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
return null; return null;
} }
Future<void> _loadClientLogoForSelectedPolicy() async {
final url = _selectedPolicy()?['client_logo']?.toString().trim();
if (url == null || url.isEmpty) {
if (mounted) setState(() => _clientLogoBytes = null);
return;
}
try {
final response = await http.get(Uri.parse(url));
if (!mounted) return;
if (response.statusCode == 200 && response.bodyBytes.isNotEmpty) {
setState(() => _clientLogoBytes = response.bodyBytes);
} else {
setState(() => _clientLogoBytes = null);
}
} catch (_) {
if (mounted) setState(() => _clientLogoBytes = null);
}
}
void _onPolicySelected(String policyId) { void _onPolicySelected(String policyId) {
if (policyId == _selectedPolicyId) return; if (policyId == _selectedPolicyId) return;
setState(() => _selectedPolicyId = policyId); setState(() => _selectedPolicyId = policyId);
_loadClientLogoForSelectedPolicy();
_loadDashboard(); _loadDashboard();
} }
@ -329,12 +356,17 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
); );
try { try {
if (_clientLogoBytes == null) {
await _loadClientLogoForSelectedPolicy();
}
await exportClaimsOverviewChartsPdf( await exportClaimsOverviewChartsPdf(
overlay: overlay, overlay: overlay,
replayToken: _replayToken, replayToken: _replayToken,
viewData: _viewData, viewData: _viewData,
enrollmentViewData: _enrollmentViewData, enrollmentViewData: _enrollmentViewData,
policyId: _selectedPolicyId ?? '', policyId: _selectedPolicyId ?? '',
clientLogoBytes: _clientLogoBytes,
onProgress: (_, __, label) { onProgress: (_, __, label) {
updateProgressDialog?.call(() { updateProgressDialog?.call(() {
progressLabel = label; progressLabel = label;

View File

@ -23,12 +23,12 @@ import 'claims_overview_theme.dart';
const _tabNames = [ const _tabNames = [
'Overview', 'Overview',
'Policy Experience', 'Claims Overview',
'Claims Analysis', 'Claims Analysis',
'Demographics', 'Demographics',
'Hospitals & Geography', 'Hospitals & Geography',
'Specialty Analysis', 'Specialty Analysis',
'Enrollment', 'Insurred',
]; ];
/// Fallback heights if layout measure fails (avoids second pass). /// Fallback heights if layout measure fails (avoids second pass).
@ -63,10 +63,12 @@ class _TabCapture {
class _PdfBuildInput { class _PdfBuildInput {
final List<_TabCapture> captures; final List<_TabCapture> captures;
final Uint8List? logoBytes; final Uint8List? logoBytes;
final Uint8List? clientLogoBytes;
const _PdfBuildInput({ const _PdfBuildInput({
required this.captures, required this.captures,
required this.logoBytes, required this.logoBytes,
this.clientLogoBytes,
}); });
} }
@ -103,11 +105,14 @@ Uint8List _compressTabImageForPdf(Uint8List pngBytes) {
} }
} }
pw.MemoryImage? _pdfMemoryImage(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return pw.MemoryImage(bytes);
}
Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async { Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
pw.MemoryImage? logo; final logo = _pdfMemoryImage(input.logoBytes);
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) { final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
logo = pw.MemoryImage(input.logoBytes!);
}
final doc = pw.Document(); final doc = pw.Document();
@ -120,7 +125,7 @@ Future<Uint8List> _buildPdfBytesSync(_PdfBuildInput input) async {
build: (ctx) => pw.Column( build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
_pdfPageHeader(title: capture.tabName, logo: logo), _pdfPageHeader(clientLogo: clientLogo, logo: logo),
pw.SizedBox(height: 8), pw.SizedBox(height: 8),
pw.Expanded( pw.Expanded(
child: pw.Center( child: pw.Center(
@ -145,10 +150,8 @@ Future<Uint8List> _buildPdfBytesWithYields(
ClaimsPdfExportProgress? onProgress, ClaimsPdfExportProgress? onProgress,
int totalSteps, int totalSteps,
) async { ) async {
pw.MemoryImage? logo; final logo = _pdfMemoryImage(input.logoBytes);
if (input.logoBytes != null && input.logoBytes!.isNotEmpty) { final clientLogo = _pdfMemoryImage(input.clientLogoBytes);
logo = pw.MemoryImage(input.logoBytes!);
}
final doc = pw.Document(); final doc = pw.Document();
@ -171,7 +174,7 @@ Future<Uint8List> _buildPdfBytesWithYields(
build: (ctx) => pw.Column( build: (ctx) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch, crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [ children: [
_pdfPageHeader(title: capture.tabName, logo: logo), _pdfPageHeader(clientLogo: clientLogo, logo: logo),
pw.SizedBox(height: 8), pw.SizedBox(height: 8),
pw.Expanded( pw.Expanded(
child: pw.Center( child: pw.Center(
@ -359,45 +362,24 @@ Future<Uint8List?> _captureTabPng({
} }
pw.Widget _pdfPageHeader({ pw.Widget _pdfPageHeader({
required String title, pw.MemoryImage? clientLogo,
pw.MemoryImage? logo, pw.MemoryImage? logo,
bool showSubtitle = false,
String? subtitle,
}) { }) {
return pw.Row( return pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start, crossAxisAlignment: pw.CrossAxisAlignment.center,
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Expanded( if (clientLogo != null)
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
title,
style: pw.TextStyle(
fontSize: showSubtitle ? 22 : 14,
fontWeight: pw.FontWeight.bold,
color: PdfColors.grey800,
),
),
if (showSubtitle && subtitle != null) ...[
pw.SizedBox(height: 6),
pw.Text(
subtitle,
style: const pw.TextStyle(
fontSize: 11,
color: PdfColors.grey700,
),
),
],
],
),
),
if (logo != null)
pw.Container( pw.Container(
alignment: pw.Alignment.centerRight, height: 28,
child: pw.Image(logo, height: 28, fit: pw.BoxFit.contain), constraints: const pw.BoxConstraints(maxWidth: 120),
), alignment: pw.Alignment.centerLeft,
child: pw.Image(clientLogo, fit: pw.BoxFit.contain),
)
else
pw.SizedBox.shrink(),
if (logo != null)
pw.Image(logo, height: 28, fit: pw.BoxFit.contain),
], ],
); );
} }
@ -438,6 +420,7 @@ Future<void> exportClaimsOverviewChartsPdf({
required ClaimsOverviewViewData viewData, required ClaimsOverviewViewData viewData,
required EnrollmentOverviewViewData enrollmentViewData, required EnrollmentOverviewViewData enrollmentViewData,
required String policyId, required String policyId,
Uint8List? clientLogoBytes,
ClaimsPdfExportProgress? onProgress, ClaimsPdfExportProgress? onProgress,
}) async { }) async {
await _preloadFonts(); await _preloadFonts();
@ -495,6 +478,7 @@ Future<void> exportClaimsOverviewChartsPdf({
final buildInput = _PdfBuildInput( final buildInput = _PdfBuildInput(
captures: captures, captures: captures,
logoBytes: logoBytes, logoBytes: logoBytes,
clientLogoBytes: clientLogoBytes,
); );
final Uint8List bytes; final Uint8List bytes;

View File

@ -79,22 +79,43 @@ class ClaimsTabMiniGrid extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(items.length == 4); assert(items.length == 3 || items.length == 4);
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxW = constraints.maxWidth; final maxW = constraints.maxWidth;
final isPdf = ClaimsPdfExportScope.of(context); final isPdf = ClaimsPdfExportScope.of(context);
Widget cell(int index) { Widget cell(int index, {bool fullWidth = false}) {
final child = items[index]; final child = items[index];
if (isPdf && maxW.isFinite) { if (isPdf && maxW.isFinite) {
final half = (maxW - gap) / 2; final width = fullWidth ? maxW : (maxW - gap) / 2;
return SizedBox(width: half, child: child); return SizedBox(width: width, child: child);
} }
return Expanded(child: child); return Expanded(child: child);
} }
if (items.length == 3) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
cell(0),
SizedBox(width: gap),
cell(1),
],
),
SizedBox(height: gap),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [cell(2, fullWidth: true)],
),
],
);
}
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View File

@ -14,7 +14,10 @@ Widget _tabBodyWrapper({
required Widget child, required Widget child,
}) { }) {
if (forPdfExport) return child; if (forPdfExport) return child;
return SingleChildScrollView(child: child); return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: child,
);
} }
Widget _claimsChartPlaceholder(String message) { Widget _claimsChartPlaceholder(String message) {
@ -84,6 +87,23 @@ class ClaimsOverviewTab extends StatelessWidget {
} }
class _OverviewTab extends StatelessWidget { class _OverviewTab extends StatelessWidget {
static const _metricGap = 12.0;
static const _sectionBodyPadding = 32.0;
static const _sectionHeaderHeight = 46.0;
/// Fixed height from the larger tile count in a paired row (no flex in scroll view).
static double _pairedSectionHeight(
int leftTiles,
int rightTiles, {
int columns = 2,
}) {
final maxTiles = leftTiles > rightTiles ? leftTiles : rightTiles;
final rows = (maxTiles + columns - 1) ~/ columns;
final gridHeight = rows * ClaimsMetricCard.layoutHeight +
(rows > 1 ? (rows - 1) * _metricGap : 0);
return _sectionHeaderHeight + _sectionBodyPadding + gridHeight;
}
final int replayToken; final int replayToken;
final bool forPdfExport; final bool forPdfExport;
@ -95,88 +115,153 @@ class _OverviewTab extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final d = ClaimsOverviewScope.of(context); final d = ClaimsOverviewScope.of(context);
final policyRowHeight = _pairedSectionHeight(5, 5);
final claimsRowHeight = _pairedSectionHeight(5, 2);
return _tabBodyWrapper( return _tabBodyWrapper(
forPdfExport: forPdfExport, forPdfExport: forPdfExport,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClaimsStaggeredEntrance( _overviewTwoColumnRow(
replayToken: replayToken, baseIndex: 0,
index: 0, left: _overviewGroup(
child: _sectionTitle( title: 'Policy Information',
Icons.description_outlined, icon: Icons.description_outlined,
'Policy Basic Information', sectionHeight: policyRowHeight,
columns: 2,
cards: [
_m('Policy Start Date', d.policyStartDate,
Icons.calendar_today, ClaimsOverviewTheme.blue,
animateValue: false),
_m('Policy End Date', d.policyEndDate, Icons.event,
ClaimsOverviewTheme.red, animateValue: false),
_m('Policy Run Days', d.policyRunDays, Icons.timelapse,
ClaimsOverviewTheme.purple),
_m('TPA', d.tpaName, Icons.apartment, ClaimsOverviewTheme.teal,
animateValue: false),
_m('Insurer', d.insurerName, Icons.military_tech,
ClaimsOverviewTheme.orange, animateValue: false),
],
),
right: _overviewGroup(
title: 'Premium & Membership',
icon: Icons.groups_outlined,
sectionHeight: policyRowHeight,
columns: 2,
cards: [
_m('Premium As On Date', d.premiumAsOnDate,
Icons.account_balance_wallet, ClaimsOverviewTheme.orange),
_m('Earned Premium', d.earnedPremium, Icons.payments,
ClaimsOverviewTheme.green),
_m('Current Employee', d.currentEmployee,
Icons.business_center, ClaimsOverviewTheme.blue),
_m('Current Lives', d.currentLives, Icons.favorite_border,
ClaimsOverviewTheme.pink),
_m('Avg F-Size', d.avgFamilySize, Icons.groups,
ClaimsOverviewTheme.purple),
],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 16),
LayoutBuilder( _overviewTwoColumnRow(
builder: (context, constraints) { baseIndex: 2,
return _OverviewMetricGrid( left: _overviewGroup(
replayToken: replayToken, title: 'Claims Experience',
maxWidth: constraints.maxWidth, icon: Icons.analytics_outlined,
cards: [ sectionHeight: claimsRowHeight,
_m(1, 'Policy Start Date', d.policyStartDate, columns: 2,
Icons.calendar_today, ClaimsOverviewTheme.blue, cards: [
animateValue: false), _m('Incurred Claims', d.incurredClaims, Icons.gps_fixed,
_m(2, 'Policy End Date', d.policyEndDate, Icons.event, ClaimsOverviewTheme.red),
ClaimsOverviewTheme.red, animateValue: false), _m('Incurred Ratio', d.incurredRatio, Icons.speed,
_m(3, 'Policy Run Days', d.policyRunDays, Icons.timelapse, ClaimsOverviewTheme.teal),
ClaimsOverviewTheme.purple), _m('Projected Claims', d.projectedClaims, Icons.trending_up,
_m(5, 'Premium As On Date', d.premiumAsOnDate, ClaimsOverviewTheme.red),
Icons.account_balance_wallet, ClaimsOverviewTheme.orange), _m('Projected Ratio', d.projectedRatio, Icons.percent,
_m(6, 'Earned Premium', d.earnedPremium, Icons.payments, ClaimsOverviewTheme.orange),
ClaimsOverviewTheme.green), _m('Claims Incidence Rate', d.claimsIncidenceRate,
_m(7, 'Current Employee', d.currentEmployee, Icons.timeline, ClaimsOverviewTheme.cyan),
Icons.business_center, ClaimsOverviewTheme.blue), ],
_m(8, 'Current Lives', d.currentLives, Icons.favorite_border, ),
ClaimsOverviewTheme.pink), right: _overviewGroup(
_m(9, 'Avg F-Size', d.avgFamilySize, Icons.groups, title: 'Inception',
ClaimsOverviewTheme.purple), icon: Icons.flag_outlined,
_m(11, 'Incurred Claims', d.incurredClaims, Icons.gps_fixed, sectionHeight: claimsRowHeight,
ClaimsOverviewTheme.red), columns: 2,
_m(12, 'Incurred Ratio', d.incurredRatio, Icons.speed, cards: [
ClaimsOverviewTheme.teal), _m('Inception Employee', d.inceptionEmployee,
_m(13, 'Inception Employee', d.inceptionEmployee, Icons.person_add_alt, ClaimsOverviewTheme.blue),
Icons.person_add_alt, ClaimsOverviewTheme.blue), _m('Inception Lives', d.inceptionLives, Icons.favorite_border,
_m(14, 'Inception Lives', d.inceptionLives, ClaimsOverviewTheme.pink),
Icons.favorite_border, ClaimsOverviewTheme.pink), ],
_m(16, 'Projected Claims', d.projectedClaims, Icons.trending_up, ),
ClaimsOverviewTheme.red),
_m(17, 'Projected Ratio', d.projectedRatio, Icons.percent,
ClaimsOverviewTheme.orange),
_m(18, 'TPA', d.tpaName, Icons.apartment,
ClaimsOverviewTheme.teal, animateValue: false),
_m(19, 'Insurer', d.insurerName, Icons.military_tech,
ClaimsOverviewTheme.orange, animateValue: false),
],
);
},
), ),
], ],
), ),
); );
} }
Widget _sectionTitle(IconData icon, String title) { Widget _overviewTwoColumnRow({
return Row( required int baseIndex,
children: [ required Widget left,
Icon(icon, size: 18, color: ClaimsOverviewTheme.primary), required Widget right,
const SizedBox(width: 8), }) {
Text( return ClaimsTabPanelRow(
title, panels: [
style: GoogleFonts.poppins( ClaimsTabPanel(
fontSize: 14, flex: 1,
fontWeight: FontWeight.w600, child: ClaimsStaggeredEntrance(
color: ClaimsOverviewTheme.textPrimary, replayToken: replayToken,
index: baseIndex,
child: left,
),
),
ClaimsTabPanel(
flex: 1,
child: ClaimsStaggeredEntrance(
replayToken: replayToken,
index: baseIndex + 1,
child: right,
), ),
), ),
], ],
); );
} }
Widget _overviewGroup({
required String title,
required IconData icon,
required List<ClaimsMetricCard> cards,
required double sectionHeight,
int columns = 2,
}) {
return ConstrainedBox(
constraints: BoxConstraints(
minHeight: sectionHeight,
maxHeight: sectionHeight,
),
child: ClaimsSectionCard(
title: title,
icon: icon,
child: LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: (ClaimsPdfExportScope.laneWidthOf(context) ?? 800);
return _OverviewMetricGrid(
replayToken: replayToken,
maxWidth: maxW,
columns: columns,
cards: cards,
);
},
),
),
);
}
ClaimsMetricCard _m( ClaimsMetricCard _m(
int stagger,
String label, String label,
String value, String value,
IconData icon, IconData icon,
@ -195,25 +280,26 @@ class _OverviewTab extends StatelessWidget {
} }
/// 4-column metric grid equal card width on every row. /// Metric grid with equal card width per row inside a section card.
class _OverviewMetricGrid extends StatelessWidget { class _OverviewMetricGrid extends StatelessWidget {
static const _columns = 4;
static const _gap = 12.0; static const _gap = 12.0;
final List<Widget> cards; final List<Widget> cards;
final int replayToken; final int replayToken;
final double maxWidth; final double maxWidth;
final int columns;
const _OverviewMetricGrid({ const _OverviewMetricGrid({
required this.cards, required this.cards,
required this.replayToken, required this.replayToken,
required this.maxWidth, required this.maxWidth,
this.columns = 4,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cellWidth = final cols = columns.clamp(1, 4);
(maxWidth - _gap * (_columns - 1)) / _columns; final cellWidth = (maxWidth - _gap * (cols - 1)) / cols;
return Wrap( return Wrap(
spacing: _gap, spacing: _gap,
@ -325,74 +411,78 @@ class _PolicyExperienceTabState extends State<_PolicyExperienceTab>
forPdfExport: widget.forPdfExport, forPdfExport: widget.forPdfExport,
child: ClaimsTabPanelRow( child: ClaimsTabPanelRow(
panels: [ panels: [
ClaimsTabPanel( ClaimsTabPanel(
flex: 7, flex: 7,
child: Column( child: Column(
children: [ mainAxisSize: MainAxisSize.min,
ClaimsStaggeredEntrance( children: [
replayToken: widget.replayToken, ClaimsStaggeredEntrance(
index: 0, replayToken: widget.replayToken,
child: ClaimsSectionCard( index: 0,
title: 'Total Incurred by Claim Status', child: ClaimsSectionCard(
icon: Icons.bar_chart, title: 'Total Incurred by Claim Status',
child: SizedBox( icon: Icons.bar_chart,
height: 300, child: SizedBox(
child: incurredSeries.isEmpty height: 300,
? _claimsChartPlaceholder('No incurred-by-status data') child: incurredSeries.isEmpty
: ClaimsHorizontalIncurredBarChart( ? _claimsChartPlaceholder(
labels: incurredSeries.labels, 'No incurred-by-status data',
valuesMillions: incurredSeries.values, )
animationKey: _horizontalChartKey, : ClaimsHorizontalIncurredBarChart(
maxMillions: incurredSeries.maxY ?? 18, labels: incurredSeries.labels,
), valuesLakhs: incurredSeries.values,
), animationKey: _horizontalChartKey,
maxLakhs: incurredSeries.maxY ?? 180,
),
), ),
), ),
const SizedBox(height: 16), ),
ClaimsStaggeredEntrance( const SizedBox(height: 16),
replayToken: widget.replayToken, ClaimsStaggeredEntrance(
index: 1, replayToken: widget.replayToken,
child: ClaimsSectionCard( index: 1,
title: 'Total Claims Count by Status', child: ClaimsSectionCard(
icon: Icons.insert_chart_outlined, title: 'Total Claims Count by Status',
child: SizedBox( icon: Icons.insert_chart_outlined,
height: 280, child: SizedBox(
child: countSeries.isEmpty height: 280,
? _claimsChartPlaceholder('No claims count data') child: countSeries.isEmpty
: ClaimsCountByStatusBarChart( ? _claimsChartPlaceholder('No claims count data')
labels: countSeries.labels, : ClaimsCountByStatusBarChart(
values: countSeries.values, labels: countSeries.labels,
animationKey: _verticalChartKey, values: countSeries.values,
maxY: countSeries.maxY ?? 220, animationKey: _verticalChartKey,
), maxY: countSeries.maxY ?? 220,
), ),
), ),
), ),
], ),
), ],
), ),
ClaimsTabPanel( ),
flex: 3, ClaimsTabPanel(
child: Column( flex: 3,
children: [ child: Column(
for (var i = 0; i < kpiMetrics.length; i++) ...[ mainAxisSize: MainAxisSize.min,
if (i > 0) const SizedBox(height: 12), children: [
ClaimsStaggeredEntrance( for (var i = 0; i < kpiMetrics.length; i++) ...[
if (i > 0) const SizedBox(height: 12),
ClaimsStaggeredEntrance(
replayToken: widget.replayToken,
index: 2 + i,
child: ClaimsMetricCard(
label: kpiMetrics[i].$1,
value: kpiMetrics[i].$2,
icon: kpiMetrics[i].$3,
accentColor: kpiMetrics[i].$4,
replayToken: widget.replayToken, replayToken: widget.replayToken,
index: 2 + i,
child: ClaimsMetricCard(
label: kpiMetrics[i].$1,
value: kpiMetrics[i].$2,
icon: kpiMetrics[i].$3,
accentColor: kpiMetrics[i].$4,
replayToken: widget.replayToken,
),
), ),
], ),
], ],
), ],
), ),
], ),
],
), ),
); );
} }
@ -415,6 +505,8 @@ class _ClaimsAnalysisTab extends StatefulWidget {
class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab> class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
static const _pairedChartHeight = 280.0;
int _chartAnimKey = 0; int _chartAnimKey = 0;
@override @override
@ -466,7 +558,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Claim Amount by Type', title: 'Claim Amount by Type',
icon: Icons.bar_chart, icon: Icons.bar_chart,
child: SizedBox( child: SizedBox(
height: 280, height: _pairedChartHeight,
child: claimTypeCounts.isEmpty child: claimTypeCounts.isEmpty
? _claimsChartPlaceholder('No claim type count data') ? _claimsChartPlaceholder('No claim type count data')
: ClaimsCountByClaimTypeChart( : ClaimsCountByClaimTypeChart(
@ -481,7 +573,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Total Incurred by Claim Type', title: 'Total Incurred by Claim Type',
icon: Icons.donut_large, icon: Icons.donut_large,
child: SizedBox( child: SizedBox(
height: 260, height: _pairedChartHeight,
child: claimTypeIncurred.isEmpty child: claimTypeIncurred.isEmpty
? _claimsChartPlaceholder('No claim type incurred data') ? _claimsChartPlaceholder('No claim type incurred data')
: ClaimsDonutChart( : ClaimsDonutChart(
@ -501,7 +593,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Claim Amount by Claim Status', title: 'Claim Amount by Claim Status',
icon: Icons.pie_chart, icon: Icons.pie_chart,
child: SizedBox( child: SizedBox(
height: 280, height: _pairedChartHeight,
child: statusDonut.isEmpty child: statusDonut.isEmpty
? _claimsChartPlaceholder('No claim status amount data') ? _claimsChartPlaceholder('No claim status amount data')
: ClaimsDonutChart( : ClaimsDonutChart(
@ -517,7 +609,7 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
title: 'Amount by Month Wise', title: 'Amount by Month Wise',
icon: Icons.show_chart, icon: Icons.show_chart,
child: SizedBox( child: SizedBox(
height: 280, height: _pairedChartHeight,
child: monthSeries.isEmpty child: monthSeries.isEmpty
? _claimsChartPlaceholder('No monthly amount data') ? _claimsChartPlaceholder('No monthly amount data')
: ClaimsAreaChart( : ClaimsAreaChart(
@ -536,7 +628,8 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
} }
Widget _chartRow(int baseIndex, Widget left, Widget right) { Widget _chartRow(int baseIndex, Widget left, Widget right) {
return ClaimsTabPanelRow( final row = ClaimsTabPanelRow(
crossAxisAlignment: CrossAxisAlignment.stretch,
panels: [ panels: [
ClaimsTabPanel( ClaimsTabPanel(
flex: 1, flex: 1,
@ -556,6 +649,8 @@ class _ClaimsAnalysisTabState extends State<_ClaimsAnalysisTab>
), ),
], ],
); );
if (widget.forPdfExport) return row;
return IntrinsicHeight(child: row);
} }
} }
@ -670,6 +765,8 @@ class _DemographicsTab extends StatelessWidget {
} }
class _HospitalsTab extends StatelessWidget { class _HospitalsTab extends StatelessWidget {
static const _hospitalsPairHeight = 360.0;
final int replayToken; final int replayToken;
final bool forPdfExport; final bool forPdfExport;
@ -723,6 +820,7 @@ class _HospitalsTab extends StatelessWidget {
child: ClaimsSectionCard( child: ClaimsSectionCard(
title: 'Top 5 Hospitals by Incurred Amount', title: 'Top 5 Hospitals by Incurred Amount',
icon: Icons.local_hospital_outlined, icon: Icons.local_hospital_outlined,
height: _hospitalsPairHeight,
child: hospitals.isEmpty child: hospitals.isEmpty
? _claimsChartPlaceholder('No hospital data') ? _claimsChartPlaceholder('No hospital data')
: Column( : Column(
@ -748,6 +846,7 @@ class _HospitalsTab extends StatelessWidget {
child: ClaimsSectionCard( child: ClaimsSectionCard(
title: 'Total Incurred by City', title: 'Total Incurred by City',
icon: Icons.location_on_outlined, icon: Icons.location_on_outlined,
height: _hospitalsPairHeight,
child: cities.isEmpty child: cities.isEmpty
? _claimsChartPlaceholder('No city data') ? _claimsChartPlaceholder('No city data')
: Column( : Column(
@ -1066,7 +1165,6 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
Icons.payments_outlined, Icons.payments_outlined,
ClaimsOverviewTheme.teal, ClaimsOverviewTheme.teal,
), ),
const SizedBox.shrink(),
]), ]),
), ),
), ),
@ -1097,7 +1195,6 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
Icons.person_add_alt_1, Icons.person_add_alt_1,
ClaimsOverviewTheme.orange, ClaimsOverviewTheme.orange,
), ),
const SizedBox.shrink(),
]), ]),
), ),
), ),
@ -1342,7 +1439,6 @@ class _EnrollmentAgeTrendChart extends StatelessWidget {
leftAxisWidth: forPdfExport ? 40 : 48, leftAxisWidth: forPdfExport ? 40 : 48,
bottomAxisHeight: forPdfExport ? 40 : 52, bottomAxisHeight: forPdfExport ? 40 : 52,
formatYTick: (v) => v.toStringAsFixed(0), formatYTick: (v) => v.toStringAsFixed(0),
tooltipValueLabel: 'avg age',
); );
} }
} }

View File

@ -6,6 +6,9 @@ import 'claims_overview_scope.dart';
import 'claims_overview_theme.dart'; import 'claims_overview_theme.dart';
class ClaimsMetricCard extends StatelessWidget { class ClaimsMetricCard extends StatelessWidget {
/// Measured layout: label row (~50) + value block (62) + accent (3).
static const double layoutHeight = 115;
final String label; final String label;
final String value; final String value;
final IconData icon; final IconData icon;
@ -34,6 +37,7 @@ class ClaimsMetricCard extends StatelessWidget {
); );
return Container( return Container(
width: double.infinity,
constraints: BoxConstraints(minHeight: compact ? 88 : 108), constraints: BoxConstraints(minHeight: compact ? 88 : 108),
decoration: BoxDecoration( decoration: BoxDecoration(
color: ClaimsOverviewTheme.cardBg, color: ClaimsOverviewTheme.cardBg,
@ -49,6 +53,7 @@ class ClaimsMetricCard extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
@ -157,17 +162,58 @@ class ClaimsSectionCard extends StatelessWidget {
final String title; final String title;
final IconData icon; final IconData icon;
final Widget child; final Widget child;
final bool fillHeight;
final double? height;
const ClaimsSectionCard({ const ClaimsSectionCard({
super.key, super.key,
required this.title, required this.title,
required this.icon, required this.icon,
required this.child, required this.child,
this.fillHeight = false,
this.height,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final body = Padding(
padding: const EdgeInsets.all(16),
child: child,
);
final header = Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Row(
children: [
Icon(icon, size: 20, color: ClaimsOverviewTheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: GoogleFonts.poppins(
fontSize: 15,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),
),
),
],
),
);
final useExpandedBody = fillHeight || height != null;
final bodySlot = useExpandedBody
? Expanded(
child: Align(
alignment: Alignment.topLeft,
child: body,
),
)
: body;
return Container( return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: ClaimsOverviewTheme.cardBg, color: ClaimsOverviewTheme.cardBg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -182,36 +228,92 @@ class ClaimsSectionCard extends StatelessWidget {
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize:
useExpandedBody ? MainAxisSize.max : MainAxisSize.min,
children: [ children: [
Padding( header,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), bodySlot,
child: Row(
children: [
Icon(icon, size: 20, color: ClaimsOverviewTheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: GoogleFonts.poppins(
fontSize: 15,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: child,
),
], ],
), ),
); );
} }
} }
/// Metric tiles in a grid [columns] per row (default 3), title + value inside each card.
class ClaimsMetricTileGrid extends StatelessWidget {
static const _gap = 12.0;
static const _tileMinHeight = 108.0;
final List<String> labels;
final List<String> values;
final List<Color> accentColors;
final int replayToken;
final int columns;
final IconData icon;
const ClaimsMetricTileGrid({
super.key,
required this.labels,
required this.values,
required this.accentColors,
required this.replayToken,
this.columns = 3,
this.icon = Icons.insights_outlined,
});
@override
Widget build(BuildContext context) {
assert(labels.length == values.length);
final cols = columns.clamp(1, 4);
return LayoutBuilder(
builder: (context, constraints) {
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: 800.0;
final cellWidth = (maxW - _gap * (cols - 1)) / cols;
final rowCount = (labels.length + cols - 1) ~/ cols;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: List.generate(rowCount, (row) {
final start = row * cols;
return Padding(
padding: EdgeInsets.only(top: row == 0 ? 0 : _gap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(cols, (col) {
final index = start + col;
if (index >= labels.length) {
return SizedBox(width: cellWidth);
}
final color =
accentColors[index % accentColors.length];
return SizedBox(
width: cellWidth,
child: ConstrainedBox(
constraints:
const BoxConstraints(minHeight: _tileMinHeight),
child: ClaimsMetricCard(
label: labels[index],
value: values[index],
icon: icon,
accentColor: color,
replayToken: replayToken,
),
),
);
}),
),
);
}),
);
},
);
}
}
class ClaimsListRow extends StatelessWidget { class ClaimsListRow extends StatelessWidget {
final IconData icon; final IconData icon;
final Color iconColor; final Color iconColor;

View File

@ -147,8 +147,8 @@ class EnrollmentOverviewViewData {
} }
static String formatPremiumAmount(double n) { static String formatPremiumAmount(double n) {
if (n.abs() >= 1000000) { if (n.abs() >= 100000) {
return '${(n / 1000000).toStringAsFixed(1)}M'; return '${(n / 100000).toStringAsFixed(n.abs() >= 1000000 ? 1 : 2)}L';
} }
if (n.abs() >= 1000) { if (n.abs() >= 1000) {
return '${(n / 1000).toStringAsFixed(1)}K'; return '${(n / 1000).toStringAsFixed(1)}K';