175 lines
6 KiB
Dart
175 lines
6 KiB
Dart
/// Time-tracking entry on a ticket. Ended-null = currently running.
|
|
///
|
|
/// Mirrors `cases.serializer.TimeEntrySerializer`, which two endpoints emit in
|
|
/// two shapes. On a ticket's own list `case` and `invoice` are bare ids; on
|
|
/// `/time-entries/timesheet/` the view expands both to objects, because that
|
|
/// page shows a week across many tickets and an id is not a name. `fromJson`
|
|
/// reads either, so `caseId` is an id in both and never the string
|
|
/// `"{id: ..., name: ...}"`, which is what `.toString()` on the object form
|
|
/// used to produce.
|
|
class TimeEntry {
|
|
final String id;
|
|
final String caseId;
|
|
|
|
/// Present only where the server expands the ticket (the timesheet).
|
|
final String? caseName;
|
|
final String profileId;
|
|
final String? profileName;
|
|
final DateTime startedAt;
|
|
final DateTime? endedAt;
|
|
final int? durationMinutes;
|
|
final bool billable;
|
|
final String? description;
|
|
|
|
/// Set once the entry has been billed. A billed entry is shown as billed and
|
|
/// never offered for editing: double-billing an hour is a refund.
|
|
final String? invoiceId;
|
|
final String? invoiceNumber;
|
|
|
|
/// True once billed, even while Invoices is off and `invoice` is null.
|
|
final bool invoiced;
|
|
|
|
/// The rate as it stood when this entry was logged, and the currency it was
|
|
/// logged in. Both are snapshotted per entry precisely so a rate change next
|
|
/// month does not rewrite what last month was worth. Sent as a decimal
|
|
/// string, so parsed rather than cast.
|
|
final double? hourlyRate;
|
|
final String? currency;
|
|
|
|
/// The server's own count of minutes elapsed on a running timer, taken at
|
|
/// response time. The phone's clock is a rendering detail, not the truth
|
|
/// about how long somebody has been working, so the screen counts up from
|
|
/// this rather than from `DateTime.now()` against `startedAt`.
|
|
final int? liveDurationMinutes;
|
|
|
|
const TimeEntry({
|
|
required this.id,
|
|
required this.caseId,
|
|
this.caseName,
|
|
required this.profileId,
|
|
this.profileName,
|
|
required this.startedAt,
|
|
this.endedAt,
|
|
this.durationMinutes,
|
|
this.billable = false,
|
|
this.description,
|
|
this.invoiceId,
|
|
this.invoiceNumber,
|
|
this.invoiced = false,
|
|
this.hourlyRate,
|
|
this.currency,
|
|
this.liveDurationMinutes,
|
|
});
|
|
|
|
bool get isRunning => endedAt == null;
|
|
|
|
bool get isBilled => invoiced || invoiceId != null;
|
|
|
|
/// Live duration, including currently-running timers.
|
|
Duration get liveDuration {
|
|
final end = endedAt ?? DateTime.now();
|
|
return end.difference(startedAt);
|
|
}
|
|
|
|
/// Minutes this entry contributes to a total, `elapsed` minutes after the
|
|
/// response was received. A stopped entry ignores `elapsed`: its duration is
|
|
/// settled and must not drift upwards while the screen is open.
|
|
int minutesAt(int elapsed) {
|
|
if (!isRunning) return durationMinutes ?? 0;
|
|
final base = liveDurationMinutes ?? liveDuration.inMinutes;
|
|
return base + elapsed;
|
|
}
|
|
|
|
factory TimeEntry.fromJson(Map<String, dynamic> json) {
|
|
String? profileName;
|
|
final profile = json['profile'];
|
|
if (profile is Map<String, dynamic>) {
|
|
final user = profile['user_details'] ?? profile['user'];
|
|
if (user is Map<String, dynamic>) {
|
|
profileName = user['email'] as String?;
|
|
}
|
|
profileName ??= profile['email'] as String?;
|
|
}
|
|
final caseValue = json['case'];
|
|
final invoice = json['invoice'];
|
|
return TimeEntry(
|
|
id: json['id']?.toString() ?? '',
|
|
caseId: caseValue is Map<String, dynamic>
|
|
? caseValue['id']?.toString() ?? ''
|
|
: caseValue?.toString() ?? '',
|
|
caseName: caseValue is Map<String, dynamic>
|
|
? caseValue['name'] as String?
|
|
: null,
|
|
profileId: profile is Map<String, dynamic>
|
|
? profile['id']?.toString() ?? ''
|
|
: json['profile']?.toString() ?? '',
|
|
profileName: profileName,
|
|
startedAt:
|
|
DateTime.tryParse(json['started_at']?.toString() ?? '') ??
|
|
DateTime.now(),
|
|
endedAt: json['ended_at'] != null
|
|
? DateTime.tryParse(json['ended_at'].toString())
|
|
: null,
|
|
durationMinutes: json['duration_minutes'] as int?,
|
|
billable: json['billable'] as bool? ?? false,
|
|
description: json['description'] as String?,
|
|
invoiceId: invoice is Map<String, dynamic>
|
|
? invoice['id']?.toString()
|
|
: invoice?.toString(),
|
|
invoiceNumber: invoice is Map<String, dynamic>
|
|
? invoice['invoice_number'] as String?
|
|
: null,
|
|
invoiced: json['invoiced'] == true,
|
|
hourlyRate: double.tryParse(json['hourly_rate']?.toString() ?? ''),
|
|
currency: json['currency'] as String?,
|
|
liveDurationMinutes: json['live_duration_minutes'] as int?,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Aggregate totals for a ticket. Mirrors `CaseSerializer.time_summary`.
|
|
class TimeSummary {
|
|
final int totalMinutes;
|
|
final int billableMinutes;
|
|
final DateTime? lastEntryAt;
|
|
final List<TimeSummaryByProfile> byProfile;
|
|
|
|
const TimeSummary({
|
|
this.totalMinutes = 0,
|
|
this.billableMinutes = 0,
|
|
this.lastEntryAt,
|
|
this.byProfile = const [],
|
|
});
|
|
|
|
factory TimeSummary.fromJson(Map<String, dynamic> json) {
|
|
return TimeSummary(
|
|
totalMinutes: json['total_minutes'] as int? ?? 0,
|
|
billableMinutes: json['billable_minutes'] as int? ?? 0,
|
|
lastEntryAt: json['last_entry_at'] != null
|
|
? DateTime.tryParse(json['last_entry_at'].toString())
|
|
: null,
|
|
byProfile: ((json['by_profile'] as List<dynamic>?) ?? const [])
|
|
.whereType<Map<String, dynamic>>()
|
|
.map(TimeSummaryByProfile.fromJson)
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class TimeSummaryByProfile {
|
|
final String profileId;
|
|
final String name;
|
|
final int minutes;
|
|
const TimeSummaryByProfile({
|
|
required this.profileId,
|
|
required this.name,
|
|
required this.minutes,
|
|
});
|
|
factory TimeSummaryByProfile.fromJson(Map<String, dynamic> json) {
|
|
return TimeSummaryByProfile(
|
|
profileId: json['profile_id']?.toString() ?? '',
|
|
name: json['name'] as String? ?? '',
|
|
minutes: json['minutes'] as int? ?? 0,
|
|
);
|
|
}
|
|
}
|