Merge branch 'origin/dev' into feature/session-persistence-new

This commit is contained in:
2026-03-20 12:44:25 +05:30
162 changed files with 6978 additions and 1283 deletions

View File

@@ -161,6 +161,7 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
children: <Widget>[
ShiftHeader(
title: shift.roleName,
locationName: shift.locationName,
startTime: _formatTime(shift.timeRange.startsAt),
current: shift.assignedWorkerCount,
total: shift.requiredWorkerCount,
@@ -226,9 +227,10 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
worker: worker,
shiftStartTime: _formatTime(shift.timeRange.startsAt),
showRateButton:
worker.status == AssignmentStatus.checkedIn ||
worker.status == AssignmentStatus.checkedOut ||
worker.status == AssignmentStatus.completed,
!worker.hasReview &&
(worker.status == AssignmentStatus.checkedIn ||
worker.status == AssignmentStatus.checkedOut ||
worker.status == AssignmentStatus.completed),
showCancelButton:
DateTime.now().isAfter(shift.timeRange.startsAt) &&
(worker.status == AssignmentStatus.noShow ||

View File

@@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget {
required this.lateCount,
required this.isExpanded,
required this.onToggle,
this.locationName,
super.key,
});
@@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget {
/// Callback invoked when the header is tapped to expand or collapse.
final VoidCallback onToggle;
/// Optional location or hub name for the shift.
final String? locationName;
/// Returns the status colour based on [coveragePercent].
///
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
@@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget {
title,
style: UiTypography.body1b.textPrimary,
),
if (locationName != null &&
locationName!.isNotEmpty) ...<Widget>[
const SizedBox(height: 2),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 10,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
locationName!,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
],
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[

View File

@@ -75,7 +75,7 @@ class ReorderWidget extends StatelessWidget {
borderRadius: UiConstants.radiusLg,
),
child: const Icon(
UiIcons.building,
UiIcons.briefcase,
size: 16,
color: UiColors.primary,
),
@@ -104,18 +104,6 @@ class ReorderWidget extends StatelessWidget {
],
),
),
// Column(
// crossAxisAlignment: CrossAxisAlignment.end,
// children: <Widget>[
// // ASSUMPTION: No i18n key for 'positions' under
// // reorder section — carrying forward existing
// // hardcoded string pattern for this migration.
// Text(
// '${order.positionCount} positions',
// style: UiTypography.footnote2r.textSecondary,
// ),
// ],
// ),
],
),
const SizedBox(height: UiConstants.space3),
@@ -130,7 +118,7 @@ class ReorderWidget extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
icon: UiIcons.users,
text: '${order.positionCount}',
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,

View File

@@ -10,6 +10,7 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
required this.endTime,
this.roleName,
this.lunchBreak,
this.hourlyRateCents,
});
/// The role ID for this position.
@@ -30,9 +31,19 @@ class OneTimeOrderPositionArgument extends UseCaseArgument {
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
final String? lunchBreak;
/// Hourly rate in cents for this position, if set.
final int? hourlyRateCents;
@override
List<Object?> get props =>
<Object?>[roleId, roleName, workerCount, startTime, endTime, lunchBreak];
List<Object?> get props => <Object?>[
roleId,
roleName,
workerCount,
startTime,
endTime,
lunchBreak,
hourlyRateCents,
];
}
/// Typed arguments for [CreateOneTimeOrderUseCase].
@@ -63,6 +74,40 @@ class OneTimeOrderArguments extends UseCaseArgument {
/// The selected vendor ID, if applicable.
final String? vendorId;
/// Serialises these arguments into the V2 API payload shape.
///
/// Times and dates are converted to UTC so the backend's
/// `combineDateAndTime` helper receives the correct values.
Map<String, dynamic> toJson() {
final String firstStartTime =
positions.isNotEmpty ? positions.first.startTime : '00:00';
final String utcOrderDate = toUtcDateIso(orderDate, firstStartTime);
final List<Map<String, dynamic>> positionsList =
positions.map((OneTimeOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': toUtcTimeHHmm(orderDate, p.startTime),
'endTime': toUtcTimeHHmm(orderDate, p.endTime),
if (p.lunchBreak != null &&
p.lunchBreak != 'NO_BREAK' &&
p.lunchBreak!.isNotEmpty)
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
};
}).toList();
return <String, dynamic>{
'hubId': hubId,
'eventName': eventName,
'orderDate': utcOrderDate,
'positions': positionsList,
if (vendorId != null) 'vendorId': vendorId,
};
}
@override
List<Object?> get props =>
<Object?>[hubId, eventName, orderDate, positions, vendorId];

View File

@@ -9,6 +9,7 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
required this.startTime,
required this.endTime,
this.roleName,
this.hourlyRateCents,
});
/// The role ID for this position.
@@ -26,9 +27,18 @@ class PermanentOrderPositionArgument extends UseCaseArgument {
/// Shift end time in HH:mm format.
final String endTime;
/// Hourly rate in cents for this position, if set.
final int? hourlyRateCents;
@override
List<Object?> get props =>
<Object?>[roleId, roleName, workerCount, startTime, endTime];
List<Object?> get props => <Object?>[
roleId,
roleName,
workerCount,
startTime,
endTime,
hourlyRateCents,
];
}
/// Typed arguments for [CreatePermanentOrderUseCase].
@@ -63,6 +73,52 @@ class PermanentOrderArguments extends UseCaseArgument {
/// The selected vendor ID, if applicable.
final String? vendorId;
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
static const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Serialises these arguments into the V2 API payload shape.
///
/// Times and dates are converted to UTC so the backend's
/// `combineDateAndTime` helper receives the correct values.
Map<String, dynamic> toJson() {
final String firstStartTime =
positions.isNotEmpty ? positions.first.startTime : '00:00';
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
final List<int> daysOfWeekList = daysOfWeek
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positionsList =
positions.map((PermanentOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': toUtcTimeHHmm(startDate, p.startTime),
'endTime': toUtcTimeHHmm(startDate, p.endTime),
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
};
}).toList();
return <String, dynamic>{
'hubId': hubId,
'eventName': eventName,
'startDate': utcStartDate,
'daysOfWeek': daysOfWeekList,
'positions': positionsList,
if (vendorId != null) 'vendorId': vendorId,
};
}
@override
List<Object?> get props => <Object?>[
hubId,

View File

@@ -9,6 +9,7 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
required this.startTime,
required this.endTime,
this.roleName,
this.hourlyRateCents,
});
/// The role ID for this position.
@@ -26,9 +27,18 @@ class RecurringOrderPositionArgument extends UseCaseArgument {
/// Shift end time in HH:mm format.
final String endTime;
/// Hourly rate in cents for this position, if set.
final int? hourlyRateCents;
@override
List<Object?> get props =>
<Object?>[roleId, roleName, workerCount, startTime, endTime];
List<Object?> get props => <Object?>[
roleId,
roleName,
workerCount,
startTime,
endTime,
hourlyRateCents,
];
}
/// Typed arguments for [CreateRecurringOrderUseCase].
@@ -67,6 +77,54 @@ class RecurringOrderArguments extends UseCaseArgument {
/// The selected vendor ID, if applicable.
final String? vendorId;
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
static const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Serialises these arguments into the V2 API payload shape.
///
/// Times and dates are converted to UTC so the backend's
/// `combineDateAndTime` helper receives the correct values.
Map<String, dynamic> toJson() {
final String firstStartTime =
positions.isNotEmpty ? positions.first.startTime : '00:00';
final String utcStartDate = toUtcDateIso(startDate, firstStartTime);
final String utcEndDate = toUtcDateIso(endDate, firstStartTime);
final List<int> recurrenceDaysList = recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positionsList =
positions.map((RecurringOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': toUtcTimeHHmm(startDate, p.startTime),
'endTime': toUtcTimeHHmm(startDate, p.endTime),
if (p.hourlyRateCents != null) 'hourlyRateCents': p.hourlyRateCents,
};
}).toList();
return <String, dynamic>{
'hubId': hubId,
'eventName': eventName,
'startDate': utcStartDate,
'endDate': utcEndDate,
'recurrenceDays': recurrenceDaysList,
'positions': positionsList,
if (vendorId != null) 'vendorId': vendorId,
};
}
@override
List<Object?> get props => <Object?>[
hubId,

View File

@@ -1,49 +1,19 @@
import 'package:krow_core/core.dart';
import '../arguments/one_time_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a one-time staffing order.
///
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, position mapping, break-minutes conversion) is business
/// logic that belongs here, not in the BLoC.
class CreateOneTimeOrderUseCase
implements UseCase<OneTimeOrderArguments, void> {
/// Delegates payload construction to [OneTimeOrderArguments.toJson] and
/// submission to the repository.
class CreateOneTimeOrderUseCase {
/// Creates a [CreateOneTimeOrderUseCase].
const CreateOneTimeOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
@override
/// Creates a one-time order from the given arguments.
Future<void> call(OneTimeOrderArguments input) {
final String orderDate = formatDateToIso(input.orderDate);
final List<Map<String, dynamic>> positions =
input.positions.map((OneTimeOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': p.startTime,
'endTime': p.endTime,
if (p.lunchBreak != null &&
p.lunchBreak != 'NO_BREAK' &&
p.lunchBreak!.isNotEmpty)
'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!),
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'orderDate': orderDate,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createOneTimeOrder(payload);
return _repository.createOneTimeOrder(input.toJson());
}
}

View File

@@ -1,61 +1,19 @@
import 'package:krow_core/core.dart';
import '../arguments/permanent_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a permanent staffing order.
///
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, day-of-week mapping, position mapping) is business
/// logic that belongs here, not in the BLoC.
class CreatePermanentOrderUseCase
implements UseCase<PermanentOrderArguments, void> {
/// Delegates payload construction to [PermanentOrderArguments.toJson] and
/// submission to the repository.
class CreatePermanentOrderUseCase {
/// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
@override
/// Creates a permanent order from the given arguments.
Future<void> call(PermanentOrderArguments input) {
final String startDate = formatDateToIso(input.startDate);
final List<int> daysOfWeek = input.daysOfWeek
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((PermanentOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': p.startTime,
'endTime': p.endTime,
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'startDate': startDate,
'daysOfWeek': daysOfWeek,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createPermanentOrder(payload);
return _repository.createPermanentOrder(input.toJson());
}
}

View File

@@ -1,63 +1,19 @@
import 'package:krow_core/core.dart';
import '../arguments/recurring_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a recurring staffing order.
///
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, recurrence-day mapping, position mapping) is business
/// logic that belongs here, not in the BLoC.
class CreateRecurringOrderUseCase
implements UseCase<RecurringOrderArguments, void> {
/// Delegates payload construction to [RecurringOrderArguments.toJson] and
/// submission to the repository.
class CreateRecurringOrderUseCase {
/// Creates a [CreateRecurringOrderUseCase].
const CreateRecurringOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
@override
/// Creates a recurring order from the given arguments.
Future<void> call(RecurringOrderArguments input) {
final String startDate = formatDateToIso(input.startDate);
final String endDate = formatDateToIso(input.endDate);
final List<int> recurrenceDays = input.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((RecurringOrderPositionArgument p) {
return <String, dynamic>{
if (p.roleName != null) 'roleName': p.roleName,
if (p.roleId.isNotEmpty) 'roleId': p.roleId,
'workerCount': p.workerCount,
'startTime': p.startTime,
'endTime': p.endTime,
};
}).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'startDate': startDate,
'endDate': endDate,
'recurrenceDays': recurrenceDays,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createRecurringOrder(payload);
return _repository.createRecurringOrder(input.toJson());
}
}

View File

@@ -265,6 +265,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
startTime: p.startTime,
endTime: p.endTime,
lunchBreak: p.lunchBreak,
hourlyRateCents:
role != null ? (role.costPerHour * 100).round() : null,
);
}).toList();

View File

@@ -360,6 +360,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
workerCount: p.count,
startTime: p.startTime,
endTime: p.endTime,
hourlyRateCents:
role != null ? (role.costPerHour * 100).round() : null,
);
}).toList();

View File

@@ -380,6 +380,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
workerCount: p.count,
startTime: p.startTime,
endTime: p.endTime,
hourlyRateCents:
role != null ? (role.costPerHour * 100).round() : null,
);
}).toList();

View File

@@ -22,8 +22,8 @@ class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface {
final ApiResponse response = await _api.get(
ClientEndpoints.ordersView,
params: <String, dynamic>{
'startDate': start.toIso8601String(),
'endDate': end.toIso8601String(),
'startDate': start.toUtc().toIso8601String(),
'endDate': end.toUtc().toIso8601String(),
},
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;

View File

@@ -48,13 +48,13 @@ class OrderEditSheetState extends State<OrderEditSheet> {
_orderNameController = TextEditingController(text: widget.order.roleName);
final String startHH =
widget.order.startsAt.toLocal().hour.toString().padLeft(2, '0');
widget.order.startsAt.hour.toString().padLeft(2, '0');
final String startMM =
widget.order.startsAt.toLocal().minute.toString().padLeft(2, '0');
widget.order.startsAt.minute.toString().padLeft(2, '0');
final String endHH =
widget.order.endsAt.toLocal().hour.toString().padLeft(2, '0');
widget.order.endsAt.hour.toString().padLeft(2, '0');
final String endMM =
widget.order.endsAt.toLocal().minute.toString().padLeft(2, '0');
widget.order.endsAt.minute.toString().padLeft(2, '0');
_positions = <Map<String, dynamic>>[
<String, dynamic>{

View File

@@ -77,9 +77,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
/// Formats a [DateTime] to a display time string (e.g. "9:00 AM").
String _formatTime({required DateTime dateTime}) {
final DateTime local = dateTime.toLocal();
final int hour24 = local.hour;
final int minute = local.minute;
final int hour24 = dateTime.hour;
final int minute = dateTime.minute;
final String ampm = hour24 >= 12 ? 'PM' : 'AM';
int hour = hour24 % 12;
if (hour == 0) hour = 12;
@@ -124,7 +123,9 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
: 0;
final double hours = _computeHours(order);
final double cost = order.totalCostCents / 100.0;
final double cost = order.totalValue > 0
? order.totalValue
: order.totalCostCents / 100.0;
return Container(
decoration: BoxDecoration(