diff --git a/.fvmrc b/.fvmrc index 5c7f58e2b..0fdcb4876 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.5" + "flutter": "3.27.1" } \ No newline at end of file diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 6d45cb686..43575a1f0 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -15,7 +15,7 @@ jobs: - name: Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '3.24.5' + flutter-version: '3.27.1' channel: 'stable' - run: flutter build web --web-renderer canvaskit --release --base-href / diff --git a/.vscode/settings.json b/.vscode/settings.json index 0deca682e..b01430d59 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.24.5" + "dart.flutterSdkPath": ".fvm/versions/3.27.1" } \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..55aebf98a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,3 @@ + + + diff --git a/assets/translations/cs.json b/assets/translations/cs.json index 551e835ce..bf6b1d8d6 100644 --- a/assets/translations/cs.json +++ b/assets/translations/cs.json @@ -349,5 +349,7 @@ "Tickets": "Vstupenky", "Orders": "Objednávky", "State": "Stav", + "Do you want to send the tickets to orders?": "Chcete poslat vstupenky pro objednávky?", + "Send tickets": "Poslat vstupenky", "_": "_" } \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json index c82be63e3..b6dec2f2f 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -349,5 +349,7 @@ "Tickets": "Tickets", "Orders": "Orders", "State": "State", + "Do you want to send the tickets to orders?": "Do you want to send the tickets to orders?", + "Send tickets": "Send tickets", "_":"_" } \ No newline at end of file diff --git a/lib/AppRouter.dart b/lib/AppRouter.dart index 6935c4ce5..6e6c416da 100644 --- a/lib/AppRouter.dart +++ b/lib/AppRouter.dart @@ -43,8 +43,8 @@ class AppRouter extends RootStackRouter { AutoRoute(page: InstallRoute.page, path: sl(InstallPage.ROUTE)), AutoRoute(page: AdminDashboardRoute.page, path: sl(AdminDashboardPage.ROUTE)), AutoRoute(page: AdminDashboardRoute.page, path: sl(AdminDashboardPage.ROUTE)), - AutoRoute(page: FormRoute.page, path: "/${FormPage.ROUTE}/:id"), - AutoRoute(page: FormEditRoute.page, path: "/${FormPage.ROUTE}/:formKey/edit"), + AutoRoute(page: FormRoute.page, path: "/${FormPage.ROUTE}/:formLink"), + AutoRoute(page: FormEditRoute.page, path: "/${FormPage.ROUTE}/:formLink/edit"), AutoRoute(page: CheckRoute.page, path: "/:{$LINK}/${CheckPage.ROUTE}/:id"), AutoRoute(page: NewsFormRoute.page, path: "/:{$LINK}/${NewsFormPage.ROUTE}"), AutoRoute(page: HtmlEditorRoute.page, path: "/:{$LINK}/${HtmlEditorPage.ROUTE}"), diff --git a/lib/AppRouter.gr.dart b/lib/AppRouter.gr.dart index 15a77a293..cdc451918 100644 --- a/lib/AppRouter.gr.dart +++ b/lib/AppRouter.gr.dart @@ -268,15 +268,15 @@ class ForgotPasswordRoute extends _i28.PageRouteInfo { class FormEditRoute extends _i28.PageRouteInfo { FormEditRoute({ _i29.Key? key, - String? formKey, + String? formLink, List<_i28.PageRouteInfo>? children, }) : super( FormEditRoute.name, args: FormEditRouteArgs( key: key, - formKey: formKey, + formLink: formLink, ), - rawPathParams: {'formKey': formKey}, + rawPathParams: {'formLink': formLink}, initialChildren: children, ); @@ -288,12 +288,12 @@ class FormEditRoute extends _i28.PageRouteInfo { final pathParams = data.inheritedPathParams; final args = data.argsAs( orElse: () => - FormEditRouteArgs(formKey: pathParams.optString('formKey'))); + FormEditRouteArgs(formLink: pathParams.optString('formLink'))); return _i28.DeferredWidget( _i7.loadLibrary, () => _i7.FormEditPage( key: args.key, - formKey: args.formKey, + formLink: args.formLink, ), ); }, @@ -303,16 +303,16 @@ class FormEditRoute extends _i28.PageRouteInfo { class FormEditRouteArgs { const FormEditRouteArgs({ this.key, - this.formKey, + this.formLink, }); final _i29.Key? key; - final String? formKey; + final String? formLink; @override String toString() { - return 'FormEditRouteArgs{key: $key, formKey: $formKey}'; + return 'FormEditRouteArgs{key: $key, formLink: $formLink}'; } } @@ -343,10 +343,15 @@ class FormEditorTab extends _i28.PageRouteInfo { class FormRoute extends _i28.PageRouteInfo { FormRoute({ _i29.Key? key, + String? formLink, List<_i28.PageRouteInfo>? children, }) : super( FormRoute.name, - args: FormRouteArgs(key: key), + args: FormRouteArgs( + key: key, + formLink: formLink, + ), + rawPathParams: {'formLink': formLink}, initialChildren: children, ); @@ -355,24 +360,34 @@ class FormRoute extends _i28.PageRouteInfo { static _i28.PageInfo page = _i28.PageInfo( name, builder: (data) { - final args = - data.argsAs(orElse: () => const FormRouteArgs()); + final pathParams = data.inheritedPathParams; + final args = data.argsAs( + orElse: () => + FormRouteArgs(formLink: pathParams.optString('formLink'))); return _i28.DeferredWidget( _i9.loadLibrary, - () => _i9.FormPage(key: args.key), + () => _i9.FormPage( + key: args.key, + formLink: args.formLink, + ), ); }, ); } class FormRouteArgs { - const FormRouteArgs({this.key}); + const FormRouteArgs({ + this.key, + this.formLink, + }); final _i29.Key? key; + final String? formLink; + @override String toString() { - return 'FormRouteArgs{key: $key}'; + return 'FormRouteArgs{key: $key, formLink: $formLink}'; } } diff --git a/lib/dataModelsEshop/ProductModel.dart b/lib/dataModelsEshop/ProductModel.dart index 535dea041..d8a3795b1 100644 --- a/lib/dataModelsEshop/ProductModel.dart +++ b/lib/dataModelsEshop/ProductModel.dart @@ -12,6 +12,7 @@ class ProductModel { int? productType; int? occasion; String? productTypeString; + int? order; static const String foodType = "food"; static const String taxiType = "taxi"; @@ -20,17 +21,18 @@ class ProductModel { factory ProductModel.fromJson(Map json) { return ProductModel( - id: json[TbEshop.products.id], - createdAt: json[TbEshop.products.created_at] != null ? DateTime.parse(json[TbEshop.products.created_at]) : null, - updatedAt: json[TbEshop.products.updated_at] != null ? DateTime.parse(json[TbEshop.products.updated_at]) : null, - title: json[TbEshop.products.title], - isHidden: json[TbEshop.products.is_hidden], - description: json[TbEshop.products.description], - price: json[TbEshop.products.price] != null ? double.tryParse(json[TbEshop.products.price].toString()) : null, - data: json[TbEshop.products.data], - productType: json[TbEshop.products.product_type], - occasion: json[TbEshop.products.occasion], - productTypeString: json[metaTypeField], + id: json[TbEshop.products.id], + createdAt: json[TbEshop.products.created_at] != null ? DateTime.parse(json[TbEshop.products.created_at]) : null, + updatedAt: json[TbEshop.products.updated_at] != null ? DateTime.parse(json[TbEshop.products.updated_at]) : null, + title: json[TbEshop.products.title], + isHidden: json[TbEshop.products.is_hidden], + description: json[TbEshop.products.description], + price: json[TbEshop.products.price] != null ? double.tryParse(json[TbEshop.products.price].toString()) : null, + data: json[TbEshop.products.data], + productType: json[TbEshop.products.product_type], + occasion: json[TbEshop.products.occasion], + productTypeString: json[metaTypeField], + order: json[TbEshop.products.order] // Adding order to the JSON factory method ); } @@ -45,6 +47,7 @@ class ProductModel { TbEshop.products.data: data, TbEshop.products.product_type: productType, TbEshop.products.occasion: occasion, + 'order': order }; String toBasicString() => title ?? id.toString(); @@ -61,5 +64,6 @@ class ProductModel { this.productType, this.occasion, this.productTypeString, + this.order, }); } diff --git a/lib/dataModelsEshop/TbEshop.dart b/lib/dataModelsEshop/TbEshop.dart index 11a88852e..656278b98 100644 --- a/lib/dataModelsEshop/TbEshop.dart +++ b/lib/dataModelsEshop/TbEshop.dart @@ -35,6 +35,7 @@ class ProductsTb { String get data => "data"; String get product_type => "product_type"; String get occasion => "occasion"; + String get order => "order"; } class OrderProductTicketTb { diff --git a/lib/dataModelsEshop/TicketModel.dart b/lib/dataModelsEshop/TicketModel.dart index d66f47795..17147767a 100644 --- a/lib/dataModelsEshop/TicketModel.dart +++ b/lib/dataModelsEshop/TicketModel.dart @@ -20,7 +20,7 @@ class TicketModel extends IPlutoRowModel { double? totalPrice; // Relating spots and products directly to the ticket - List? relatedSpots; + BlueprintObjectModel? relatedSpot; List? relatedProducts; // Relating order directly to the ticket @@ -34,6 +34,7 @@ class TicketModel extends IPlutoRowModel { static const String metaRelatedOrder = "related_order"; static const String metaTicketsProducts = "ticket_products"; static const String metaPrice = "price"; + static const String metaSpot = "spot"; TicketModel({ this.id, @@ -44,7 +45,7 @@ class TicketModel extends IPlutoRowModel { this.occasion, this.note, this.noteHidden, - this.relatedSpots, + this.relatedSpot, this.relatedProducts, this.relatedOrder, }); @@ -95,6 +96,10 @@ class TicketModel extends IPlutoRowModel { value: relatedProducts != null ? relatedProducts!.map((p)=>p.toBasicString()).join(" | ") : ""), + metaSpot: PlutoCell( + value: relatedSpot != null + ? relatedSpot?.toShortString() + : ""), metaPrice: PlutoCell(value: totalPrice != null ? Utilities.formatPrice(context, totalPrice!) : ""), }); } diff --git a/lib/dataServices/DbEshop.dart b/lib/dataServices/DbEshop.dart index 7fa7fa8c7..3dd51c7a0 100644 --- a/lib/dataServices/DbEshop.dart +++ b/lib/dataServices/DbEshop.dart @@ -28,7 +28,7 @@ class DbEshop { "${TbEshop.product_types.id}," "${TbEshop.product_types.type}," "${TbEshop.product_types.title}," - "${TbEshop.products.table}(${TbEshop.products.id},${TbEshop.products.title},${TbEshop.products.price})" + "${TbEshop.products.table}(${TbEshop.products.id},${TbEshop.products.title},${TbEshop.products.price},${TbEshop.products.order})" ) .eq(TbEshop.product_types.occasion, currentOccasion) .eq("${TbEshop.products.table}.${TbEshop.products.is_hidden}", false); @@ -37,7 +37,7 @@ class DbEshop { data.map((x) { var toReturn = ProductTypeModel.fromJson(x); toReturn.products = toReturn.products?.sortedBy((i) => i.title ?? ""); - toReturn.products = toReturn.products?.sortedBy((i) => i.price ?? 0); + toReturn.products = toReturn.products?.sortedBy((i) => i.order ?? 0); for (ProductModel v in toReturn.products??[]){ v.title = v.price != null && v.price! > 0 ? "${v.title} (${Utilities.formatPrice(context, v.price!)})" : v.title; } @@ -51,9 +51,9 @@ class DbEshop { return await _supabase.functions.invoke("send-ticket-order", body: {"orderDetails": data}); } - static Future getForm(String formKey) async { + static Future getFormFromLink(String link) async { final response = await _supabase - .rpc('get_form', params: {'form_key': formKey}); + .rpc('get_form_from_link', params: {'form_link': link}); if(response["code"] == 200){ var form = FormModel.fromJson(response["data"]); @@ -62,11 +62,11 @@ class DbEshop { return null; } - static Future getFormForEdit(String formKey) async { + static Future getFormForEdit(String formLink) async { var data = await _supabase .from(Tb.forms.table) .select() - .eq(Tb.forms.key, formKey) + .eq(Tb.forms.link, formLink) .maybeSingle(); if(data==null) { @@ -125,7 +125,7 @@ class DbEshop { final response = await _supabase.rpc( 'get_blueprint_editor', params: { - 'form_key': formKey, + 'form_link': formKey, }, ); @@ -176,12 +176,12 @@ class DbEshop { return true; } - static Future> getAllOrders(String formKey) async { + static Future> getAllOrders(String formLink) async { final response = await _supabase.rpc( 'get_orders', params: { - 'form_key': formKey, + 'form_link': formLink, }, ); @@ -206,9 +206,9 @@ class DbEshop { for (var ticket in order.relatedTickets!) { // Relate spots to the ticket via orderProductTickets - ticket.relatedSpots = spots!.where((spot) { + ticket.relatedSpot = spots!.firstWhereOrNull((spot) { return orderProductTickets!.any((opt) => opt.ticketId == ticket.id && opt.id == spot.orderProductTicket); - }).toList(); + }); // Relate products to the ticket via orderProductTickets ticket.relatedProducts = products!.where((product) { @@ -244,8 +244,8 @@ class DbEshop { return orders.sortedBy((ou)=>ou.createdAt!).reversed.toList(); } - static Future> getAllTickets(String formKey) async { - var orders = await getAllOrders(formKey); + static Future> getAllTickets(String formLink) async { + var orders = await getAllOrders(formLink); List toReturn = []; for(var o in orders){ @@ -266,6 +266,31 @@ class DbEshop { toReturn = toReturn.sortedBy((ou)=>ou.createdAt!).reversed.toList(); return toReturn; } + + static Future sendTicketsToEmail({ + required List ticketIds, + required String email, + required int oc, // Occasion ID + }) async { + final body = { + "ticketIds": ticketIds, + "email": email, + "oc": oc, + }; + + try { + final response = await _supabase.functions.invoke( + "send-tickets", + body: body, + ); + + return response; + } catch (e) { + print("Unexpected error in sendTicketsToEmail: $e"); + rethrow; + } + } + static Future deleteOrder(OrderModel model) async { } diff --git a/lib/pages/Eshop/BlueprintEditorTab.dart b/lib/pages/Eshop/BlueprintEditorTab.dart index af02e2cec..4905ff6cf 100644 --- a/lib/pages/Eshop/BlueprintEditorTab.dart +++ b/lib/pages/Eshop/BlueprintEditorTab.dart @@ -32,7 +32,7 @@ class BlueprintTab extends StatefulWidget { class _BlueprintTabState extends State { BlueprintModel? blueprint; BlueprintGroupModel? currentGroup; - String? formKey; + String? formLink; List allBoxes = []; @@ -44,8 +44,8 @@ class _BlueprintTabState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - if (formKey == null && context.routeData.pathParams.isNotEmpty) { - formKey = context.routeData.pathParams.getString("formKey"); + if (formLink == null && context.routeData.pathParams.isNotEmpty) { + formLink = context.routeData.pathParams.getString("formLink"); } loadData(); } @@ -631,7 +631,7 @@ class _BlueprintTabState extends State { } Future loadData() async { - blueprint = await DbEshop.getBlueprintForEdit(formKey!); + blueprint = await DbEshop.getBlueprintForEdit(formLink!); setState(() {}); } } diff --git a/lib/pages/Eshop/EshopColumns.dart b/lib/pages/Eshop/EshopColumns.dart index b7370e329..c9b8390a7 100644 --- a/lib/pages/Eshop/EshopColumns.dart +++ b/lib/pages/Eshop/EshopColumns.dart @@ -15,6 +15,7 @@ class EshopColumns { static const String TICKET_TOTAL_PRICE = "ticketTotalPrice"; static const String TICKET_PRODUCTS = "ticketProducts"; static const String TICKET_CREATED_AT = "ticketCreatedAt"; + static const String TICKET_SPOT = "ticketSpot"; static const String ORDER_ID = "orderId"; static const String ORDER_PRICE = "orderPrice"; @@ -124,6 +125,16 @@ class EshopColumns { width: 200, ), ], + TICKET_SPOT: [ + PlutoColumn( + readOnly: true, + enableEditingMode: false, + title: "Spot".tr(), + field: TicketModel.metaSpot, + type: PlutoColumnType.text(), + width: 60, + ), + ], ORDER_ID: [ PlutoColumn( hide: true, diff --git a/lib/pages/Eshop/FormEditPage.dart b/lib/pages/Eshop/FormEditPage.dart index 2fe1b3da3..b073791a2 100644 --- a/lib/pages/Eshop/FormEditPage.dart +++ b/lib/pages/Eshop/FormEditPage.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:fstapp/RouterService.dart'; import 'package:fstapp/components/dataGrid/AdminPageHelper.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:fstapp/dataServices/AuthService.dart'; +import 'package:fstapp/dataServices/RightsService.dart'; +import 'package:fstapp/pages/LoginPage.dart'; @RoutePage() class FormEditPage extends StatefulWidget { static const ROUTE = "formEdit"; - String? formKey; + String? formLink; - FormEditPage({super.key, @pathParam this.formKey}); + FormEditPage({super.key, @pathParam this.formLink}); @override _FormEditPageState createState() => _FormEditPageState(); @@ -25,6 +29,15 @@ class _FormEditPageState extends State with SingleTickerProviderSt AdminTabDefinition.tickets, ]; + @override + Future didChangeDependencies() async { + if (!AuthService.isLoggedIn()) { + await RouterService.navigate(context, LoginPage.ROUTE); + } + + super.didChangeDependencies(); + } + @override void initState() { super.initState(); diff --git a/lib/pages/Eshop/FormEditorTab.dart b/lib/pages/Eshop/FormEditorTab.dart index a8ec84c75..59bc677ad 100644 --- a/lib/pages/Eshop/FormEditorTab.dart +++ b/lib/pages/Eshop/FormEditorTab.dart @@ -22,18 +22,18 @@ class FormEditorTab extends StatefulWidget { class _FormEditorTabState extends State { FormModel? form; - String? formKey; + String? formLink; @override void didChangeDependencies() { super.didChangeDependencies(); - if (formKey == null && context.routeData.pathParams.isNotEmpty) { - formKey = context.routeData.pathParams.getString("formKey"); + if (formLink == null && context.routeData.pathParams.isNotEmpty) { + formLink = context.routeData.pathParams.getString("formLink"); } loadData(); } Future loadData() async { - form = await DbEshop.getFormForEdit(formKey!); + form = await DbEshop.getFormForEdit(formLink!); setState(() {}); } diff --git a/lib/pages/Eshop/FormPage.dart b/lib/pages/Eshop/FormPage.dart index 14d22ea2c..2ad578484 100644 --- a/lib/pages/Eshop/FormPage.dart +++ b/lib/pages/Eshop/FormPage.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; @@ -31,8 +30,8 @@ import 'package:fstapp/widgets/SeatReservationWidget.dart'; class FormPage extends StatefulWidget { static const ROUTE = "form"; - String? formKey; - FormPage({super.key}); + String? formLink; + FormPage({super.key, @pathParam this.formLink}); @override _FormPageState createState() => _FormPageState(); @@ -52,8 +51,8 @@ class _FormPageState extends State { @override Future didChangeDependencies() async { - if (widget.formKey == null && context.routeData.hasPendingChildren) { - widget.formKey = context.routeData.pendingChildren[0].pathParams.getString("formKey"); + if (widget.formLink == null && context.routeData.hasPendingChildren) { + widget.formLink = context.routeData.pendingChildren[0].pathParams.getString("formLink"); } await loadData(); @@ -321,7 +320,7 @@ class _FormPageState extends State { onPressed: () { RouterService.navigate( context, - "${FormPage.ROUTE}/${form!.formKey}/edit") + "${FormPage.ROUTE}/${widget.formLink}/edit") .then((value) => loadData()); }, child: const Icon(Icons.edit), @@ -364,12 +363,12 @@ class _FormPageState extends State { _isLoading = true; }); - // if(widget.id == null) { - // return; - // } + if(widget.formLink == null) { + return; + } //var key = UuidConverter.base62ToUuid(widget.id!); - form = await DbEshop.getForm("7f4e3892-a544-4385-b933-61117e9755c3"); + form = await DbEshop.getFormFromLink(widget.formLink!); if (form == null) { return; } diff --git a/lib/pages/Eshop/OrdersTab.dart b/lib/pages/Eshop/OrdersTab.dart index 2105aae64..7c1f2bb97 100644 --- a/lib/pages/Eshop/OrdersTab.dart +++ b/lib/pages/Eshop/OrdersTab.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fstapp/components/dataGrid/DataGridAction.dart'; import 'package:fstapp/components/dataGrid/SingleTableDataGrid.dart'; @@ -7,6 +8,8 @@ import 'package:fstapp/dataServices/DbEshop.dart'; import 'package:fstapp/dataServices/RightsService.dart'; import 'package:fstapp/pages/Eshop/EshopColumns.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:fstapp/services/DialogHelper.dart'; +import 'package:fstapp/services/ToastHelper.dart'; class OrdersTab extends StatefulWidget { const OrdersTab({super.key}); @@ -16,16 +19,24 @@ class OrdersTab extends StatefulWidget { } class _OrdersTabState extends State { - String? formKey; + String? formLink; + Key refreshKey = UniqueKey(); + @override void didChangeDependencies() { super.didChangeDependencies(); - if (formKey == null && context.routeData.pathParams.isNotEmpty) { - formKey = context.routeData.pathParams.getString("formKey"); + if (formLink == null && context.routeData.pathParams.isNotEmpty) { + formLink = context.routeData.pathParams.getString("formLink"); } } + Future refreshData() async { + setState(() { + refreshKey = UniqueKey(); + }); + } + static const List columnIdentifiers = [ EshopColumns.ORDER_ID, EshopColumns.ORDER_SYMBOL, @@ -41,16 +52,71 @@ class _OrdersTabState extends State { @override Widget build(BuildContext context) { - return SingleTableDataGrid( + return KeyedSubtree(child: SingleTableDataGrid( context, - () => DbEshop.getAllOrders(formKey!), + () => DbEshop.getAllOrders(formLink!), OrderModel.fromPlutoJson, DataGridFirstColumn.check, TbEshop.orders.id, actionsExtended: DataGridActionsController( areAllActionsEnabled: RightsService.canUpdateUsers, isAddActionPossible: () => false), + headerChildren: [ + DataGridAction( + name: "Send tickets".tr(), + action: (SingleTableDataGrid dataGrid, [_]) => sendTickets(dataGrid), + isEnabled: RightsService.isEditor, + ), + ], columns: EshopColumns.generateColumns(columnIdentifiers), - ).DataGrid(); + ).DataGrid()); + } + + Future sendTickets(SingleTableDataGrid dataGrid) async { + var selectedTickets = _getChecked(dataGrid); + + if (selectedTickets.isEmpty) { + return; + } + + var confirm = await DialogHelper.showConfirmationDialogAsync( + context, + "Storno".tr(), + "${"Do you want to send the tickets to orders?".tr()} (${selectedTickets.length})", + ); + + if (confirm) { + var allOrders = await DbEshop.getAllOrders(formLink!); + var stornoFutures = selectedTickets.map((order) { + return () async { + var o = allOrders.firstWhere((o)=>o.id == order.id); + await sendTicketsToEmail(o); + }; + }).toList(); + + await DialogHelper.showProgressDialogAsync( + context, + "Processing".tr(), + stornoFutures.length, + futures: stornoFutures, + ); + refreshData(); + } + } + + Future sendTicketsToEmail(OrderModel order) async { + await DbEshop.sendTicketsToEmail( + ticketIds: order.relatedTickets!.map((t)=>t.id!).toList(), + email: order.data!["email"], + oc: RightsService.currentOccasion!, + ); + } + + List _getChecked(SingleTableDataGrid dataGrid) { + return List.from( + dataGrid.stateManager.refRows.originalList + .where((row) => row.checked == true) + .map((row) => OrderModel.fromPlutoJson(row.toJson())), + ); } } \ No newline at end of file diff --git a/lib/pages/Eshop/TicketsTab.dart b/lib/pages/Eshop/TicketsTab.dart index 12d267669..61f01a533 100644 --- a/lib/pages/Eshop/TicketsTab.dart +++ b/lib/pages/Eshop/TicketsTab.dart @@ -19,15 +19,15 @@ class TicketsTab extends StatefulWidget { } class _TicketsTabState extends State { - String? formKey; + String? formLink; Key refreshKey = UniqueKey(); @override void didChangeDependencies() { super.didChangeDependencies(); - if (formKey == null && context.routeData.pathParams.isNotEmpty) { - formKey = context.routeData.pathParams.getString("formKey"); + if (formLink == null && context.routeData.pathParams.isNotEmpty) { + formLink = context.routeData.pathParams.getString("formLink"); } } @@ -45,6 +45,7 @@ class _TicketsTabState extends State { EshopColumns.TICKET_CREATED_AT, EshopColumns.TICKET_STATE, EshopColumns.TICKET_TOTAL_PRICE, + EshopColumns.TICKET_SPOT, EshopColumns.TICKET_PRODUCTS, EshopColumns.TICKET_NOTE, EshopColumns.TICKET_NOTE_HIDDEN, @@ -57,7 +58,7 @@ class _TicketsTabState extends State { child: SingleTableDataGrid( context, - () => DbEshop.getAllTickets(formKey!), + () => DbEshop.getAllTickets(formLink!), TicketModel.fromPlutoJson, DataGridFirstColumn.check, TbEshop.tickets.id, diff --git a/netlify.toml b/netlify.toml index bf18f80f5..1d957d29e 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,3 +1,3 @@ [build] -command = "curl -L https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.24.5-stable.tar.xz | tar -xJf - -C /opt/buildhome && PATH=/opt/buildhome/flutter/bin:$PATH flutter precache && PATH=/opt/buildhome/flutter/bin:$PATH flutter build web --web-renderer canvaskit --release" +command = "curl -L https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.27.1-stable.tar.xz | tar -xJf - -C /opt/buildhome && PATH=/opt/buildhome/flutter/bin:$PATH flutter precache && PATH=/opt/buildhome/flutter/bin:$PATH flutter build web --web-renderer canvaskit --release" publish = "build/web" \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 18a1eac89..a471f5a5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: sdk: flutter flutter: sdk: flutter - flutter_svg: ^2.0.16 + flutter_svg: ^2.0.17 fluttertoast: ^8.2.10 # The following adds the Cupertino Icons font to your application. @@ -43,7 +43,7 @@ dependencies: flutter_map_marker_popup: ^7.0.0 # flutter_map_marker_popup: # path: ./pckgs/flutter_map_marker_popup-master - supabase_flutter: ^2.8.2 + supabase_flutter: ^2.8.3 flutter_secure_storage: ^9.2.2 timelines_plus: ^1.0.4 intl: ^0.19.0 @@ -62,15 +62,15 @@ dependencies: sembast_web: ^2.4.0+4 path_provider: ^2.1.5 select_dialog: ^2.0.1 - onesignal_flutter: ^5.2.7 + onesignal_flutter: ^5.2.9 package_info_plus: ^8.1.2 flutter_animate: ^4.5.2 easy_localization: ^3.0.7 flutter_map_cancellable_tile_provider: ^3.0.2 simple_shadow: ^0.3.1 pwa_install: ^0.0.5 - flutter_form_builder: ^9.5.0 - form_builder_validators: ^11.0.0 + flutter_form_builder: ^9.7.0 + form_builder_validators: ^11.1.1 qr_flutter: ^4.1.0 screenshot: ^3.0.0 image_downloader_web: ^2.0.4 @@ -78,7 +78,7 @@ dependencies: flutter_map_pmtiles: ^1.0.4 flutter_map_animations: ^0.8.0 auto_route: ^9.2.2 - adaptive_theme: ^3.6.0 + adaptive_theme: ^3.7.0 connectivity_plus: ^6.1.1 dev_dependencies: diff --git a/scripts/database/eshop/get_blueprint_editor.sql b/scripts/database/eshop/get_blueprint_editor.sql index 1904a6856..94d71f588 100644 --- a/scripts/database/eshop/get_blueprint_editor.sql +++ b/scripts/database/eshop/get_blueprint_editor.sql @@ -1,5 +1,5 @@ CREATE OR REPLACE FUNCTION get_blueprint_editor( - form_key UUID + form_link TEXT ) RETURNS JSONB LANGUAGE plpgsql @@ -16,13 +16,13 @@ DECLARE valid_spots JSONB; occasion_id BIGINT; BEGIN - -- Resolve blueprint_id using form_key + -- Resolve blueprint_id using form_link SELECT blueprint INTO blueprint_id FROM public.forms - WHERE key = form_key; + WHERE link = form_link; - -- Check if form_key is valid + -- Check if form_link is valid IF blueprint_id IS NULL THEN RETURN jsonb_build_object('code', 404, 'message', 'Form key does not exist or is not linked to a blueprint'); END IF; diff --git a/scripts/database/eshop/get_form_from_link.sql b/scripts/database/eshop/get_form_from_link.sql new file mode 100644 index 000000000..02046cc9f --- /dev/null +++ b/scripts/database/eshop/get_form_from_link.sql @@ -0,0 +1,46 @@ +CREATE OR REPLACE FUNCTION get_form_from_link(form_link TEXT) +RETURNS JSON LANGUAGE plpgsql SECURITY DEFINER AS $$ +DECLARE + allData JSON; + generated_secret UUID := gen_random_uuid(); +BEGIN + -- Check if the form is open based on the provided link + IF NOT EXISTS ( + SELECT 1 + FROM public.forms + WHERE link = form_link AND is_open = true + ) THEN + -- Return an error if the form is not open + RETURN jsonb_build_object( + 'code', 400, + 'message', 'Form is not open.' + ); + END IF; + + -- Build the JSON response with form data if the form is open + SELECT jsonb_build_object( + 'code', 200, + 'data', jsonb_build_object( + 'id', f.id, + 'key', f.key, + 'created_at', f.created_at, + 'data', f.data, + 'type', f.type, + 'header', f.header, + 'footer', f.footer, + 'occasion', f.occasion, + 'blueprint', f.blueprint, + 'deadline_duration_seconds', f.deadline_duration_seconds, + 'account_number', ba.account_number, + 'secret', generated_secret + ) + ) + INTO allData + FROM public.forms f + LEFT JOIN eshop.bank_accounts ba ON f.bank_account = ba.id + WHERE f.link = form_link; + + -- Return the constructed JSON data + RETURN allData; +END; +$$; \ No newline at end of file diff --git a/scripts/database/eshop/get_orders.sql b/scripts/database/eshop/get_orders.sql index d9eba7a77..d4c045ae5 100644 --- a/scripts/database/eshop/get_orders.sql +++ b/scripts/database/eshop/get_orders.sql @@ -1,5 +1,5 @@ CREATE OR REPLACE FUNCTION get_orders( - form_key UUID + form_link TEXT ) RETURNS JSONB LANGUAGE plpgsql @@ -15,13 +15,13 @@ DECLARE orderProductTicketsData JSONB; paymentInfoData JSONB; BEGIN - -- Resolve blueprint_id using form_key + -- Resolve blueprint_id using form_link SELECT blueprint INTO blueprint_id FROM public.forms - WHERE key = form_key; + WHERE link = form_link; - -- Check if form_key is valid + -- Check if form_link is valid IF blueprint_id IS NULL THEN RETURN jsonb_build_object('code', 404, 'message', 'Form key does not exist or is not linked to a blueprint'); END IF; diff --git a/scripts/database/eshop/get_tickets_with_details.sql b/scripts/database/eshop/get_tickets_with_details.sql new file mode 100644 index 000000000..b127a5f1c --- /dev/null +++ b/scripts/database/eshop/get_tickets_with_details.sql @@ -0,0 +1,32 @@ +CREATE OR REPLACE FUNCTION get_tickets_with_details(ticket_ids BIGINT[]) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + tickets JSONB; +BEGIN + SELECT jsonb_agg(t) + INTO tickets + FROM ( + SELECT + t.id, + t.ticket_symbol, + t.occasion, + t.note, + ( + SELECT jsonb_agg(opt) + FROM ( + SELECT + opt.id, + opt.product + FROM eshop.order_product_ticket opt + WHERE opt.ticket = t.id + ) opt + ) AS order_product_ticket + FROM eshop.tickets t + WHERE t.id = ANY(ticket_ids) + ) t; + + RETURN tickets; +END; +$$; \ No newline at end of file diff --git a/supabase/functions/_shared/generateTicket.ts b/supabase/functions/_shared/generateTicket.ts new file mode 100644 index 000000000..7c69565a4 --- /dev/null +++ b/supabase/functions/_shared/generateTicket.ts @@ -0,0 +1,199 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.46.2'; +import { qrcode } from 'https://deno.land/x/qrcode/mod.ts'; +import { createCanvas, loadImage } from "https://deno.land/x/canvas/mod.ts"; + +// Initialize Supabase client +const supabaseAdmin = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +); + +/** + * Generates a ticket image based on the provided ticket data. + * @param ticket - The ticket object containing necessary details. + * @returns A Promise that resolves to a Uint8Array containing the image data. + */ +export async function generateTicketImage(ticket: any): Promise { + try { + // 1. Fetch occasion details to get the background image and other data + const { data: occasion, error: occasionError } = await supabaseAdmin + .from('occasions') + .select(` + id, + data + `) + .eq('id', ticket.occasion) + .single(); + + if (occasionError || !occasion) { + throw new Error(`Occasion not found or error fetching occasion: ${occasionError?.message}`); + } + + // 2. Extract background URL and color from occasion.data.features array + let backgroundUrl: string | undefined; + let fontColor: string = '#000000'; + + if (occasion.data && Array.isArray(occasion.data.features)) { + const ticketFeature = occasion.data.features.find( + (feature: any) => feature.code === 'ticket' + ); + if (ticketFeature) { + if (typeof ticketFeature.background === 'string') { + backgroundUrl = ticketFeature.background; + } + + if (typeof ticketFeature.color === 'string') { + // Validate hex color code + const hexColorRegex = /^[A-Fa-f0-9]{6}$/; + if (hexColorRegex.test(ticketFeature.color)) { + fontColor = `#${ticketFeature.color}`; + } else { + console.log(`Invalid color format: ${ticketFeature.color}`); + } + } + } + } + + if (!backgroundUrl) { + throw new Error('Background image URL not found in occasion.data.features.'); + } + + // 3. Fetch all product types to map product_type IDs to their string types + const { data: productTypes, error: productTypesError } = await supabaseAdmin.schema('eshop') + .from('product_types') + .select('id, type') + .throwOnError(); + + if (productTypesError || !productTypes) { + throw new Error(`Error fetching product types: ${productTypesError?.message}`); + } + + // Create a map of product_type ID to type string + const productTypeMap: Record = {}; + productTypes.forEach((pt: any) => { + productTypeMap[pt.id] = pt.type; + }); + + // 4. Fetch related products (spot, food, taxi) + const productIds = ticket.order_product_ticket.map((opt: any) => opt.product); + const { data: products, error: productsError } = await supabaseAdmin.schema('eshop') + .from('products') + .select(` + id, + title, + title_short, + product_type, + data + `) + .in('id', productIds) + .throwOnError(); + + if (productsError || !products) { + throw new Error(`Error fetching products: ${productsError?.message}`); + } + + // Map products by their type string + const productMap: Record = {}; + products.forEach((product: any) => { + const type = productTypeMap[product.product_type]; + if (type) { + productMap[type] = product; + } + }); + + const spotProduct = productMap['spot']; + const foodProduct = productMap['food']; + const taxiProduct = productMap['taxi']; + + // 5. Load background image + const backgroundImage = await loadImage(backgroundUrl); + + // 6. Create canvas with background dimensions + const canvas = createCanvas(backgroundImage.width(), backgroundImage.height()); + const ctx = canvas.getContext('2d'); + + // 7. Draw background + ctx.drawImage(backgroundImage, 0, 0, backgroundImage.width(), backgroundImage.height()); + + // 8. Define positions and styles for QR code and text + const padding = 175; + const qrSize = 200; // Increased size for better visibility + const lineHeight = 40; + + // Calculate center positions + const centerX = backgroundImage.width() / 2; + const lowerHalfStartY = backgroundImage.height() / 2; + + // Set font color and alignment + ctx.fillStyle = fontColor; + ctx.textAlign = 'center'; + + // 9. Generate QR Code based on ticket.ticket_symbol + const qrData = ticket.ticket_symbol; + const qrBase64 = await qrcode(qrData, { size: qrSize }); + const qrImage = await loadImage(`data:image/png;base64,${qrBase64.split(',')[1]}`); + + // 10. Create a separate canvas for the grouped object (texts + QR code) + const groupCanvasWidth = backgroundImage.width(); + const groupCanvasHeight = qrSize; // Height based on QR code size + const groupCanvas = createCanvas(groupCanvasWidth, groupCanvasHeight); + const groupCtx = groupCanvas.getContext('2d'); + + // Draw QR code on the group canvas (right side) + const groupQrX = groupCanvasWidth - qrSize - padding; + const groupQrY = 0; + groupCtx.drawImage(qrImage, groupQrX, groupQrY, qrSize, qrSize); + + // Prepare text content + const texts: string[] = []; + + // Add Spot Title + const spotOrder = ticket.order_product_ticket.find((opt: any) => opt.product === spotProduct.id); + if (spotOrder && spotOrder.spot_group_title) { + texts.push(`Stůl: ${spotOrder.spot_group_title}`); + } else { + texts.push(`Stůl: N/A`); + } + + // Add Food Title + if (foodProduct) { + const foodTitle = foodProduct.title_short || foodProduct.title || 'N/A'; + texts.push(`Večeře: ${foodTitle}`); + } + + // Add Taxi Title + if (taxiProduct) { + const taxiTitle = taxiProduct.title_short || taxiProduct.title || 'N/A'; + texts.push(`Odvoz: ${taxiTitle}`); + } + + // Set common font for all texts + ctx.font = 'bold 36px Arial'; // Larger font size + + // Add texts to the group canvas (left side) + const textStartX = padding; + let currentTextY = (groupCanvasHeight - (texts.length * lineHeight)) / 2 + lineHeight; + + texts.forEach((text) => { + groupCtx.font = 'bold 36px Arial'; + groupCtx.fillStyle = fontColor; + groupCtx.fillText(text, textStartX, currentTextY); + currentTextY += lineHeight; + }); + + // Draw the group canvas onto the main canvas, centered in the lower half + const groupX = 0; // Since groupCanvasWidth == backgroundImage.width() + const groupY = lowerHalfStartY + padding; // Positioned in the lower half with padding + + ctx.drawImage(groupCanvas, groupX, groupY, groupCanvasWidth, groupCanvasHeight); + + + // 12. Convert the canvas to a buffer + const buffer = canvas.toBuffer('image/png'); + return buffer; + + } catch (error) { + console.error('Error generating ticket image:', error); + throw error; // Re-throw the error after logging + } +} diff --git a/supabase/functions/send-ticket-order/index.ts b/supabase/functions/send-ticket-order/index.ts index 1e652bc74..a2c1701c1 100644 --- a/supabase/functions/send-ticket-order/index.ts +++ b/supabase/functions/send-ticket-order/index.ts @@ -211,8 +211,8 @@ Deno.serve(async (req) => { .insert({ "from": _DEFAULT_EMAIL, "to": orderDetails.email, - "template": "TICKET_ORDER_CONFIRMATION", - "organization": occasion.id, + "template": template.data.id, + "organization": organizationId, "occasion": occasion.id, }); diff --git a/supabase/functions/send-tickets/index.ts b/supabase/functions/send-tickets/index.ts new file mode 100644 index 000000000..dd3fbf222 --- /dev/null +++ b/supabase/functions/send-tickets/index.ts @@ -0,0 +1,207 @@ + +import { sendEmailWithSubs } from "../_shared/emailClient.ts"; +import { generateTicketImage } from "../_shared/generateTicket.ts"; // Ensure this path is correct +import { createClient, SupabaseClient } from 'https://esm.sh/@supabase/supabase-js@2.46.2'; +import { decode } from "https://deno.land/std@0.140.0/encoding/base64.ts"; + +// Initialize Supabase client +const supabaseAdmin: SupabaseClient = createClient( + Deno.env.get('SUPABASE_URL')!, + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! +); + +// Default sender email +const _DEFAULT_EMAIL = Deno.env.get("DEFAULT_EMAIL")!; + +// CORS Headers +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +}; + +/** + * Checks if the user is an editor for the given occasion. + * @param userId - The UUID of the user. + * @param occasionId - The ID of the occasion. + * @returns A Promise that resolves to a boolean indicating editor status. + */ +async function isUserEditor(userId: string, occasionId: bigint): Promise { + const { data, error } = await supabaseAdmin + .from('occasion_users') + .select('is_editor') + .eq('user', userId) + .eq('occasion', occasionId) + .single(); + + if (error || !data) { + console.error("Error fetching user role:", error); + return false; + } + + return data.is_editor; +} + +/** + * Serves the endpoint to send tickets via email. + */ +Deno.serve(async (req) => { + try { + // Handle CORS preflight request + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeaders }); + } + + const supabaseUser = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '', + { global: { headers: { Authorization: req.headers.get('Authorization')! } } } + ); + + const { data: user, error: userError } = await supabaseUser.auth.getUser(); + + if (userError || !user) { + console.error("User authentication failed:", userError); + return new Response(JSON.stringify({ error: "Unauthorized" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 401, + }); + } + + const userId = user.user.id; + console.log(userId); + // Parse request body + const reqData = await req.json(); + const { ticketIds, email, oc } = reqData; + + // Validate input + if (!Array.isArray(ticketIds) || typeof email !== 'string' || !oc) { + return new Response(JSON.stringify({ error: "Invalid input parameters" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + }); + } + + const occasionId = oc; + + // Check if user is editor for the occasion + const userIsEditor = await isUserEditor(userId, occasionId); + if (!userIsEditor) { + console.error(`User ${userId} is not an editor for occasion ${occasionId}`); + return new Response(JSON.stringify({ error: "Forbidden: User is not an editor for this occasion" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 403, + }); + } + + // Fetch occasion details for email template + const { data: occasionData, error: occasionError } = await supabaseAdmin + .from("occasions") + .select("organization, title") + .eq("id", occasionId) + .single(); + + if (occasionError || !occasionData) { + console.error("Occasion not found:", occasionError); + return new Response(JSON.stringify({ error: "Occasion not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + const organizationId = occasionData.organization; + const occasionTitle = occasionData.title; + + // Fetch email template + const { data: template, error: templateError } = await supabaseAdmin + .from("email_templates") + .select() + .eq("organization", organizationId) + .eq("occasion", occasionId) + .eq("code", "TICKET_ORDER_PAYMENT_DONE") + .single(); + + if (templateError || !template) { + console.error("Email template not found for the occasion:", templateError); + return new Response(JSON.stringify({ error: "Email template not found" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 404, + }); + } + + console.log(ticketIds); + const { data: tickets } = await supabaseAdmin.rpc("get_tickets_with_details", { + ticket_ids: ticketIds + }); + + if (!tickets) { + console.error("Error fetching tickets:", ticketsError); + return new Response(JSON.stringify({ error: "Error fetching tickets" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }); + } + + // Generate ticket images + const attachments = []; + for (const ticket of tickets) { + try { + console.log("generating..."); + const ticketImage = await generateTicketImage(ticket); + + attachments.push({ + filename: `ticket_${ticket.ticket_symbol}.png`, + content: ticketImage, // Uint8Array + contentType: "image/png", + encoding: "binary", + }); + } catch (imageError) { + console.error(`Error generating image for ticket ${ticket.id}:`, imageError); + // Continue with other tickets or handle accordingly + } + } + + if (attachments.length === 0) { + return new Response(JSON.stringify({ error: "No valid ticket images generated" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 400, + }); + } + + // Prepare email substitutions + const subs = { + occasionTitle: occasionTitle, + }; + + // Send email + await sendEmailWithSubs({ + to: email, + subject: template.subject, + content: template.html, + subs, + from: `${occasionTitle} | Festapp <${_DEFAULT_EMAIL}>`, + attachments: attachments, + }); + + await supabaseAdmin + .from("log_emails") + .insert({ + "from": _DEFAULT_EMAIL, + "to": email, + "template": template.id, + "organization": organizationId, + "occasion": occasionId, + }); + + return new Response(JSON.stringify({ message: "Tickets sent successfully", code: 200 }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }); + + } catch (error) { + console.error("Unexpected error:", error); + return new Response(JSON.stringify({ error: "Unexpected error occurred" }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }); + } +});