bottleneckcrm/mobile/lib/data/models/dashboard_data.dart
2026-10-20 21:48:14 +05:30

480 lines
16 KiB
Dart

// Dashboard data models for BottleCRM
import 'package:flutter/foundation.dart';
import 'deal_pipeline.dart';
/// Urgent counts from dashboard API
class UrgentCounts {
final int overdueTasks;
final int tasksDueToday;
final int followupsToday;
final int hotLeads;
const UrgentCounts({
this.overdueTasks = 0,
this.tasksDueToday = 0,
this.followupsToday = 0,
this.hotLeads = 0,
});
factory UrgentCounts.fromJson(Map<String, dynamic> json) {
return UrgentCounts(
overdueTasks: json['overdue_tasks'] as int? ?? 0,
tasksDueToday: json['tasks_due_today'] as int? ?? 0,
followupsToday: json['followups_today'] as int? ?? 0,
hotLeads: json['hot_leads'] as int? ?? 0,
);
}
}
/// One stage of the org's default deal pipeline, with its deal count and
/// value, from `pipeline_by_stage`.
class PipelineStage {
final String code;
final String label;
/// `open`, `won` or `lost`. See `deal_pipeline.dart`.
final String kind;
/// Board order within the pipeline.
final int order;
final int count;
final double value;
const PipelineStage({
required this.code,
required this.label,
this.kind = dealStageOpen,
this.order = 0,
this.count = 0,
this.value = 0,
});
bool get isOpen => kind == dealStageOpen;
/// A server older than configurable pipelines sends neither `kind` nor
/// `order`; the seeded codes' own meaning and order stand in.
factory PipelineStage.fromJson(String code, Map<String, dynamic> json) {
return PipelineStage(
code: code,
label: json['label'] as String? ?? legacyDealStageLabel(code),
kind: json['kind'] as String? ?? legacyDealStageKind(code),
order:
(json['order'] as num?)?.toInt() ??
DealPipeline.legacy.stageByCode(code)?.order ??
DealPipeline.legacy.stages.length,
count: json['count'] as int? ?? 0,
value: (json['value'] as num?)?.toDouble() ?? 0,
);
}
}
/// Revenue metrics from dashboard API
class RevenueMetrics {
final double pipelineValue;
/// Deals making up [pipelineValue], open stages only. The dashboard's
/// top-level `opportunities_count` counts every stage including closed, so
/// it is not the number to show under an "Open Deals" label.
final int openOpportunitiesCount;
final double weightedPipeline;
final double wonThisMonth;
final double conversionRate;
final String currency;
final int otherCurrencyCount;
const RevenueMetrics({
this.pipelineValue = 0,
this.openOpportunitiesCount = 0,
this.weightedPipeline = 0,
this.wonThisMonth = 0,
this.conversionRate = 0,
this.currency = 'USD',
this.otherCurrencyCount = 0,
});
factory RevenueMetrics.fromJson(Map<String, dynamic> json) {
return RevenueMetrics(
pipelineValue: (json['pipeline_value'] as num?)?.toDouble() ?? 0,
openOpportunitiesCount:
(json['open_opportunities_count'] as num?)?.toInt() ?? 0,
weightedPipeline: (json['weighted_pipeline'] as num?)?.toDouble() ?? 0,
wonThisMonth: (json['won_this_month'] as num?)?.toDouble() ?? 0,
conversionRate: (json['conversion_rate'] as num?)?.toDouble() ?? 0,
currency: json['currency'] as String? ?? 'USD',
otherCurrencyCount: json['other_currency_count'] as int? ?? 0,
);
}
}
/// Hot lead from dashboard API
class HotLead {
final String id;
final String firstName;
final String lastName;
final String? company;
final String? rating;
final DateTime? nextFollowUp;
final DateTime? lastContacted;
const HotLead({
required this.id,
required this.firstName,
required this.lastName,
this.company,
this.rating,
this.nextFollowUp,
this.lastContacted,
});
String get fullName => '$firstName $lastName'.trim();
factory HotLead.fromJson(Map<String, dynamic> json) {
return HotLead(
id: json['id']?.toString() ?? '',
firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
company: json['company'] as String?,
rating: json['rating'] as String?,
nextFollowUp: json['next_follow_up'] != null
? DateTime.tryParse(json['next_follow_up'] as String)
: null,
lastContacted: json['last_contacted'] != null
? DateTime.tryParse(json['last_contacted'] as String)
: null,
);
}
}
/// Dashboard task from API
class DashboardTask {
final String id;
final String title;
final String? description;
final String status;
final String priority;
final DateTime? dueDate;
final String? accountName;
final String? leadName;
const DashboardTask({
required this.id,
required this.title,
this.description,
this.status = 'New',
this.priority = 'Medium',
this.dueDate,
this.accountName,
this.leadName,
});
bool get isCompleted => status.toLowerCase() == 'completed';
String? get relatedTo => accountName ?? leadName;
factory DashboardTask.fromJson(Map<String, dynamic> json) {
// Backend's TaskSerializer returns `account`/`lead` as plain FK UUIDs
// (strings), not nested {id, name} objects. Only extract a display name
// when the field is a Map. Otherwise we have a UUID with no name and
// skip the related-entity badge rather than crashing on String indexing.
String? accountName;
final account = json['account'];
if (account is Map<String, dynamic>) {
accountName = account['name'] as String?;
}
String? leadName;
final lead = json['lead'];
if (lead is Map<String, dynamic>) {
final first = (lead['first_name'] as String?) ?? '';
final last = (lead['last_name'] as String?) ?? '';
final joined = '$first $last'.trim();
if (joined.isNotEmpty) leadName = joined;
}
return DashboardTask(
id: json['id']?.toString() ?? '',
title: json['title'] as String? ?? 'Untitled',
description: json['description'] as String?,
status: json['status'] as String? ?? 'New',
priority: json['priority'] as String? ?? 'Medium',
dueDate: json['due_date'] != null
? DateTime.tryParse(json['due_date'] as String)
: null,
accountName: accountName,
leadName: leadName,
);
}
}
/// Dashboard activity from API
class DashboardActivity {
final String id;
final String activityType;
final String description;
final DateTime createdAt;
final String? userName;
final String? entityType;
final String? entityName;
const DashboardActivity({
required this.id,
required this.activityType,
required this.description,
required this.createdAt,
this.userName,
this.entityType,
this.entityName,
});
String get relativeTime {
final now = DateTime.now();
final diff = now.difference(createdAt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${(diff.inDays / 7).floor()}w ago';
}
factory DashboardActivity.fromJson(Map<String, dynamic> json) {
return DashboardActivity(
id: json['id']?.toString() ?? '',
activityType: json['activity_type'] as String? ?? 'update',
description: json['description'] as String? ?? '',
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String) ?? DateTime.now()
: DateTime.now(),
userName: json['created_by']?['user__email'] as String?,
entityType: json['entity_type'] as String?,
entityName: json['entity_name'] as String?,
);
}
}
/// Complete dashboard data response
class DashboardData {
final int accountsCount;
final int contactsCount;
final int leadsCount;
final int opportunitiesCount;
final UrgentCounts urgentCounts;
final List<PipelineStage> pipelineByStage;
final RevenueMetrics revenueMetrics;
final List<HotLead> hotLeads;
final List<DashboardTask> tasks;
final List<DashboardActivity> activities;
/// The current goals the signed-in person may see, at most three. Served by
/// `/api/dashboard/` as `goal_summary` and narrowed there to their own goals,
/// their teams', and (for an admin) the org-wide ones.
final List<DashboardGoal> goalSummary;
/// The caller can see no real contact and no real lead (sample data does
/// not count). From `import_prompt_facts` on `/api/dashboard/`.
final bool emptyWorkspace;
/// The import views would let the caller in (`can_mass_import`).
final bool canImport;
const DashboardData({
this.accountsCount = 0,
this.contactsCount = 0,
this.leadsCount = 0,
this.opportunitiesCount = 0,
this.urgentCounts = const UrgentCounts(),
this.pipelineByStage = const [],
this.revenueMetrics = const RevenueMetrics(),
this.hotLeads = const [],
this.tasks = const [],
this.activities = const [],
this.goalSummary = const [],
this.emptyWorkspace = false,
this.canImport = false,
});
/// Whether the home screen offers an import, as the web home page's "Bring
/// in your contacts" card does. Absent keys, from an older server, offer
/// nothing.
bool get offersImport => emptyWorkspace && canImport;
factory DashboardData.fromJson(Map<String, dynamic> json) {
debugPrint('DashboardData.fromJson: Starting to parse...');
debugPrint('DashboardData.fromJson: Keys: ${json.keys.toList()}');
// Parse pipeline by stage
debugPrint('DashboardData.fromJson: Parsing pipeline_by_stage...');
final pipelineMap =
json['pipeline_by_stage'] as Map<String, dynamic>? ?? {};
final pipelineStages = pipelineMap.entries
.map(
(e) => PipelineStage.fromJson(e.key, e.value as Map<String, dynamic>),
)
.toList();
// The map carries no order of its own, so the board's is restored.
pipelineStages.sort((a, b) => a.order.compareTo(b.order));
debugPrint(
'DashboardData.fromJson: Pipeline stages parsed: ${pipelineStages.length}',
);
// Parse urgent counts
debugPrint('DashboardData.fromJson: Parsing urgent_counts...');
final urgentCounts = json['urgent_counts'] != null
? UrgentCounts.fromJson(json['urgent_counts'] as Map<String, dynamic>)
: const UrgentCounts();
debugPrint('DashboardData.fromJson: Urgent counts parsed');
// Parse revenue metrics
debugPrint('DashboardData.fromJson: Parsing revenue_metrics...');
final revenueMetrics = json['revenue_metrics'] != null
? RevenueMetrics.fromJson(
json['revenue_metrics'] as Map<String, dynamic>,
)
: const RevenueMetrics();
debugPrint('DashboardData.fromJson: Revenue metrics parsed');
// Parse hot leads
debugPrint('DashboardData.fromJson: Parsing hot_leads...');
final hotLeadsList = <HotLead>[];
if (json['hot_leads'] != null) {
for (final item in json['hot_leads'] as List<dynamic>) {
try {
hotLeadsList.add(HotLead.fromJson(item as Map<String, dynamic>));
} catch (e) {
debugPrint('DashboardData.fromJson: Error parsing hot lead: $e');
debugPrint('DashboardData.fromJson: Hot lead data: $item');
}
}
}
debugPrint(
'DashboardData.fromJson: Hot leads parsed: ${hotLeadsList.length}',
);
// Parse tasks
debugPrint('DashboardData.fromJson: Parsing tasks...');
final tasksList = <DashboardTask>[];
if (json['tasks'] != null) {
for (final item in json['tasks'] as List<dynamic>) {
try {
tasksList.add(DashboardTask.fromJson(item as Map<String, dynamic>));
} catch (e) {
debugPrint('DashboardData.fromJson: Error parsing task: $e');
debugPrint('DashboardData.fromJson: Task data: $item');
}
}
}
debugPrint('DashboardData.fromJson: Tasks parsed: ${tasksList.length}');
// Parse activities
debugPrint('DashboardData.fromJson: Parsing activities...');
final activitiesList = <DashboardActivity>[];
if (json['activities'] != null) {
for (final item in json['activities'] as List<dynamic>) {
try {
activitiesList.add(
DashboardActivity.fromJson(item as Map<String, dynamic>),
);
} catch (e) {
debugPrint('DashboardData.fromJson: Error parsing activity: $e');
debugPrint('DashboardData.fromJson: Activity data: $item');
}
}
}
debugPrint(
'DashboardData.fromJson: Activities parsed: ${activitiesList.length}',
);
// Parse goal summary
final goalSummaryList = <DashboardGoal>[];
if (json['goal_summary'] != null) {
for (final item in json['goal_summary'] as List<dynamic>) {
try {
goalSummaryList.add(
DashboardGoal.fromJson(item as Map<String, dynamic>),
);
} catch (e) {
debugPrint('DashboardData.fromJson: Error parsing goal: $e');
}
}
}
debugPrint('DashboardData.fromJson: All parsing complete!');
return DashboardData(
accountsCount: json['accounts_count'] as int? ?? 0,
contactsCount: json['contacts_count'] as int? ?? 0,
leadsCount: json['leads_count'] as int? ?? 0,
opportunitiesCount: json['opportunities_count'] as int? ?? 0,
urgentCounts: urgentCounts,
pipelineByStage: pipelineStages,
revenueMetrics: revenueMetrics,
hotLeads: hotLeadsList,
tasks: tasksList,
activities: activitiesList,
goalSummary: goalSummaryList,
emptyWorkspace: json['empty_workspace'] == true,
canImport: json['can_import'] == true,
);
}
}
/// One current goal on the dashboard, from `/api/dashboard/` `goal_summary`.
///
/// A slimmer shape than [SalesGoal]: the endpoint sends only what a progress
/// strip needs, and parsing it as a full goal would invent nulls for the seven
/// fields it does not carry.
///
/// The backend has served this field all along and neither client rendered it,
/// so the numbers behind this were already being computed and thrown away.
class DashboardGoal {
const DashboardGoal({
required this.id,
required this.name,
required this.goalType,
required this.targetValue,
required this.progressValue,
required this.progressPercent,
required this.status,
this.currency,
});
final String id;
final String name;
final String goalType;
/// The currency a REVENUE goal is counted in. Null from a server that
/// predates the field, where the strip falls back to the org's currency.
final String? currency;
final double targetValue;
final double progressValue;
final int progressPercent;
/// The server's pace judgement (`on_track` / `at_risk` / `behind` /
/// `completed`), which compares attainment against elapsed time. Read, never
/// recomputed: this app cannot see the opportunities another person's goal
/// counts.
final String status;
factory DashboardGoal.fromJson(Map<String, dynamic> json) {
final target = json['target_value'];
final progress = json['progress_value'];
return DashboardGoal(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
goalType: json['goal_type']?.toString() ?? 'REVENUE',
targetValue: target is num
? target.toDouble()
: double.tryParse(target?.toString() ?? '') ?? 0,
progressValue: progress is num
? progress.toDouble()
: double.tryParse(progress?.toString() ?? '') ?? 0,
progressPercent: (json['progress_percent'] as num?)?.toInt() ?? 0,
status: json['status']?.toString() ?? 'on_track',
currency: (json['currency']?.toString() ?? '').isEmpty
? null
: json['currency'].toString(),
);
}
}