Email template

This commit is contained in:
Surendiran 2026-07-02 11:06:41 +05:30
parent 2120e997f0
commit af571b914a
15 changed files with 2185 additions and 285 deletions

View File

@ -50,8 +50,6 @@ class Environment {
if (isProd) base = "Nhance HR"; // You can differentiate names if needed
switch (routeName) {
case 'hrDashboard':
return "$base - Insights";
case 'claimsOverviewDashboard':
return "$base - Dashboard";
case 'policies':

View File

@ -52,7 +52,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
prefs.setBool('showBackToHR', false);
await prefs.remove('hrSelectArgumentsData');
prefs.remove('token');
Navigator.pushNamed(context, 'hrDashboard');
Navigator.pushNamed(context, 'policies');
} else {
await prefs.clear();
Navigator.pushNamed(context, 'phone');

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import '../service/token_storage_service.dart';
@ -29,6 +31,18 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
setState(() {});
}
Future<String> _getBranchSwitchRoute() async {
final postRaw = await tokenStorage.readValue('empAllowed_modules');
final postModules = postRaw != null && postRaw.isNotEmpty
? List<int>.from(jsonDecode(postRaw))
: <int>[];
if (postModules.isNotEmpty && postModules.contains(5)) {
return 'claimsOverviewDashboard';
}
return 'policies';
}
Future<void> _onBranchSelected(Map<String, dynamic> branch) async {
final tokenStorage = TokenStorageService();
@ -36,9 +50,11 @@ class _NhanceTopBarState extends State<NhanceTopBar> {
if (!mounted) return;
// 🔄 Refresh CURRENT PAGE only
final route = ModalRoute.of(context)?.settings.name ?? 'hrDashboard';
_loadBranches();
final route = await _getBranchSwitchRoute();
if (!mounted) return;
Navigator.pushReplacementNamed(context, route);
}

View File

@ -366,7 +366,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
if (_postToken != null && _postToken.isNotEmpty) {
ToastHelper.showSuccessToast(context, 'Successfully Logged In');
// if (emp_status == 'enrolled' || emp_status == 'active') {
Navigator.pushReplacementNamed(context, 'hrDashboard');
Navigator.pushReplacementNamed(context, 'policies');
// } else {
// Navigator.pushReplacementNamed(context, 'empDetails');
// }
@ -410,7 +410,7 @@ class _MyEmailVerifyState extends State<MyEmailVerify> {
// if (emp_status == 'enrolled' || emp_status == 'active') {
// Navigator.pushReplacementNamed(context, 'home');
// } else {
Navigator.pushReplacementNamed(context, 'hrDashboard');
Navigator.pushReplacementNamed(context, 'policies');
// }
}
}

View File

@ -16,7 +16,6 @@ import 'package:nhancepolicy/presentation/policies.dart';
import 'package:nhancepolicy/service/session/session_service.dart';
import 'package:nhancepolicy/service/token_storage_service.dart';
import 'package:nhancepolicy/home.dart';
import 'package:nhancepolicy/presentation/hrDashboard.dart';
import 'package:nhancepolicy/presentation/claims_overview/claims_overview_dashboard.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:nhancepolicy/hrLogin.dart';
@ -267,7 +266,7 @@ final Map<String, WidgetBuilder> appRoutes = {
'empDetails': (context) => empDetails(),
'addOnsDetails': (context) => addOnsDetails(),
'empReviewDetails': (context) => empReviewDetails(),
'hrDashboard': (context) => hrDashboard(),
'hrDashboard': (context) => policies(),
'claimsOverviewDashboard': (context) => const ClaimsOverviewDashboard(),
'hrPolicyDetails': (context) => hrPolicyDetails(
ClientId: '',

View File

@ -58,7 +58,7 @@ class _ClaimsOverviewDashboardState extends State<ClaimsOverviewDashboard>
(Icons.people_outline, 'Demographics'),
(Icons.location_city_outlined, 'Hospitals & Geography'),
(Icons.medical_services_outlined, 'Specialty Analysis'),
(Icons.group_add_outlined, 'Insurred'),
(Icons.group_add_outlined, 'Insured'),
];
@override

View File

@ -1026,7 +1026,7 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
ClaimsOverviewTheme.pink,
];
final donutHeight = widget.forPdfExport ? 190.0 : 300.0;
final donutHeight = widget.forPdfExport ? 220.0 : 300.0;
final trendHeight = widget.forPdfExport ? 228.0 : 320.0;
final sectionGap = widget.forPdfExport ? 8.0 : 16.0;
@ -1235,19 +1235,16 @@ class _EnrollmentTabState extends State<_EnrollmentTab>
animationKey: _chartKey,
);
if (forPdfExport) {
return chart;
}
return Column(
children: [
Expanded(child: chart),
const SizedBox(height: 8),
SizedBox(height: forPdfExport ? 4 : 8),
_EnrollmentDonutLegend(
labels: series.labels,
values: series.values,
colors: colors,
totalLabel: totalLabel,
compact: forPdfExport,
),
],
);
@ -1259,20 +1256,23 @@ class _EnrollmentDonutLegend extends StatelessWidget {
final List<double> values;
final List<Color> colors;
final String totalLabel;
final bool compact;
const _EnrollmentDonutLegend({
required this.labels,
required this.values,
required this.colors,
required this.totalLabel,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final total = values.fold<double>(0, (a, b) => a + b);
final fontSize = compact ? 9.0 : 11.0;
return Wrap(
spacing: 12,
runSpacing: 8,
spacing: compact ? 8 : 12,
runSpacing: compact ? 4 : 8,
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
@ -1282,18 +1282,18 @@ class _EnrollmentDonutLegend extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
width: compact ? 8 : 10,
height: compact ? 8 : 10,
decoration: BoxDecoration(
color: colors[i % colors.length],
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
SizedBox(width: compact ? 4 : 6),
Text(
'${labels[i]} ${pct.toStringAsFixed(1)}%',
style: GoogleFonts.poppins(
fontSize: 11,
fontSize: fontSize,
color: ClaimsOverviewTheme.textSecondary,
),
),
@ -1303,7 +1303,7 @@ class _EnrollmentDonutLegend extends StatelessWidget {
Text(
'Total: $totalLabel',
style: GoogleFonts.poppins(
fontSize: 11,
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: ClaimsOverviewTheme.textPrimary,
),

View File

@ -0,0 +1,347 @@
import 'dart:ui_web' as ui;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
import 'package:universal_html/html.dart' as html;
const _caretSelector = '[data-nhance-caret="1"]';
class EmailHtmlPreviewController {
_EmailHtmlPreviewState? _state;
void _bind(_EmailHtmlPreviewState state) => _state = state;
void _unbind(_EmailHtmlPreviewState state) {
if (_state == state) _state = null;
}
bool get isReady => _state?.isReady ?? false;
bool get hasCaretMarker => _state?.hasCaretMarker ?? false;
String get currentHtml => _state?.currentHtml ?? '';
void removeCaretMarker() => _state?.removeCaretMarker();
bool insertSnippetAtCursor(String snippet) =>
_state?.insertSnippetAtCursor(snippet) ?? false;
}
class EmailHtmlPreview extends StatefulWidget {
final String html;
final double? height;
final bool editable;
final EmailHtmlPreviewController? controller;
const EmailHtmlPreview({
super.key,
required this.html,
this.height,
this.editable = false,
this.controller,
});
@override
State<EmailHtmlPreview> createState() => _EmailHtmlPreviewState();
}
class _EmailHtmlPreviewState extends State<EmailHtmlPreview> {
late final String _viewType;
html.IFrameElement? _iframe;
late String _activeHtml;
bool _iframeReady = false;
bool _selectionListenersAttached = false;
bool get isReady => _iframeReady;
bool get hasCaretMarker {
final doc = _iframeDocument;
if (doc == null) return false;
return doc.querySelector(_caretSelector) != null;
}
html.Document? get _iframeDocument {
final iframe = _iframe;
if (iframe == null) return null;
try {
return (iframe as dynamic).contentDocument as html.Document?;
} catch (_) {
return null;
}
}
html.Element? get _iframeBody {
final doc = _iframeDocument;
if (doc == null) return null;
return (doc as dynamic).body as html.Element?;
}
dynamic get _iframeWindow {
final iframe = _iframe;
if (iframe == null) return null;
return (iframe as dynamic).contentWindow;
}
String get currentHtml {
removeCaretMarker();
try {
final root = _iframeDocument?.documentElement;
if (root != null) {
return '<!DOCTYPE html>\n${root.outerHtml}';
}
} catch (_) {}
return _stripCaretMarkers(_activeHtml);
}
@override
void initState() {
super.initState();
_activeHtml = _stripCaretMarkers(widget.html);
widget.controller?._bind(this);
_viewType =
'email-preview-${identityHashCode(this)}-${DateTime.now().microsecondsSinceEpoch}';
if (kIsWeb) {
_registerView();
}
}
@override
void dispose() {
widget.controller?._unbind(this);
super.dispose();
}
String _srcForIframe(String htmlContent) {
final clean = _stripCaretMarkers(htmlContent);
if (!widget.editable) return clean;
return _injectEditableBody(clean);
}
String _injectEditableBody(String htmlContent) {
final bodyTag = RegExp(r'<body([^>]*)>', caseSensitive: false);
if (!bodyTag.hasMatch(htmlContent)) return htmlContent;
return htmlContent.replaceFirstMapped(bodyTag, (match) {
final attrs = match.group(1) ?? '';
if (RegExp('contenteditable', caseSensitive: false).hasMatch(attrs)) {
return match.group(0)!;
}
return '<body$attrs contenteditable="true">';
});
}
String _stripCaretMarkers(String htmlContent) {
return htmlContent.replaceAll(
RegExp(
r'<span[^>]*data-nhance-caret="1"[^>]*>.*?</span>',
caseSensitive: false,
dotAll: true,
),
'',
);
}
void _registerView() {
ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.display = 'block'
..style.pointerEvents = 'auto'
..srcdoc = _srcForIframe(_activeHtml);
if (!widget.editable) {
iframe.setAttribute('sandbox', 'allow-same-origin');
}
iframe.onLoad.listen((_) {
_iframeReady = true;
_selectionListenersAttached = false;
_setupEditableIframe();
});
_iframe = iframe;
return iframe;
});
}
void _setupEditableIframe() {
if (!widget.editable || _selectionListenersAttached) return;
final body = _iframeBody;
if (body == null) return;
body.setAttribute('contenteditable', 'true');
body.style.setProperty('outline', 'none');
body.style.setProperty('cursor', 'text');
// Place an invisible DOM marker at the click position so inserts survive
// focus moving to the sidebar.
body.onMouseUp.listen((_) => _placeCaretMarker());
body.onKeyUp.listen((_) => _placeCaretMarker());
body.onClick.listen((_) => _placeCaretMarker());
_selectionListenersAttached = true;
}
html.Element _createCaretMarker(html.Document doc) {
final marker = doc.createElement('span');
marker.setAttribute('data-nhance-caret', '1');
marker.text = '\u200B';
marker.style.setProperty('font-size', '0');
marker.style.setProperty('line-height', '0');
marker.style.setProperty('display', 'inline');
return marker;
}
void _placeCaretMarker() {
if (!widget.editable || !_iframeReady) return;
final doc = _iframeDocument;
final body = _iframeBody;
if (doc == null || body == null) return;
removeCaretMarker();
final range = _readSelectionRange(body);
if (range == null) return;
try {
final marker = _createCaretMarker(doc);
range.insertNode(marker);
(range as dynamic).setStartAfter(marker);
(range as dynamic).collapse(true);
final win = _iframeWindow;
final sel = win?.getSelection() as html.Selection?;
sel?.removeAllRanges();
sel?.addRange(range);
} catch (_) {}
}
dynamic _readSelectionRange(html.Element body) {
try {
final win = _iframeWindow;
final sel = win?.getSelection() as html.Selection?;
if (sel == null || (sel.rangeCount ?? 0) == 0) return null;
final range = sel.getRangeAt(0);
if (!_nodeInside(body, range.startContainer)) return null;
return (range as dynamic).cloneRange();
} catch (_) {
return null;
}
}
void removeCaretMarker() {
final doc = _iframeDocument;
if (doc == null) return;
final markers = doc.querySelectorAll(_caretSelector).toList();
for (final marker in markers) {
marker.remove();
}
}
bool insertSnippetAtCursor(String snippet) {
if (!widget.editable || !_iframeReady || snippet.trim().isEmpty) {
return false;
}
final doc = _iframeDocument;
if (doc == null) return false;
final marker = doc.querySelector(_caretSelector);
if (marker == null || marker.parent == null) return false;
try {
final parent = marker.parent!;
final tempRange = doc.createRange();
final fragment =
(tempRange as dynamic).createContextualFragment(snippet);
if (fragment == null) return false;
parent.insertBefore(fragment, marker);
marker.remove();
_activeHtml = currentHtml;
return true;
} catch (_) {
return false;
}
}
bool _nodeInside(html.Node ancestor, html.Node node) {
var current = node;
while (true) {
if (current == ancestor) return true;
final parent = current.parentNode;
if (parent == null) return false;
current = parent;
}
}
@override
Widget build(BuildContext context) {
final preview = kIsWeb
? ClipRect(
clipBehavior: Clip.hardEdge,
child: SizedBox.expand(
child: HtmlElementView(viewType: _viewType),
),
)
: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
widget.editable
? 'Editable email preview is available on web.'
: 'Email preview is available on web.',
style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54),
textAlign: TextAlign.center,
),
),
);
final decorated = Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
clipBehavior: Clip.hardEdge,
child: preview,
);
if (widget.height != null) {
return SizedBox(height: widget.height, child: decorated);
}
return decorated;
}
}
String appendHtmlSnippet(String html, String snippet) {
if (html.trim().isEmpty) {
return EmailTemplateData.wrapPlainTextAsHtml(snippet);
}
final lower = html.toLowerCase();
const footerMarkers = [
'<div class="footer"',
"<div class='footer'",
'<div class="footer "',
];
for (final marker in footerMarkers) {
final idx = lower.indexOf(marker);
if (idx != -1) {
return '${html.substring(0, idx)}$snippet${html.substring(idx)}';
}
}
final bodyEnd = lower.lastIndexOf('</body>');
if (bodyEnd == -1) {
return '$html$snippet';
}
return '${html.substring(0, bodyEnd)}$snippet${html.substring(bodyEnd)}';
}

View File

@ -0,0 +1,444 @@
class EmailTemplateData {
final String templateName;
final String subject;
final String htmlBody;
final int? templateId;
final List<EmailTemplatePlaceholder> placeholders;
const EmailTemplateData({
this.templateName = 'Enrollment Reminder Mail',
required this.subject,
required this.htmlBody,
this.templateId,
this.placeholders = const [],
});
static EmailTemplateData fromApiResponse(
Map<String, dynamic> response, {
required String defaultSubject,
required String defaultHtmlBody,
String defaultTemplateName = 'Enrollment Reminder Mail',
}) {
final data = response['data'];
Map<String, dynamic> source;
Map<String, dynamic>? parentData;
if (data is Map) {
parentData = Map<String, dynamic>.from(data);
final nested = parentData['email_template'] ?? parentData['template'];
source = nested is Map
? Map<String, dynamic>.from(nested)
: parentData;
} else {
source = Map<String, dynamic>.from(response);
}
final templateName = _readString(source, const [
'template_name',
'name',
'mail_template_name',
]);
final subject = _readString(source, const [
'email_subject',
'subject',
'mail_subject',
]);
final htmlBody = _readString(source, const [
'email_body',
'email_html',
'html_body',
'body',
'template_html',
'mail_body',
]);
final idRaw = source['id'] ?? source['template_id'];
final templateId = idRaw == null ? null : int.tryParse(idRaw.toString());
final placeholders = parsePlaceholders(
source['place_holders'] ??
source['placeholders'] ??
parentData?['place_holders'] ??
parentData?['placeholders'],
);
return EmailTemplateData(
templateName:
templateName.isNotEmpty ? templateName : defaultTemplateName,
subject: subject.isNotEmpty ? subject : defaultSubject,
htmlBody: htmlBody.isNotEmpty ? htmlBody : defaultHtmlBody,
templateId: templateId,
placeholders:
placeholders.isNotEmpty ? placeholders : defaultPlaceholders(),
);
}
static List<EmailTemplatePlaceholder> defaultPlaceholders() {
return parsePlaceholders(kDefaultEnrollmentReminderPlaceholderKeys);
}
static List<EmailTemplatePlaceholder> parsePlaceholders(dynamic raw) {
if (raw is! List || raw.isEmpty) {
return const [];
}
final placeholders = <EmailTemplatePlaceholder>[];
for (final item in raw) {
final key = item?.toString().trim();
if (key == null || key.isEmpty) continue;
placeholders.add(
EmailTemplatePlaceholder(
formatPlaceholderLabel(key),
'{{$key}}',
),
);
}
return placeholders;
}
static String formatPlaceholderLabel(String key) {
return key
.split('_')
.where((part) => part.isNotEmpty)
.map(
(part) =>
'${part[0].toUpperCase()}${part.substring(1).toLowerCase()}',
)
.join(' ');
}
static String _readString(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = map[key];
if (value != null && value.toString().trim().isNotEmpty) {
return value.toString();
}
}
return '';
}
static String wrapPlainTextAsHtml(String text) {
final escaped = text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('\n', '<br/>');
return '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body style="font-family: Arial, Helvetica, sans-serif; line-height: 1.6; color: #333333; background-color: #ffffff; max-width: 500px; margin: 0 auto; padding: 24px; text-align: left;">
<p>$escaped</p>
</body>
</html>''';
}
}
class EmailTemplatePlaceholder {
final String label;
final String token;
const EmailTemplatePlaceholder(this.label, this.token);
}
const kDefaultEnrollmentReminderPlaceholderKeys = [
'member_name',
'nhance_logo',
'client_logo',
'policy_name',
'policy_no',
'emp_code',
'app_link',
'client_name',
'enrollment_open_date',
'enrollment_close_date',
];
const kDefaultEnrollmentReminderHtmlBody = r'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enrollment Reminder</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f6f8;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6f8;padding:24px 0;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background-color:#ffffff;border-radius:10px;overflow:hidden;">
<tr>
<td align="center" style="background-color:#009195;padding:24px;">
<img src="{{client_logo}}" alt="Company Logo" width="160" style="display:block;margin:0 auto 12px;max-height:48px;width:auto;border:0;" />
<h1 style="margin:0;font-size:22px;line-height:28px;color:#ffffff;font-weight:600;">
Enrollment Reminder
</h1>
</td>
</tr>
<tr>
<td style="padding:32px 28px;color:#1f2937;font-size:15px;line-height:24px;">
<p style="margin:0 0 16px;">
Dear <strong>{{member_name}}</strong>,
</p>
<p style="margin:0 0 20px;">
This is a reminder to complete your insurance enrollment for the policy below. Please finish enrollment before the deadline to avoid missing your benefits.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;background-color:#f9fafb;margin-bottom:24px;">
<tr>
<td style="padding:16px 18px;font-size:14px;line-height:22px;color:#374151;">
<strong style="color:#111827;">Policy Name:</strong> {{policy_name}}<br>
<strong style="color:#111827;">Policy Number:</strong> {{policy_no}}<br>
<strong style="color:#111827;">Employee Code:</strong> {{emp_code}}<br>
<strong style="color:#111827;">Enrollment Open Date:</strong> {{enrollment_open_date}}<br>
<strong style="color:#111827;">Enrollment Close Date:</strong> {{enrollment_close_date}}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto 24px;">
<tr>
<td align="center" style="border-radius:6px;background-color:#009195;">
<a href="{{app_link}}" target="_blank" style="display:inline-block;padding:14px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:6px;">
Complete Enrollment
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;">
If you have already completed your enrollment, please ignore this email.
</p>
<p style="margin:0;">
Regards,<br>
<strong>{{client_name}}</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:18px 28px;background-color:#f9fafb;font-size:12px;line-height:18px;color:#6b7280;">
This is an automated reminder email. Please do not reply directly.<br>
<img src="{{nhance_logo}}" alt="Nhance" width="80" style="display:block;margin:12px auto 0;max-height:24px;width:auto;border:0;" />
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>''';
const kEmailContentBlocks = <({String label, String snippet})>[
(
label: 'Columns',
snippet:
'<table width="100%" cellpadding="0" cellspacing="0"><tr><td width="50%" style="padding:8px;vertical-align:top;">Column 1</td><td width="50%" style="padding:8px;vertical-align:top;">Column 2</td></tr></table>',
),
(
label: 'Button',
snippet:
'<p style="text-align:center;margin:16px 0;"><a href="{{app_link}}" style="background:#009195;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px;display:inline-block;font-weight:600;">Click Here</a></p>',
),
(
label: 'Divider',
snippet:
'<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;" />',
),
(
label: 'Heading',
snippet:
'<h2 style="color:#009195;font-size:20px;margin:16px 0 8px;font-weight:600;">Heading</h2>',
),
(
label: 'Paragraph',
snippet:
'<p style="margin:8px 0;line-height:1.6;font-size:14px;">Paragraph text</p>',
),
(
label: 'Image',
snippet:
'<p style="text-align:center;margin:12px 0;"><img src="{{client_logo}}" alt="Logo" style="max-width:200px;height:auto;" /></p>',
),
(
label: 'Social',
snippet:
'<p style="text-align:center;margin:12px 0;"><a href="#" style="margin:0 8px;color:#009195;">Facebook</a><a href="#" style="margin:0 8px;color:#009195;">LinkedIn</a></p>',
),
(
label: 'Menu',
snippet:
'<p style="text-align:center;margin:12px 0;font-size:13px;"><a href="#" style="margin:0 8px;color:#009195;">Home</a>|<a href="#" style="margin:0 8px;color:#009195;">Support</a></p>',
),
(
label: 'HTML',
snippet: '<!-- custom html -->',
),
];
/// Column layout blocks (Unlayer Blocks > Blank tab).
class EmailColumnLayout {
final List<int> ratios;
final String snippet;
const EmailColumnLayout({
required this.ratios,
required this.snippet,
});
}
const kEmailColumnLayouts = <EmailColumnLayout>[
EmailColumnLayout(
ratios: [100],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td style="padding:8px;vertical-align:top;border:1px dashed #ccc;min-height:40px;">&nbsp;</td></tr></table>',
),
EmailColumnLayout(
ratios: [50, 50],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="50%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="50%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td></tr></table>',
),
EmailColumnLayout(
ratios: [33, 33, 33],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="33%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="33%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="34%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td></tr></table>',
),
EmailColumnLayout(
ratios: [25, 75],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="75%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td></tr></table>',
),
EmailColumnLayout(
ratios: [75, 25],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="75%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td></tr></table>',
),
EmailColumnLayout(
ratios: [25, 25, 25, 25],
snippet:
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:8px 0;"><tr><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td><td width="25%" style="padding:8px;vertical-align:top;border:1px dashed #ccc;">&nbsp;</td></tr></table>',
),
];
class EmailBodyStyle {
final String backgroundColor;
final String contentWidth;
final String fontFamily;
final String textColor;
final String linkColor;
final String contentAlignment;
final String fontWeight;
const EmailBodyStyle({
this.backgroundColor = '#ffffff',
this.contentWidth = '500px',
this.fontFamily = 'Arial, Helvetica, sans-serif',
this.textColor = '#000000',
this.linkColor = '#009195',
this.contentAlignment = 'left',
this.fontWeight = '400',
});
int get contentWidthPx {
final n = int.tryParse(contentWidth.replaceAll('px', '').trim());
return n ?? 500;
}
EmailBodyStyle copyWith({
String? backgroundColor,
String? contentWidth,
String? fontFamily,
String? textColor,
String? linkColor,
String? contentAlignment,
String? fontWeight,
}) {
return EmailBodyStyle(
backgroundColor: backgroundColor ?? this.backgroundColor,
contentWidth: contentWidth ?? this.contentWidth,
fontFamily: fontFamily ?? this.fontFamily,
textColor: textColor ?? this.textColor,
linkColor: linkColor ?? this.linkColor,
contentAlignment: contentAlignment ?? this.contentAlignment,
fontWeight: fontWeight ?? this.fontWeight,
);
}
static EmailBodyStyle parseFromHtml(String html) {
final bgMatch = RegExp(
r'background(?:-color)?:\s*([^;"]+)',
caseSensitive: false,
).firstMatch(html);
final widthMatch = RegExp(
r'max-width:\s*(\d+)px',
caseSensitive: false,
).firstMatch(html);
final fontMatch = RegExp(
r'font-family:\s*([^;"]+)',
caseSensitive: false,
).firstMatch(html);
final colorMatch = RegExp(
r'(?<!background-)color:\s*([^;"]+)',
caseSensitive: false,
).firstMatch(html);
final alignMatch = RegExp(
r'text-align:\s*([^;"]+)',
caseSensitive: false,
).firstMatch(html);
final weightMatch = RegExp(
r'font-weight:\s*([^;"]+)',
caseSensitive: false,
).firstMatch(html);
return EmailBodyStyle(
backgroundColor: bgMatch?.group(1)?.trim() ?? '#ffffff',
contentWidth: '${widthMatch?.group(1)?.trim() ?? '500'}px',
fontFamily: fontMatch?.group(1)?.trim() ?? 'Arial, Helvetica, sans-serif',
textColor: colorMatch?.group(1)?.trim() ?? '#000000',
contentAlignment: alignMatch?.group(1)?.trim() ?? 'left',
fontWeight: weightMatch?.group(1)?.trim() ?? '400',
);
}
}
String applyEmailBodyStyles(String html, EmailBodyStyle style) {
final bodyStyle =
'font-family: ${style.fontFamily}; line-height: 1.6; color: ${style.textColor}; '
'font-weight: ${style.fontWeight}; background-color: ${style.backgroundColor}; '
'max-width: ${style.contentWidth}; margin: 0 auto; padding: 24px; '
'text-align: ${style.contentAlignment};';
final lower = html.toLowerCase();
final bodyStart = lower.indexOf('<body');
if (bodyStart == -1) {
return EmailTemplateData.wrapPlainTextAsHtml(html);
}
final openEnd = html.indexOf('>', bodyStart);
if (openEnd == -1) return html;
final bodyTag = html.substring(bodyStart, openEnd + 1);
final hasStyle = bodyTag.toLowerCase().contains('style=');
String newBodyTag;
if (hasStyle) {
newBodyTag = bodyTag.replaceAll(
RegExp(r'style="[^"]*"', caseSensitive: false),
'style="$bodyStyle"',
);
} else {
newBodyTag = bodyTag.replaceFirst('>', ' style="$bodyStyle">');
}
var result = html.replaceRange(bodyStart, openEnd + 1, newBodyTag);
final linkStyle = '<style>a{color:${style.linkColor};}</style>';
if (!result.toLowerCase().contains('<style>')) {
final headEnd = result.toLowerCase().indexOf('</head>');
if (headEnd != -1) {
result =
'${result.substring(0, headEnd)}$linkStyle${result.substring(headEnd)}';
}
}
return result;
}
enum ReminderEmailTemplateMode { config, send }

View File

@ -0,0 +1,235 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui_web' as ui;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:universal_html/html.dart' as html;
class GrapesEmailEditorController {
_GrapesEmailEditorState? _state;
void _bind(_GrapesEmailEditorState state) => _state = state;
void _unbind(_GrapesEmailEditorState state) {
if (_state == state) _state = null;
}
bool get isReady => _state?.isReady ?? false;
String get currentHtml => _state?.currentHtml ?? '';
Future<String> exportHtml() => _state?.exportHtml() ?? Future.value('');
void loadHtml(String html) => _state?.loadHtml(html);
void insertToken(String token) => _state?.insertToken(token);
}
class GrapesEmailEditor extends StatefulWidget {
final String html;
final GrapesEmailEditorController? controller;
final ValueChanged<String>? onHtmlChanged;
const GrapesEmailEditor({
super.key,
required this.html,
this.controller,
this.onHtmlChanged,
});
@override
State<GrapesEmailEditor> createState() => _GrapesEmailEditorState();
}
class _GrapesEmailEditorState extends State<GrapesEmailEditor> {
static int _instanceCounter = 0;
late final String _viewType;
html.IFrameElement? _iframe;
StreamSubscription<html.MessageEvent>? _messageSub;
bool _isReady = false;
bool _isDisposed = false;
String _currentHtml = '';
int _requestCounter = 0;
final Map<int, Completer<String>> _pendingExports = {};
bool get isReady => _isReady;
String get currentHtml => _currentHtml;
@override
void initState() {
super.initState();
_currentHtml = widget.html;
widget.controller?._bind(this);
_instanceCounter += 1;
_viewType = 'grapes-email-editor-$_instanceCounter';
if (kIsWeb) {
_registerView();
_messageSub = html.window.onMessage.listen(_onMessage);
}
}
@override
void dispose() {
_isDisposed = true;
_messageSub?.cancel();
_messageSub = null;
_pendingExports.clear();
try {
_postToEditor({'action': 'destroy'});
_iframe?.src = 'about:blank';
} catch (_) {}
widget.controller?._unbind(this);
super.dispose();
}
void _registerView() {
ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final iframe = html.IFrameElement()
..src = 'email-editor.html'
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.display = 'block';
_iframe = iframe;
return iframe;
});
}
void _postToEditor(Map<String, dynamic> payload) {
if (_isDisposed) return;
final win = _iframe?.contentWindow;
if (win == null) return;
try {
final message = jsonEncode({
'source': 'nhance-email-editor-host',
...payload,
});
(win as dynamic).postMessage(message, '*');
} catch (_) {}
}
Map<String, dynamic>? _readPayload(Object? data) {
if (data == null) return null;
if (data is String) {
try {
final decoded = jsonDecode(data);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {
return null;
}
}
if (data is Map) {
return Map<String, dynamic>.from(data);
}
try {
final dynamic raw = data;
final source = raw['source']?.toString();
if (source == null) return null;
return {
'source': source,
'action': raw['action']?.toString(),
'html': raw['html']?.toString(),
'requestId': raw['requestId'],
'message': raw['message']?.toString(),
};
} catch (_) {
return null;
}
}
void _onMessage(html.MessageEvent event) {
if (_isDisposed || !mounted) return;
Map<String, dynamic>? data;
try {
data = _readPayload(event.data);
} catch (_) {
return;
}
if (data == null || data['source'] != 'nhance-email-editor') return;
try {
switch (data['action']) {
case 'ready':
if (_isDisposed || !mounted) return;
_isReady = true;
loadHtml(_currentHtml);
break;
case 'change':
if (_isDisposed || !mounted) return;
final htmlContent = data['html']?.toString() ?? '';
if (htmlContent.isEmpty || htmlContent == _currentHtml) return;
_currentHtml = htmlContent;
widget.onHtmlChanged?.call(htmlContent);
break;
case 'html':
if (_isDisposed) return;
final requestId = int.tryParse(data['requestId']?.toString() ?? '');
if (requestId != null) {
_pendingExports.remove(requestId)?.complete(
data['html']?.toString() ?? '',
);
}
break;
}
} catch (_) {}
}
void loadHtml(String html) {
if (_isDisposed) return;
_currentHtml = html;
_postToEditor({'action': 'load', 'html': html});
}
Future<String> exportHtml() async {
if (_isDisposed) return _currentHtml;
if (!_isReady) return _currentHtml;
final requestId = ++_requestCounter;
final completer = Completer<String>();
_pendingExports[requestId] = completer;
_postToEditor({'action': 'export', 'requestId': requestId});
try {
final result = await completer.future.timeout(const Duration(seconds: 5));
if (!_isDisposed) _currentHtml = result;
return result;
} catch (_) {
return _currentHtml;
} finally {
_pendingExports.remove(requestId);
}
}
void insertToken(String token) {
_postToEditor({'action': 'insertToken', 'token': token});
}
@override
Widget build(BuildContext context) {
if (!kIsWeb) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Drag-and-drop email editor is available on web.',
style: GoogleFonts.poppins(fontSize: 13, color: Colors.black54),
textAlign: TextAlign.center,
),
),
);
}
return ClipRect(
clipBehavior: Clip.hardEdge,
child: SizedBox.expand(
child: HtmlElementView(viewType: _viewType),
),
);
}
}

View File

@ -0,0 +1,630 @@
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:nhancepolicy/customAppBar/toastHelper.dart';
import 'package:nhancepolicy/logger.dart';
import 'package:nhancepolicy/presentation/email_template/email_html_preview.dart';
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
import 'package:nhancepolicy/presentation/email_template/grapes_email_editor.dart';
import 'package:nhancepolicy/service/api_service.dart';
import 'package:nhancepolicy/service/token_storage_service.dart';
class ReminderEmailTemplateDialog extends StatefulWidget {
final String clientId;
final String clientPolicyId;
final String clientBranchId;
final String token;
final ApiService apiService;
final String defaultSubject;
final String defaultHtmlBody;
final ReminderEmailTemplateMode mode;
final Future<void> Function(String subject, String htmlBody)? onSend;
const ReminderEmailTemplateDialog({
super.key,
required this.clientId,
required this.clientPolicyId,
required this.clientBranchId,
required this.token,
required this.apiService,
required this.defaultSubject,
required this.defaultHtmlBody,
this.mode = ReminderEmailTemplateMode.config,
this.onSend,
});
@override
State<ReminderEmailTemplateDialog> createState() =>
_ReminderEmailTemplateDialogState();
}
class _ReminderEmailTemplateDialogState extends State<ReminderEmailTemplateDialog> {
static const _teal = Color(0xFF009195);
final _templateNameController = TextEditingController();
final _subjectController = TextEditingController();
final _htmlEditController = TextEditingController();
final _grapesController = GrapesEmailEditorController();
bool _isLoading = true;
bool _isSubmitting = false;
bool _isHtmlEditMode = false;
int _editorGeneration = 0;
int? _templateId;
String _templateName = 'Enrollment Reminder Mail';
String _htmlBody = '';
List<EmailTemplatePlaceholder> _placeholders = const [];
@override
void initState() {
super.initState();
_loadTemplate();
}
@override
void dispose() {
_templateNameController.dispose();
_subjectController.dispose();
_htmlEditController.dispose();
super.dispose();
}
void _syncHtmlEditor() {
_htmlEditController.text = _htmlBody;
}
Future<void> _captureHtml() async {
if (_isHtmlEditMode) {
_htmlBody = _htmlEditController.text;
return;
}
if (_grapesController.isReady) {
_htmlBody = await _grapesController.exportHtml();
} else {
final latest = _grapesController.currentHtml;
if (latest.trim().isNotEmpty) {
_htmlBody = latest;
}
}
}
Future<void> _toggleHtmlEditMode() async {
if (_isHtmlEditMode) {
_htmlBody = _htmlEditController.text;
setState(() {
_isHtmlEditMode = false;
_editorGeneration++;
});
return;
}
await _captureHtml();
_syncHtmlEditor();
setState(() => _isHtmlEditMode = true);
}
void _insertPlaceholder(String token) {
if (_isHtmlEditMode) {
_insertInHtmlTextField('<span>$token</span>');
return;
}
_grapesController.insertToken(token);
}
void _insertInHtmlTextField(String snippet) {
final controller = _htmlEditController;
final text = controller.text;
final selection = controller.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final updated = text.replaceRange(start, end, snippet);
controller.value = TextEditingValue(
text: updated,
selection: TextSelection.collapsed(offset: start + snippet.length),
);
setState(() => _htmlBody = updated);
}
Future<void> _loadTemplate() async {
setState(() => _isLoading = true);
try {
final response = await widget.apiService.getReminderMailConfigApi(
widget.clientPolicyId,
widget.token,
isEmailTemplate: true,
);
if (!mounted) return;
final ok = response['status'] == true || response['status'] == 'success';
final template = ok
? EmailTemplateData.fromApiResponse(
response,
defaultSubject: widget.defaultSubject,
defaultHtmlBody: widget.defaultHtmlBody,
)
: EmailTemplateData(
subject: widget.defaultSubject,
htmlBody: widget.defaultHtmlBody,
);
_templateId = template.templateId;
_templateName = template.templateName;
_templateNameController.text = template.templateName;
_subjectController.text = template.subject;
_htmlBody = template.htmlBody;
_placeholders = template.placeholders;
_syncHtmlEditor();
} catch (e) {
logDebug('Template load failed: $e');
_templateNameController.text = 'Enrollment Reminder Mail';
_subjectController.text = widget.defaultSubject;
_htmlBody = widget.defaultHtmlBody;
_placeholders = EmailTemplateData.defaultPlaceholders();
_syncHtmlEditor();
if (mounted) {
ToastHelper.showWarningToast(
context,
'Using default template. Submit to save changes.',
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
_editorGeneration++;
});
}
}
}
Future<int?> _resolveHrId() async {
final tokenService = TokenStorageService();
final hrId = await tokenService.readValue('enrollmentHrId') ??
await tokenService.readValue('empHrId');
return int.tryParse(hrId?.toString() ?? '');
}
Map<String, dynamic> _buildSavePayload() {
final policyId = int.tryParse(widget.clientPolicyId);
final payload = <String, dynamic>{
'client_id': widget.clientId,
'client_policy_id': policyId ?? widget.clientPolicyId,
'client_branch_id': widget.clientBranchId,
'email_subject': _subjectController.text.trim(),
'email_body': _htmlBody.trim(),
'template_name': _templateNameController.text.trim().isNotEmpty
? _templateNameController.text.trim()
: _templateName,
};
if (_templateId != null) {
payload['id'] = _templateId;
}
return payload;
}
Future<void> _submit() async {
await _captureHtml();
final subject = _subjectController.text.trim();
final htmlBody = _htmlBody.trim();
if (subject.isEmpty || htmlBody.isEmpty) {
if (mounted) {
ToastHelper.showWarningToast(
context,
'Subject and email body are required',
);
}
return;
}
if (widget.mode == ReminderEmailTemplateMode.send &&
widget.onSend != null) {
setState(() => _isSubmitting = true);
try {
await widget.onSend!(subject, htmlBody);
if (mounted) Navigator.pop(context);
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
return;
}
setState(() => _isSubmitting = true);
try {
final payload = _buildSavePayload();
final hrId = await _resolveHrId();
if (hrId != null) {
payload['hr_id'] = hrId;
}
final response = await widget.apiService.saveEnrollmentReminderEmailTemplateApi(
widget.token,
payload,
);
if (!mounted) return;
final ok = response['status'] == true || response['status'] == 'success';
if (ok) {
final data = response['data'];
if (data is Map) {
final idRaw = data['id'] ?? data['template_id'];
_templateId = int.tryParse(idRaw?.toString() ?? '');
}
ToastHelper.showSuccessToast(
context,
response['message']?.toString() ?? 'Template saved successfully',
);
} else {
ToastHelper.showErrorToast(
context,
response['message']?.toString() ?? 'Failed to save template',
);
}
} catch (e) {
logDebug('Template save failed: $e');
if (mounted) {
ToastHelper.showErrorToast(
context,
'Failed to save template. Please try again.',
);
}
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
}
}
Widget _tealButton({
required String label,
required VoidCallback? onPressed,
bool loading = false,
double? width,
}) {
return SizedBox(
width: width,
height: 36,
child: ElevatedButton(
onPressed: loading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: _teal,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
child: loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
label,
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
Future<void> _showPreviewMail() async {
await _captureHtml();
final previewHtml = _htmlBody;
if (previewHtml.trim().isEmpty) {
if (mounted) {
ToastHelper.showWarningToast(context, 'Email body is empty');
}
return;
}
if (!mounted) return;
showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.all(24),
child: SizedBox(
width: MediaQuery.sizeOf(ctx).width * 0.7,
height: MediaQuery.sizeOf(ctx).height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Text(
'Preview Mail',
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close),
),
],
),
),
const Divider(height: 1),
Expanded(child: EmailHtmlPreview(html: previewHtml)),
],
),
),
),
);
}
Widget _webPointerGuard({required Widget child}) {
if (kIsWeb) {
return PointerInterceptor(child: child);
}
return child;
}
Widget _placeholdersMenu() {
final menuChild = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
border: Border.all(
color: _placeholders.isEmpty
? const Color(0xFFE0E0E0)
: const Color(0xFFCCCCCC),
),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'PlaceHolders',
style: GoogleFonts.poppins(
fontSize: 12,
color: _placeholders.isEmpty ? Colors.black38 : Colors.black87,
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: _placeholders.isEmpty ? Colors.black26 : Colors.black87,
),
],
),
);
if (_placeholders.isEmpty) {
return _webPointerGuard(child: menuChild);
}
return _webPointerGuard(
child: PopupMenuButton<EmailTemplatePlaceholder>(
position: PopupMenuPosition.over,
itemBuilder: (context) => _placeholders
.map(
(p) => _InterceptorPopupMenuItem<EmailTemplatePlaceholder>(
value: p,
child: Text(
'${p.label} (${p.token})',
style: GoogleFonts.poppins(fontSize: 12),
),
),
)
.toList(),
onSelected: (p) => _insertPlaceholder(p.token),
child: menuChild,
),
);
}
Widget _buildEditorArea() {
if (_isHtmlEditMode) {
return Container(
margin: const EdgeInsets.fromLTRB(20, 8, 20, 0),
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
padding: const EdgeInsets.all(8),
child: TextField(
controller: _htmlEditController,
maxLines: null,
expands: true,
style: GoogleFonts.robotoMono(fontSize: 12, color: Colors.white),
decoration: const InputDecoration(
border: InputBorder.none,
hintText: 'Edit HTML source...',
hintStyle: TextStyle(color: Colors.white38),
),
onChanged: (v) => _htmlBody = v,
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: GrapesEmailEditor(
key: ValueKey('grapes-editor-$_editorGeneration'),
html: _htmlBody,
controller: _grapesController,
onHtmlChanged: (html) => _htmlBody = html,
),
);
}
@override
Widget build(BuildContext context) {
final primaryLabel =
widget.mode == ReminderEmailTemplateMode.send ? 'Send' : 'Submit';
final size = MediaQuery.sizeOf(context);
return Dialog(
insetPadding: const EdgeInsets.all(16),
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
child: SizedBox(
width: size.width * 0.95,
height: size.height * 0.92,
child: _isLoading
? const Center(child: CircularProgressIndicator(color: _teal))
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 8),
child: Row(
children: [
Text(
'Template Name',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _templateNameController,
style: GoogleFonts.poppins(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0xFFF5F5F5),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
),
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, size: 20),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Subject',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _subjectController,
style: GoogleFonts.poppins(fontSize: 14),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: const Color(0xFFF5F5F5),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 8),
_placeholdersMenu(),
const SizedBox(width: 8),
_webPointerGuard(
child: _tealButton(
label: 'Preview Mail',
onPressed: () => _showPreviewMail(),
width: 120,
),
),
],
),
),
Expanded(child: _buildEditorArea()),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 16),
child: Row(
children: [
_webPointerGuard(
child: TextButton.icon(
onPressed: () => _toggleHtmlEditMode(),
icon: Icon(
Icons.code,
size: 16,
color: _isHtmlEditMode ? _teal : Colors.black54,
),
label: Text(
_isHtmlEditMode ? 'Visual Editor' : 'Dev HTML',
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isHtmlEditMode ? _teal : Colors.black54,
),
),
),
),
const Spacer(),
_webPointerGuard(
child: _tealButton(
label: primaryLabel,
onPressed: _submit,
loading: _isSubmitting,
width: 120,
),
),
],
),
),
],
),
),
);
}
}
/// Popup menu items over [HtmlElementView] (GrapesJS iframe) need a
/// [PointerInterceptor] on web or the iframe steals pointer events.
class _InterceptorPopupMenuItem<T> extends PopupMenuItem<T> {
const _InterceptorPopupMenuItem({
required super.child,
super.value,
});
@override
PopupMenuItemState<T, _InterceptorPopupMenuItem<T>> createState() =>
_InterceptorPopupMenuItemState<T>();
}
class _InterceptorPopupMenuItemState<T>
extends PopupMenuItemState<T, _InterceptorPopupMenuItem<T>> {
@override
Widget build(BuildContext context) {
final item = super.build(context);
if (kIsWeb) {
return PointerInterceptor(child: item);
}
return item;
}
}

View File

@ -27,7 +27,8 @@ import 'package:url_launcher/url_launcher.dart';
import '../customAppBar/base_layout.dart';
import '../customAppBar/customFooter.dart';
import '../service/secure_pop_scope.dart';
import 'hrDashboard.dart';
import 'package:nhancepolicy/presentation/email_template/email_template_models.dart';
import 'package:nhancepolicy/presentation/email_template/reminder_email_template_dialog.dart';
import 'package:nhancepolicy/logger.dart';
class hrPolicyDetails extends StatefulWidget {
@ -513,10 +514,34 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
}
bool _isEnrolledStatus(String status) =>
status == 'under process' || status == 'enrolled';
status == 'under process' ||
status == 'enrolled' ||
status == 'submitted';
String _getLoggedInValue(Map<String, dynamic> row) {
final loggedInRaw = row['logged_in'] ?? row['emp_is_active'];
final value = loggedInRaw?.toString().toLowerCase().trim() ?? '';
// API can return: null / Yes / No / 1 / 0 / true / false.
if (value.isEmpty || value == 'null' || value == 'no' || value == '0' || value == 'false') {
return 'no';
}
return (value == 'yes' || value == '1' || value == 'true') ? 'yes' : 'no';
}
bool _isLoggedInRow(Map<String, dynamic> row) =>
row['emp_is_active']?.toString() == '1';
_getLoggedInValue(row) == 'yes';
bool _isSelfRow(Map<String, dynamic> row) =>
row['relationship']?.toString().toLowerCase().trim() == 'self';
String _getLoggedInDisplayLabel(Map<String, dynamic> row) =>
_getLoggedInValue(row) == 'yes' ? 'Yes' : 'No';
Color _getLoggedInChipColor(Map<String, dynamic> row) =>
_getLoggedInValue(row) == 'yes'
? const Color(0xFFBDF9D9)
: const Color(0xFFE8EAF6);
bool _matchesPreStatusFilter(Map<String, dynamic> row, String filter) {
final status = row['status']?.toString().toLowerCase().trim() ?? '';
@ -530,9 +555,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
case 'not_enrolled':
return !_isEnrolledStatus(status);
case 'logged_in':
return _isLoggedInRow(row);
return _isSelfRow(row) && _isLoggedInRow(row);
case 'not_logged_in':
return !_isLoggedInRow(row);
return _isSelfRow(row) && !_isLoggedInRow(row);
case 'draft':
return status == 'draft';
default:
@ -958,7 +983,9 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
}
Future<void> _openReminderConfigDialog() async {
final clientId = localClientId ?? widget.ClientId;
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
final token = localToken ?? widget.Token;
await showDialog<void>(
@ -966,9 +993,13 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
barrierDismissible: false,
builder: (dialogContext) {
return _ReminderMailConfigDialog(
clientId: clientId,
clientPolicyId: clientPolicyId,
clientBranchId: clientBranchId,
token: token,
apiService: apiService,
defaultSubject: _defaultReminderSubject(),
defaultHtmlBody: _defaultReminderHtmlBody(),
);
},
);
@ -979,193 +1010,36 @@ class _HrPolicyDetailsState extends State<hrPolicyDetails>
return 'Reminder: Complete Your Enrollment - $policyName';
}
String _defaultReminderBody() {
final policyName = localCardPolicyName ?? widget.cardPolicy_name;
final policyNo = localCardPolicyNo ?? widget.cardPolicyNo;
return '''Dear Employee,
This is a gentle reminder to complete your insurance enrollment for $policyName ($policyNo).
Please log in to the Nhance portal and complete your enrollment at the earliest.
If you have already completed enrollment, please ignore this email.
Regards,
HR Team''';
}
Future<({String subject, String body})> _loadReminderEmailTemplate() async {
var subject = _defaultReminderSubject();
var body = _defaultReminderBody();
try {
final response = await apiService.getEnrollmentReminderEmailTemplateApi(
localClientId ?? widget.ClientId,
localClientPolicyId ?? widget.ClientPoliyId,
localClientBranchId ?? widget.clientBranchId,
localToken ?? widget.Token,
);
if (response['status'] == 'success' || response['status'] == true) {
final data = response['data'];
if (data is Map<String, dynamic>) {
subject = data['email_subject']?.toString().trim().isNotEmpty == true
? data['email_subject'].toString()
: data['subject']?.toString().trim().isNotEmpty == true
? data['subject'].toString()
: subject;
body = data['email_body']?.toString().trim().isNotEmpty == true
? data['email_body'].toString()
: data['body']?.toString().trim().isNotEmpty == true
? data['body'].toString()
: body;
}
}
} catch (e) {
logDebug('Reminder template fetch failed, using default: $e');
}
return (subject: subject, body: body);
String _defaultReminderHtmlBody() {
return kDefaultEnrollmentReminderHtmlBody;
}
Future<void> _openReminderTemplateDialog() async {
final template = await _loadReminderEmailTemplate();
if (!mounted) return;
final clientId = localClientId ?? widget.ClientId;
final clientPolicyId = localClientPolicyId ?? widget.ClientPoliyId;
final clientBranchId = localClientBranchId ?? widget.clientBranchId;
final token = localToken ?? widget.Token;
final subjectController = TextEditingController(text: template.subject);
final bodyController = TextEditingController(text: template.body);
final result = await showDialog<Map<String, String>?>(
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
return ReminderEmailTemplateDialog(
clientId: clientId,
clientPolicyId: clientPolicyId,
clientBranchId: clientBranchId,
token: token,
apiService: apiService,
defaultSubject: _defaultReminderSubject(),
defaultHtmlBody: _defaultReminderHtmlBody(),
mode: ReminderEmailTemplateMode.send,
onSend: (subject, htmlBody) => _sendEnrollmentReminder(
emailSubject: subject,
emailBody: htmlBody,
),
title: Text(
'Send Reminder Email',
style: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
content: SizedBox(
width: 620,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Review and edit the email template before sending to employees who have not completed enrollment.',
style: GoogleFonts.poppins(
fontSize: 13,
color: Colors.black54,
),
),
const SizedBox(height: 16),
Text(
'Subject',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: subjectController,
style: GoogleFonts.poppins(fontSize: 14),
decoration: InputDecoration(
hintText: 'Email subject',
filled: true,
fillColor: const Color(0xFFF5F5F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
const SizedBox(height: 14),
Text(
'Email Body',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
TextField(
controller: bodyController,
minLines: 10,
maxLines: 14,
style: GoogleFonts.poppins(fontSize: 14, height: 1.45),
decoration: InputDecoration(
hintText: 'Email body',
filled: true,
fillColor: const Color(0xFFF5F5F5),
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(12),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(
'Cancel',
style: GoogleFonts.poppins(color: Colors.black54),
),
),
ElevatedButton(
onPressed: () {
final subject = subjectController.text.trim();
final body = bodyController.text.trim();
if (subject.isEmpty || body.isEmpty) {
ToastHelper.showWarningToast(
dialogContext,
'Subject and email body are required',
);
return;
}
Navigator.pop(dialogContext, {
'subject': subject,
'body': body,
});
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFE26728),
),
child: Text(
'Send',
style: GoogleFonts.poppins(color: Colors.white),
),
),
],
);
},
);
subjectController.dispose();
bodyController.dispose();
if (result != null) {
await _sendEnrollmentReminder(
emailSubject: result['subject']!,
emailBody: result['body']!,
);
}
}
Future<void> _sendEnrollmentReminder({
@ -1176,7 +1050,7 @@ HR Team''';
try {
final hrId = await tokenService.readValue('enrollmentHrId');
final response = await apiService.sendEnrollmentReminderApi(
final response = await apiService.sendReminderMailApi(
localClientId ?? widget.ClientId,
localClientPolicyId ?? widget.ClientPoliyId,
localClientBranchId ?? widget.clientBranchId,
@ -1383,28 +1257,6 @@ HR Team''';
/// Push right content to end
const Spacer(),
/// 🔍 Search Box
Container(
width: 380,
height: 37,
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(8),
),
child: TextField(
controller: searchController,
onChanged: search,
style: GoogleFonts.poppins(fontSize: 14),
decoration: const InputDecoration(
hintText: 'Search',
prefixIcon: Icon(Icons.search, size: 18),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
),
),
if (localIsEcardBulkDownload == 1) ...[
const SizedBox(width: 12),
SizedBox(
@ -1687,7 +1539,7 @@ HR Team''';
// ---------------- PREMIUM / STATUS ----------------
if (localTokenType == "pre")
Padding(
padding: const EdgeInsets.only(left: 15),
padding: const EdgeInsets.only(left: 15, right: 16),
child: _buildStatusSummary(),
),
@ -1695,6 +1547,7 @@ HR Team''';
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(
@ -1712,6 +1565,8 @@ HR Team''';
),
),
),
const Spacer(),
_buildCompactSearchField(),
],
),
),
@ -2069,10 +1924,12 @@ HR Team''';
} else {
notEnrolled++;
}
if (_isLoggedInRow(item)) {
loggedIn++;
} else {
notLoggedIn++;
if (_isSelfRow(item)) {
if (_isLoggedInRow(item)) {
loggedIn++;
} else {
notLoggedIn++;
}
}
if (status == 'draft') draft++;
}
@ -2106,6 +1963,37 @@ HR Team''';
}
}
Widget _buildCompactSearchField({double width = 180}) {
return SizedBox(
width: width,
height: 32,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(6),
),
child: TextField(
controller: searchController,
onChanged: search,
textAlign: TextAlign.left,
textAlignVertical: TextAlignVertical.center,
style: GoogleFonts.poppins(fontSize: 12),
decoration: InputDecoration(
hintText: 'Search',
hintStyle: GoogleFonts.poppins(fontSize: 12, color: Colors.black45),
prefixIcon: const Icon(Icons.search, size: 16),
prefixIconConstraints:
const BoxConstraints(minWidth: 28, minHeight: 32),
isDense: true,
border: InputBorder.none,
contentPadding:
const EdgeInsets.only(left: 0, right: 10, top: 0, bottom: 0),
),
),
),
);
}
Widget _buildPreStatusFilterChip({
required String filterKey,
required String label,
@ -2158,51 +2046,63 @@ HR Team''';
return SizedBox(
height: 38,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filters.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
if (index == filters.length) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: _preStatusFilter == null ? null : _resetPreStatusFilter,
borderRadius: BorderRadius.circular(6),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: _preStatusFilter != null
? const Color(0xFF009195)
: const Color(0xFFD0D0D0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: filters.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
if (index == filters.length) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap:
_preStatusFilter == null ? null : _resetPreStatusFilter,
borderRadius: BorderRadius.circular(6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFFF0F0F0),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: _preStatusFilter != null
? const Color(0xFF009195)
: const Color(0xFFD0D0D0),
),
),
child: Text(
'Reset',
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _preStatusFilter != null
? const Color(0xFF009195)
: Colors.black54,
),
),
),
),
),
child: Text(
'Reset',
style: GoogleFonts.poppins(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _preStatusFilter != null
? const Color(0xFF009195)
: Colors.black54,
),
),
),
),
);
}
);
}
final filter = filters[index];
return _buildPreStatusFilterChip(
filterKey: filter['key']!,
label: filter['label']!,
count: statusCounts[filter['key']] ?? 0,
);
},
final filter = filters[index];
return _buildPreStatusFilterChip(
filterKey: filter['key']!,
label: filter['label']!,
count: statusCounts[filter['key']] ?? 0,
);
},
),
),
const SizedBox(width: 12),
_buildCompactSearchField(),
],
),
);
}
@ -2221,6 +2121,7 @@ HR Team''';
pinned: true,
delegate: _CDHeaderDelegate(
showUHID: localPolicyTypeId != '6' && localPolicyTypeId != '7',
showLoggedIn: localTokenType == "pre",
showAction:
localTokenType != "pre" && (hasAnyEcardLink || hasModule),
),
@ -2302,6 +2203,30 @@ HR Team''';
child: Text(item['email_corporate'] ?? '-', style: _dataBold),
),
if (localTokenType == "pre")
Expanded(
flex: 2,
child: _isSelfRow(item)
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getLoggedInChipColor(item),
borderRadius: BorderRadius.circular(10),
),
child: Text(
_getLoggedInDisplayLabel(item),
textAlign: TextAlign.center,
style: _dataBold,
),
)
: Text('-', style: _dataBold),
),
if (localTokenType == "pre") const SizedBox(width: 8),
/// STATUS
Expanded(
flex: 2,
@ -3236,10 +3161,12 @@ HR Team''';
class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
final bool showUHID;
final bool showLoggedIn;
final bool showAction;
_CDHeaderDelegate({
required this.showUHID,
required this.showLoggedIn,
required this.showAction,
});
@ -3265,6 +3192,8 @@ class _CDHeaderDelegate extends SliverPersistentHeaderDelegate {
_headerCell('Gender', 2),
_headerCell('Mobile', 2),
_headerCell('Email', 5),
if (showLoggedIn) _headerCell('Logged In', 2),
if (showLoggedIn) const SizedBox(width: 8),
_headerCell('Status', 2),
if (showAction) _headerCell('Action', 3, center: true),
],
@ -3368,14 +3297,22 @@ class _PolicyTermsDialog extends StatelessWidget {
}
class _ReminderMailConfigDialog extends StatefulWidget {
final String clientId;
final String clientPolicyId;
final String clientBranchId;
final String token;
final ApiService apiService;
final String defaultSubject;
final String defaultHtmlBody;
const _ReminderMailConfigDialog({
required this.clientId,
required this.clientPolicyId,
required this.clientBranchId,
required this.token,
required this.apiService,
required this.defaultSubject,
required this.defaultHtmlBody,
});
@override
@ -3736,6 +3673,25 @@ class _ReminderMailConfigDialogState extends State<_ReminderMailConfigDialog> {
);
}
Future<void> _openTemplateEditor() async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return ReminderEmailTemplateDialog(
clientId: widget.clientId,
clientPolicyId: widget.clientPolicyId,
clientBranchId: widget.clientBranchId,
token: widget.token,
apiService: widget.apiService,
defaultSubject: widget.defaultSubject,
defaultHtmlBody: widget.defaultHtmlBody,
mode: ReminderEmailTemplateMode.config,
);
},
);
}
@override
Widget build(BuildContext context) {
final isBusy = _isSaving;
@ -3830,6 +3786,26 @@ class _ReminderMailConfigDialogState extends State<_ReminderMailConfigDialog> {
),
const SizedBox(height: 14),
_buildDaysField(),
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: isBusy ? null : _openTemplateEditor,
icon: const Icon(Icons.edit_outlined, size: 18),
label: Text(
'Edit Email Template',
style: GoogleFonts.poppins(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF009195),
side: const BorderSide(color: Color(0xFF009195)),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
],
),
),

View File

@ -1114,6 +1114,50 @@ class ApiService {
return _makeGetRequest(url, headers);
}
Future<Map<String, dynamic>> saveEnrollmentReminderEmailTemplateApi(
String token,
Map<String, dynamic> payload,
) async {
final url =
Uri.parse('${Environment.apiUrl}saveEnrollmentReminderEmailTemplate');
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'APP-SIGNATURE':
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
};
return _makePostRequest(url, jsonEncode(payload), headers);
}
Future<Map<String, dynamic>> sendTestEnrollmentReminderEmailApi(
String clientId,
String clientPolicyId,
String clientBranchId,
String hrId,
String token, {
required String emailSubject,
required String emailBody,
}) async {
final url =
Uri.parse('${Environment.apiUrl}testEnrollmentReminderEmail');
final headers = {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'APP-SIGNATURE':
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
};
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
final body = {
'client_id': clientId,
'client_policy_id': policyId,
'client_branch_id': clientBranchId,
'hr_id': hrId,
'email_subject': emailSubject,
'email_body': emailBody,
};
return _makePostRequest(url, jsonEncode(body), headers);
}
Future<Map<String, dynamic>> sendEnrollmentReminderApi(
String clientId,
String clientPolicyId,
@ -1157,12 +1201,17 @@ class ApiService {
Future<Map<String, dynamic>> getReminderMailConfigApi(
String clientPolicyId,
String token,
) async {
final url = Uri.parse(
String token, {
bool isEmailTemplate = false,
}) async {
final query = StringBuffer(
'${Environment.apiUrl}getReminderMailConfig'
'?client_policy_id=$clientPolicyId',
);
if (isEmailTemplate) {
query.write('&is_email_template=true');
}
final url = Uri.parse(query.toString());
final headers = {
'Authorization': 'Bearer $token',
};
@ -1182,20 +1231,31 @@ class ApiService {
}
Future<Map<String, dynamic>> sendReminderMailApi(
String clientId,
String clientPolicyId,
String token,
) async {
String clientBranchId,
String hrId,
String token, {
required String emailSubject,
required String emailBody,
}) async {
final url = Uri.parse('${Environment.apiUrl}sendReminderMail');
final headers = {
'Authorization': 'Bearer $token',
'APP-SIGNATURE':
'nhance-2025-signature-T8f6gV9k5fK2wf5V5c4vId3b0J8z6cAm2x8Y',
'Content-Type': 'application/json',
};
final policyId = int.tryParse(clientPolicyId) ?? clientPolicyId;
return _makePostRequest(
url,
jsonEncode({'client_policy_id': policyId}),
headers,
);
final body = {
'client_id': clientId,
'client_policy_id': policyId,
'client_branch_id': clientBranchId,
'hr_id': hrId,
'email_subject': emailSubject,
'email_body': emailBody,
};
return _makePostRequest(url, jsonEncode(body), headers);
}
Future<Map<String, dynamic>> getClientRMApi(

View File

@ -64,6 +64,7 @@ dependencies:
dropdown_button2: ^2.3.9
fl_chart: ^1.2.0
image: ^4.3.0
pointer_interceptor: ^0.10.1+2
dev_dependencies:

194
web/email-editor.html Normal file
View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Email Editor</title>
<link rel="stylesheet" href="https://unpkg.com/grapesjs/dist/css/grapes.min.css" />
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: #f5f5f5;
font-family: Arial, Helvetica, sans-serif;
}
#gjs { height: 100%; border: none; }
.gjs-one-bg { background-color: #f8f9fa; }
.gjs-two-color { color: #333; }
.gjs-three-bg { background-color: #009195; color: #fff; }
.gjs-four-color,
.gjs-four-color-h:hover { color: #009195; }
.gjs-pn-btn.gjs-pn-active,
.gjs-pn-btn:hover { color: #009195; }
.gjs-block { min-height: 54px; }
</style>
</head>
<body>
<div id="gjs"></div>
<script src="https://unpkg.com/grapesjs"></script>
<script src="https://unpkg.com/grapesjs-preset-newsletter"></script>
<script>
let editor = null;
let pendingHtml = null;
let changeTimer = null;
let lastExportedHtml = '';
let destroyed = false;
function postToParent(payload) {
if (destroyed) return;
if (window.parent && window.parent !== window) {
window.parent.postMessage(JSON.stringify(payload), '*');
}
}
function extractBodyContent(html) {
if (!html) return '';
const match = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1].trim() : html;
}
function wrapAsEmailDocument(innerHtml) {
return '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n</head>\n<body style="margin:0;padding:0;">' + innerHtml + '\n</body>\n</html>';
}
function exportFullHtml() {
if (!editor) return '';
try {
const inlined = editor.runCommand('gjs-get-inlined-html');
return wrapAsEmailDocument(inlined || '');
} catch (e) {
const html = editor.getHtml() || '';
const css = editor.getCss() || '';
return wrapAsEmailDocument('<style>' + css + '</style>' + html);
}
}
function scheduleChange() {
if (destroyed) return;
clearTimeout(changeTimer);
changeTimer = setTimeout(function () {
if (destroyed || !editor) return;
const html = exportFullHtml();
if (!html || html === lastExportedHtml) return;
lastExportedHtml = html;
postToParent({ source: 'nhance-email-editor', action: 'change', html: html });
}, 800);
}
function loadHtml(fullHtml) {
if (destroyed || !editor) {
pendingHtml = fullHtml;
return;
}
const content = extractBodyContent(fullHtml);
editor.setComponents(content);
editor.refresh();
lastExportedHtml = exportFullHtml();
}
function insertToken(token) {
if (destroyed || !editor) return;
const selected = editor.getSelected();
const cmp = selected && selected.get('type') !== 'wrapper'
? selected
: editor.getWrapper().components().at(0);
if (cmp && cmp.append) {
cmp.append('<span>' + token + '</span>');
} else {
editor.addComponents('<span>' + token + '</span>');
}
scheduleChange();
}
function destroyEditor() {
destroyed = true;
clearTimeout(changeTimer);
changeTimer = null;
try {
if (editor) editor.destroy();
} catch (e) {}
editor = null;
}
function initEditor() {
try {
editor = grapesjs.init({
container: '#gjs',
height: '100%',
width: 'auto',
fromElement: false,
storageManager: false,
noticeOnUnload: false,
canvas: {
styles: ['https://fonts.googleapis.com/css?family=Roboto:400,500,700'],
},
deviceManager: {
devices: [
{ name: 'Desktop', width: '' },
{ name: 'Mobile', width: '320px', widthMedia: '480px' },
],
},
plugins: ['grapesjs-preset-newsletter'],
pluginsOpts: {
'grapesjs-preset-newsletter': {
modalLabelImport: 'Paste your HTML',
modalLabelExport: 'Copy HTML',
codeViewerTheme: 'material',
},
},
});
editor.on('update', scheduleChange);
if (pendingHtml) {
loadHtml(pendingHtml);
pendingHtml = null;
}
postToParent({ source: 'nhance-email-editor', action: 'ready' });
} catch (e) {
postToParent({ source: 'nhance-email-editor', action: 'error', message: String(e) });
}
}
window.addEventListener('message', function (event) {
let data = event.data || {};
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (_) {
return;
}
}
if (data.source !== 'nhance-email-editor-host') return;
switch (data.action) {
case 'load':
loadHtml(data.html || '');
break;
case 'export':
postToParent({
source: 'nhance-email-editor',
action: 'html',
requestId: data.requestId,
html: exportFullHtml(),
});
break;
case 'insertToken':
insertToken(data.token || '');
break;
case 'destroy':
destroyEditor();
break;
default:
break;
}
});
initEditor();
</script>
</body>
</html>