From a42c0847b88300c95fc0eef3792f1da4603a6d5c Mon Sep 17 00:00:00 2001 From: Frederik Benoist Date: Sat, 3 Jun 2023 10:38:34 +0200 Subject: [PATCH] refactor: visit synchronisation --- lib/bloc/example/bloc.dart | 3 - lib/bloc/example/example_bloc.dart | 38 ---- lib/bloc/example/example_event.dart | 15 -- lib/bloc/example/example_state.dart | 26 --- lib/main.dart | 4 +- lib/model/visit_model.dart | 4 +- lib/network/api_provider.dart | 213 ++----------------- lib/objectbox.dart | 49 ++++- lib/old/tab_sync.dart | 319 ---------------------------- lib/ui/sync/tab_synchro.dart | 47 ++-- 10 files changed, 103 insertions(+), 615 deletions(-) delete mode 100644 lib/bloc/example/bloc.dart delete mode 100644 lib/bloc/example/example_bloc.dart delete mode 100644 lib/bloc/example/example_event.dart delete mode 100644 lib/bloc/example/example_state.dart delete mode 100644 lib/old/tab_sync.dart diff --git a/lib/bloc/example/bloc.dart b/lib/bloc/example/bloc.dart deleted file mode 100644 index fdf7254..0000000 --- a/lib/bloc/example/bloc.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'example_bloc.dart'; -export 'example_event.dart'; -export 'example_state.dart'; \ No newline at end of file diff --git a/lib/bloc/example/example_bloc.dart b/lib/bloc/example/example_bloc.dart deleted file mode 100644 index 8b8854c..0000000 --- a/lib/bloc/example/example_bloc.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:mobdr/network/api_provider.dart'; -import './bloc.dart'; - -class ExampleBloc extends Bloc { - ExampleBloc() : super(InitialExampleState()) { - on(_getExample); - on(_postExample); - } -} - -void _getExample(GetExample event, Emitter emit) async { - ApiProvider _apiProvider = ApiProvider(); - - emit(ExampleWaiting()); - try { - String data = await _apiProvider.getExample(event.apiToken); - emit(GetExampleSuccess(exampleData: data)); - } catch (ex) { - if (ex != 'cancel') { - emit(ExampleError(errorMessage: ex.toString())); - } - } -} - -void _postExample(PostExample event, Emitter emit) async { - ApiProvider _apiProvider = ApiProvider(); - - emit(ExampleWaiting()); - try { - String data = await _apiProvider.postExample(event.id, event.apiToken); - emit(PostExampleSuccess(exampleData: data)); - } catch (ex) { - if (ex != 'cancel') { - emit(ExampleError(errorMessage: ex.toString())); - } - } -} diff --git a/lib/bloc/example/example_event.dart b/lib/bloc/example/example_event.dart deleted file mode 100644 index 8b16fc3..0000000 --- a/lib/bloc/example/example_event.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:meta/meta.dart'; - -@immutable -abstract class ExampleEvent {} - -class GetExample extends ExampleEvent { - final apiToken; - GetExample({@required this.apiToken}); -} - -class PostExample extends ExampleEvent { - final String id; - final apiToken; - PostExample({required this.id, required this.apiToken}); -} \ No newline at end of file diff --git a/lib/bloc/example/example_state.dart b/lib/bloc/example/example_state.dart deleted file mode 100644 index 9cbcc74..0000000 --- a/lib/bloc/example/example_state.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:meta/meta.dart'; - -@immutable -abstract class ExampleState {} - -class InitialExampleState extends ExampleState {} - -class ExampleError extends ExampleState { - final String errorMessage; - - ExampleError({ - required this.errorMessage, - }); -} - -class ExampleWaiting extends ExampleState {} - -class GetExampleSuccess extends ExampleState { - final String exampleData; - GetExampleSuccess({required this.exampleData}); -} - -class PostExampleSuccess extends ExampleState { - final String exampleData; - PostExampleSuccess({required this.exampleData}); -} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 520b253..4a90785 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -71,7 +71,9 @@ Future main() async { Wakelock.enable(); eventBus.on().listen((event) { - LoggerUtil.logVerbose('${DateTime.now()} Event: $event'); + if (!(event is EmptyEvent)) { + LoggerUtil.logNStackInfo('${DateTime.now()} Event: $event'); + } }); runApp(MyApp()); diff --git a/lib/model/visit_model.dart b/lib/model/visit_model.dart index cc955c4..cab0cb2 100644 --- a/lib/model/visit_model.dart +++ b/lib/model/visit_model.dart @@ -54,8 +54,8 @@ class VisitModel { } static Future> getPreviousVisits() async { - // Retrieve all previsous visits from the database - final visits = await objectbox.getPreviousVisit(); + // Retrieve all previous visits from the database + final visits = await objectbox.getActivePreviousVisit(); // Map each retrieved visit to VisiteModel final visitModelList = visits diff --git a/lib/network/api_provider.dart b/lib/network/api_provider.dart index 2dcf0ea..df35fc6 100644 --- a/lib/network/api_provider.dart +++ b/lib/network/api_provider.dart @@ -1,8 +1,3 @@ -/* -This is api provider -This page is used to get data from API - */ - import 'dart:io'; import 'package:dio/dio.dart'; @@ -11,10 +6,12 @@ import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as path; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; +import 'package:intl/intl.dart'; import 'package:mobdr/config/constant.dart'; import 'package:mobdr/main.dart'; import 'package:mobdr/service/shared_prefs.dart'; +import 'package:mobdr/service/logger_util.dart'; class ApiProvider { Dio dio = Dio(); @@ -210,6 +207,12 @@ class ApiProvider { /// Synchronize all informations about store, competitor, calendar Future SyncCalendar() async { + DateTime now = DateTime.now(); + DateTime end = now.add(Duration(days: 7)); + + String formattedStartDate = DateFormat('yyyyMMdd').format(now); + String formattedEndDate = DateFormat('yyyyMMdd').format(end); + try { final responseFutures = Future.wait([ getCrud( @@ -231,7 +234,11 @@ class ApiProvider { ApiConstants.externalEndpoint + ApiConstants.restEndpoint + '/mobDR/visite/calendrier', - {"id_utilisateur": 6, "start": 20230101, "end": 20230531}, + { + "id_utilisateur": SharedPrefs().id_utilisateur, + "start": formattedStartDate, + "end": formattedEndDate + }, ), getCrud( ApiConstants.baseUrl + @@ -313,38 +320,6 @@ class ApiProvider { } } - /// Synchronize "Photos" - Future SyncPhotos() async { - Response response; - - try { - /// get "Photo typologies" - response = await getCrud( - ApiConstants.baseUrl + - ApiConstants.externalEndpoint + - ApiConstants.restEndpoint + - '/mobDR/visite/typologie', - null); - - if (response.statusCode == STATUS_OK) { - // remove all objects - objectbox.PhotoTypologyBox.removeAll(); - - /// fill box "Photo typologies" - objectbox.addPhotoTypologies(response.data['typologies']); - } - - /// all ok - if (response.statusCode == STATUS_OK) { - return 'OK'; - } else { - return response.statusMessage ?? 'Unknow error ...'; - } - } catch (ex) { - return ex.toString(); // return ex.response!.data; - } - } - Future uploadPhotoServlet(int id_visite, String photoPath) async { try { final url = Uri.parse(SERVLET_API); @@ -476,161 +451,13 @@ class ApiProvider { } } - Future getExample(apiToken) async { - Response response; - - response = - await getConnect(ApiConstants.baseUrl + '/example/getData', apiToken); - print('res : ' + response.toString()); - return response.data.toString(); - } - - Future postExample(String id, apiToken) async { - Response response; - - var postData = {'id': id}; - response = await postConnect( - ApiConstants.baseUrl + '/example/postData', postData, apiToken); - print('res : ' + response.toString()); - return response.data.toString(); - } - /* - Future> getStudent(String sessionId, apiToken) async { - var postData = {'session_id': sessionId}; - response = await postConnect( - ApiConstants.baseUrl + '/student/getStudent', postData, apiToken); - if (response.data['status'] == STATUS_OK) { - List responseList = response.data['data']; - List listData = - responseList.map((f) => StudentModel.fromJson(f)).toList(); - return listData; - } else { - throw response.data['msg']; + /// Synchronize all informations about store, competitor, calendar + Future SyncLog() async { + try { + LoggerUtil.logNStacktackDebug("Synchronisation LOG à implementer !!"); + return 'OK'; + } catch (ex) { + return ex.toString(); } } - */ - - Future> addStudent( - String sessionId, - String studentName, - String studentPhoneNumber, - String studentGender, - String studentAddress, - apiToken) async { - var postData = { - 'session_id': sessionId, - 'student_name': studentName, - 'student_phone_number': studentPhoneNumber, - 'student_gender': studentGender, - 'student_address': studentAddress, - }; - Response response; - - response = await postConnect( - ApiConstants.baseUrl + '/student/addStudent', postData, apiToken); - if (response.data['status'] == STATUS_OK) { - List respList = []; - respList.add(response.data['msg']); - respList.add(response.data['data']['id']); - return respList; - } else { - throw response.data['msg']; - } - } - - Future editStudent( - String sessionId, - int studentId, - String studentName, - String studentPhoneNumber, - String studentGender, - String studentAddress, - apiToken) async { - var postData = { - 'session_id': sessionId, - 'student_id': studentId, - 'student_name': studentName, - 'student_phone_number': studentPhoneNumber, - 'student_gender': studentGender, - 'student_address': studentAddress, - }; - Response response; - - response = await postConnect( - ApiConstants.baseUrl + '/student/editStudent', postData, apiToken); - if (response.data['status'] == STATUS_OK) { - return response.data['msg']; - } else { - throw response.data['msg']; - } - } - - Future deleteStudent( - String sessionId, int studentId, apiToken) async { - var postData = { - 'session_id': sessionId, - 'student_id': studentId, - }; - Response response; - response = await postConnect( - ApiConstants.baseUrl + '/student/deleteStudent', postData, apiToken); - if (response.data['status'] == STATUS_OK) { - return response.data['msg']; - } else { - throw response.data['msg']; - } - } - - /* - Future> login2( - String email, String password, apiToken) async { - var postData = { - 'email': email, - 'password': password, - }; - response = await postConnect(LOGIN_API, postData, apiToken); - if (response.data['status'] == STATUS_OK) { - List responseList = response.data['data']; - List listData = - responseList.map((f) => LoginModel.fromJson(f)).toList(); - return listData; - } else { - throw response.data['msg']; - } - } - */ - - /* - Future> getProductGrid( - String sessionId, String skip, String limit, apiToken) async { - var postData = {'session_id': sessionId, 'skip': skip, 'limit': limit}; - response = await postConnect(PRODUCT_API, postData, apiToken); - if (response.data['status'] == STATUS_OK) { - List responseList = response.data['data']; - //print('data : '+responseList.toString()); - List listData = - responseList.map((f) => ProductGridModel.fromJson(f)).toList(); - return listData; - } else { - throw response.data['msg']; - } - } - */ - - /* - Future> getProductListview( - String sessionId, String skip, String limit, apiToken) async { - var postData = {'session_id': sessionId, 'skip': skip, 'limit': limit}; - response = await postConnect(PRODUCT_API, postData, apiToken); - if (response.data['status'] == STATUS_OK) { - List responseList = response.data['data']; - //print('data : '+responseList.toString()); - List listData = - responseList.map((f) => ProductListviewModel.fromJson(f)).toList(); - return listData; - } else { - throw response.data['msg']; - } - } - */ } diff --git a/lib/objectbox.dart b/lib/objectbox.dart index 038e302..981a9b5 100644 --- a/lib/objectbox.dart +++ b/lib/objectbox.dart @@ -7,6 +7,8 @@ import 'package:mobdr/db/box_visit_tag.dart'; import 'package:mobdr/db/box_visit_photo.dart'; import 'package:mobdr/db/box_photo_typology.dart'; +import 'package:mobdr/service/logger_util.dart'; + import 'model.dart'; import 'objectbox.g.dart'; // created by `flutter pub run build_runner build` @@ -270,7 +272,7 @@ class ObjectBox { return builder.find(); } - List getPreviousVisit() { + List getActivePreviousVisit() { // Get the previous date at midnight. final now = DateTime.now(); final midnight = DateTime(now.year, now.month, now.day); @@ -291,6 +293,24 @@ class ObjectBox { return builder.find(); } + List getPreviousVisit() { + // Get the previous date at midnight. + final now = DateTime.now(); + final midnight = DateTime(now.year, now.month, now.day); + + // Convert the date to an integer. + final millisecondsSinceEpoch = midnight.millisecondsSinceEpoch; + + // Query for all visits that match the date criteria, sorted by their date. + final builder = visitBox + .query(Visit_.date_visite.lessThan(millisecondsSinceEpoch)) + .order(Visit_.date_visite, flags: Order.descending) + .build(); + + // Execute the query and return the result. + return builder.find(); + } + // A function that converts a response body list into a List. List parseVisit(List responseDataList) { final parsed = responseDataList.cast>(); @@ -372,6 +392,33 @@ class ObjectBox { }); } + Future DeletePreviousVisitWithoutPhoto() async { + final visits = getPreviousVisit(); + + for (final visit in visits) { + final photoCount = getVisitPhotoCount(visit.id_visite); + + if (photoCount == 0) { + // Delete the visit + delVisitById(visit.id); + } + } + } + + /// Removes a Visito object from the ObjectBox database with the specified ID. + /// + /// Parameters: + /// id: The ID of the Visit object to remove. + /// + /// Returns: + /// None. + void delVisitById(int id) { + if (visitBox.remove(id)) { + LoggerUtil.logNStacktackDebug("delete visit:${id}"); + } else + LoggerUtil.logNStackError("delete visit:${id} KO"); + } + int getVisitCount() { return visitBox.count(); } diff --git a/lib/old/tab_sync.dart b/lib/old/tab_sync.dart deleted file mode 100644 index 0b881c7..0000000 --- a/lib/old/tab_sync.dart +++ /dev/null @@ -1,319 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:timelines/timelines.dart'; -import 'dart:math'; - -import 'package:mobdr/config/global_style.dart'; -import 'package:mobdr/ui/reusable/reusable_widget.dart'; -import 'package:mobdr/network/api_provider.dart'; -import 'package:mobdr/main.dart'; - -const completeColor = Color(0xff5e6172); -const inProgressColor = Color(0xff5ec792); -const todoColor = Color(0xffd1d2d7); -const failedColor = Colors.red; - -class TabSyncPage extends StatefulWidget { - @override - _TabSyncPageState createState() => _TabSyncPageState(); -} - -class _TabSyncPageState extends State - with AutomaticKeepAliveClientMixin { - // initialize global function and reusable widget - //final _globalFunction = GlobalFunction(); - final _reusableWidget = ReusableWidget(); - - final _processes = ['Btqs', 'Params', 'Visites', 'Photos', 'Logs']; - - final ApiProvider _apiProvider = - ApiProvider(); // TODO: A voir si bien positionné - - // _listKey is used for AnimatedList - //final GlobalKey _listKey = GlobalKey(); - - int _processIndex = 1; - - Color getColor(int index) { - if (index == _processIndex) { - return inProgressColor; - } else if (index < _processIndex) { - return completeColor; - } else { - return todoColor; - } - } - - // keep the state to do not refresh when switch navbar - @override - bool get wantKeepAlive => true; - - @override - void initState() { - super.initState(); - } - - @override - void dispose() { - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // if we used AutomaticKeepAliveClientMixin, we must call super.build(context); - super.build(context); - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - iconTheme: IconThemeData( - color: GlobalStyle.appBarIconThemeColor, - ), - elevation: GlobalStyle.appBarElevation, - title: Text( - 'Synchronisation', - style: GlobalStyle.appBarTitle, - ), - backgroundColor: GlobalStyle.appBarBackgroundColor, - systemOverlayStyle: GlobalStyle.appBarSystemOverlayStyle, - bottom: _reusableWidget.bottomAppBar(), - ), - body: Timeline.tileBuilder( - theme: TimelineThemeData( - direction: Axis.horizontal, - connectorTheme: ConnectorThemeData( - space: 30.0, - thickness: 5.0, - ), - ), - builder: TimelineTileBuilder.connected( - connectionDirection: ConnectionDirection.before, - itemExtentBuilder: (_, __) => - MediaQuery.of(context).size.width / _processes.length, - oppositeContentsBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: 15.0), - child: Image.asset( - 'assets/images/process_timeline/status${index + 1}.png', - width: 50.0, - color: getColor(index), - ), - ); - }, - contentsBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(top: 15.0), - child: Text( - _processes[index], - style: TextStyle( - fontWeight: FontWeight.bold, - color: getColor(index), - ), - ), - ); - }, - indicatorBuilder: (_, index) { - var color; - var child; - if (index == _processIndex) { - color = inProgressColor; - child = Padding( - padding: const EdgeInsets.all(8.0), - child: CircularProgressIndicator( - strokeWidth: 3.0, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ); - } else if (index < _processIndex) { - color = failedColor; - child = Icon( - Icons.cancel, - color: Colors.white, - size: 20.0, - ); - } else { - color = todoColor; - } - - if (index <= _processIndex) { - return Stack( - children: [ - CustomPaint( - size: Size(30.0, 30.0), - painter: _BezierPainter( - color: color, - drawStart: index > 0, - drawEnd: index < _processIndex, - ), - ), - DotIndicator( - size: 30.0, - color: color, - child: child, - ), - ], - ); - } else { - return Stack( - children: [ - CustomPaint( - size: Size(15.0, 15.0), - painter: _BezierPainter( - color: color, - drawEnd: index < _processes.length - 1, - ), - ), - OutlinedDotIndicator( - borderWidth: 4.0, - color: color, - ), - ], - ); - } - }, - connectorBuilder: (_, index, type) { - if (index > 0) { - if (index == _processIndex) { - final prevColor = getColor(index - 1); - final color = getColor(index); - List gradientColors; - if (type == ConnectorType.start) { - gradientColors = [Color.lerp(prevColor, color, 0.5)!, color]; - } else { - gradientColors = [ - prevColor, - Color.lerp(prevColor, color, 0.5)! - ]; - } - return DecoratedLineConnector( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: gradientColors, - ), - ), - ); - } else { - return SolidLineConnector( - color: getColor(index), - ); - } - } else { - return null; - } - }, - itemCount: _processes.length, - ), - ), - floatingActionButton: FloatingActionButton( - child: Icon(Icons.chevron_right), - onPressed: () async { - var futures = [ - //_apiProvider.SyncEtablissements(), - //_apiProvider.SyncVisites(), - _apiProvider.SyncPhotos(), - ]; - - objectbox.etabBox.removeAll(); - objectbox.etabCompetitorBox.removeAll(); - objectbox.visitBox.removeAll(); - objectbox.visitTagBox.removeAll(); - objectbox.visitPhotoBox.removeAll(); - objectbox.PhotoTypologyBox.removeAll(); - - var results = await Future.wait(futures); - - if (results[0] == 'OK') { - print("SyncEtablissements OK"); - } else { - print("SyncEtablissements Error:" + results[0]); - } - - if (results[1] == 'OK') { - print("SyncVisites OK"); - } else { - print("SyncVisites Error:" + results[1]); - } - - if (results[0] == 'OK') { - print("SyncPhotos OK"); - } else { - print("SyncPhotos Error:" + results[0]); - } - - /// - setState(() { - _processIndex = (_processIndex + 1) % _processes.length; - }); - }, - backgroundColor: inProgressColor, - ), - ); - } -} - -/// hardcoded bezier painter -class _BezierPainter extends CustomPainter { - const _BezierPainter({ - required this.color, - this.drawStart = true, - this.drawEnd = true, - }); - - final Color color; - final bool drawStart; - final bool drawEnd; - - Offset _offset(double radius, double angle) { - return Offset( - radius * cos(angle) + radius, - radius * sin(angle) + radius, - ); - } - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..style = PaintingStyle.fill - ..color = color; - - final radius = size.width / 2; - - var angle; - var offset1; - var offset2; - - var path; - - if (drawStart) { - angle = 3 * pi / 4; - offset1 = _offset(radius, angle); - offset2 = _offset(radius, -angle); - path = Path() - ..moveTo(offset1.dx, offset1.dy) - ..quadraticBezierTo(0.0, size.height / 2, -radius, radius) - ..quadraticBezierTo(0.0, size.height / 2, offset2.dx, offset2.dy) - ..close(); - - canvas.drawPath(path, paint); - } - if (drawEnd) { - angle = -pi / 4; - offset1 = _offset(radius, angle); - offset2 = _offset(radius, -angle); - - path = Path() - ..moveTo(offset1.dx, offset1.dy) - ..quadraticBezierTo( - size.width, size.height / 2, size.width + radius, radius) - ..quadraticBezierTo(size.width, size.height / 2, offset2.dx, offset2.dy) - ..close(); - - canvas.drawPath(path, paint); - } - } - - @override - bool shouldRepaint(_BezierPainter oldDelegate) { - return oldDelegate.color != color || - oldDelegate.drawStart != drawStart || - oldDelegate.drawEnd != drawEnd; - } -} diff --git a/lib/ui/sync/tab_synchro.dart b/lib/ui/sync/tab_synchro.dart index 18ebec5..3055f84 100644 --- a/lib/ui/sync/tab_synchro.dart +++ b/lib/ui/sync/tab_synchro.dart @@ -25,6 +25,7 @@ class _SynchronizationPageState extends State bool _backofficeSyncCompleted = false; bool _photosSyncCompleted = false; bool _logSyncCompleted = false; + bool _syncLog = false; @override void initState() { @@ -134,16 +135,6 @@ class _SynchronizationPageState extends State } _photosSyncCompleted = (_totalUploaded == _visitPhotosList.length); - - // Get unique id_visite values from _photosList - Set uniqueIds = - _visitPhotosList.map((photo) => photo.id_visite).toSet(); - - // Send VisitPhotoCountEvent for each unique id_visite - for (int id_visite in uniqueIds) { - eventBus.fire(VisitPhotoCountEvent( - id_visite, objectbox.getVisitPhotoCount(id_visite))); - } } Future _cleanVisitPhotoDir() async { @@ -221,8 +212,15 @@ class _SynchronizationPageState extends State // upload photo to server await _uploadVisitPhotos(_apiProvider); - // TODO: - // supprimer les visites "ancienne" sans photo !! + final syncLogResult = _syncLog ? await _apiProvider.SyncCalendar() : 'OK'; + + // log synchronization OK ? + if (syncLogResult == 'OK') { + _logSyncCompleted = true; + } + + // delete visits without a photo! + await objectbox.DeletePreviousVisitWithoutPhoto(); // deletes photos that are no longer in any visits await _cleanVisitPhotoDir(); @@ -234,10 +232,7 @@ class _SynchronizationPageState extends State SharedPrefs().lastCalendarRefresh = DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now()); - // TODO: je pense que comme il y a cet event (qui reload tout) on est plus obligé - // de lancer l'autre évent pour les count() de photo. - // - // de plus il faut supprimer les visites qui n'ont plus de photo !!! + // send global event to refresh calendar eventBus.fire(RefreshCalendarEvent(SharedPrefs().lastCalendarRefresh)); setState(() { @@ -312,7 +307,25 @@ class _SynchronizationPageState extends State ), textAlign: TextAlign.center, ), - SizedBox(height: 30), + SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + 'Sync Logs', + style: TextStyle(fontSize: 16), + ), + Switch( + value: _syncLog, + onChanged: (value) { + setState(() { + _syncLog = value; + }); + }, + ), + ], + ), + SizedBox(height: 20), SyncItem( icon: Icons.business, title: 'Backoffice',