From 88f998cae1bb4eef4041c87b12de492a56e3c0c8 Mon Sep 17 00:00:00 2001 From: bvlourenco Date: Wed, 28 Dec 2022 20:43:22 +0000 Subject: [PATCH] feat: can edit and delete billings #298 --- backend/src/mongodb/billing.go | 28 +- backend/src/mongodb/company.go | 14 +- backend/src/router/company.go | 8 +- .../lib/routes/company/CompanyScreen.dart | 13 +- .../company/billing/AddBillingForm.dart | 5 +- .../routes/company/billing/BillingCard.dart | 45 ++- .../routes/company/billing/BillingScreen.dart | 7 +- .../company/billing/editBillingForm.dart | 353 ++++++++++++++++++ .../billing/participationBillingWidget.dart | 5 +- frontend/lib/services/billingService.dart | 31 +- 10 files changed, 464 insertions(+), 45 deletions(-) create mode 100644 frontend/lib/routes/company/billing/editBillingForm.dart diff --git a/backend/src/mongodb/billing.go b/backend/src/mongodb/billing.go index 6ac44598..43da2512 100644 --- a/backend/src/mongodb/billing.go +++ b/backend/src/mongodb/billing.go @@ -230,27 +230,21 @@ func (b *BillingsType) UpdateBilling(id primitive.ObjectID, data CreateBillingDa var updateQuery = bson.M{ "$set": bson.M{ "status": bson.M{ - "invoice": data.Status.Invoice, - "paid": data.Status.Paid, - "proForma": data.Status.ProForma, - "receipt": data.Status.Receipt, + "invoice": *data.Status.Invoice, + "paid": *data.Status.Paid, + "proForma": *data.Status.ProForma, + "receipt": *data.Status.Receipt, }, - "event": data.Event, - "value": data.Value, - "invoiceNumber": data.InvoiceNumber, - "emission": data.Emission, - "notes": data.Notes, + "event": *data.Event, + "value": *data.Value, + "invoiceNumber": *data.InvoiceNumber, + "emission": *data.Emission, + "notes": *data.Notes, + "company": *data.Company, + "visible": *data.Visible, }, } - if data.Company != nil { - updateQuery["company"] = *data.Company - } - - if data.Visible != nil { - updateQuery["visible"] = *data.Visible - } - var optionsQuery = options.FindOneAndUpdate() optionsQuery.SetReturnDocument(options.After) diff --git a/backend/src/mongodb/company.go b/backend/src/mongodb/company.go index 7017db25..096b002b 100644 --- a/backend/src/mongodb/company.go +++ b/backend/src/mongodb/company.go @@ -1184,23 +1184,19 @@ func (c *CompaniesType) UpdateBilling(companyID primitive.ObjectID, billingID pr } // RemoveCompanyParticipationBilling removes a billing on the company participation. -func (c *CompaniesType) RemoveCompanyParticipationBilling(companyID primitive.ObjectID) (*models.Company, error) { +func (c *CompaniesType) RemoveCompanyParticipationBilling(companyID primitive.ObjectID, event int) (*models.Company, error) { ctx := context.Background() var updatedCompany models.Company - currentEvent, err := Events.GetCurrentEvent() - if err != nil { - return nil, err - } - var updateQuery = bson.M{ - "$pull": bson.M{ - "participations.billing": "", + "$unset": bson.M{ + "participations.$.billing": "", + "participations.billing": "", }, } - var filterQuery = bson.M{"_id": companyID, "participations.event": currentEvent.ID} + var filterQuery = bson.M{"_id": companyID, "participations.event": event} var optionsQuery = options.FindOneAndUpdate() optionsQuery.SetReturnDocument(options.After) diff --git a/backend/src/router/company.go b/backend/src/router/company.go index 5a80ea4a..89b5068e 100644 --- a/backend/src/router/company.go +++ b/backend/src/router/company.go @@ -280,14 +280,14 @@ func deleteCompanyThread(w http.ResponseWriter, r *http.Request) { http.Error(w, "Could not parse credentials", http.StatusBadRequest) return } - + company, err := mongodb.Companies.DeleteCompanyThread(id, threadID) if err != nil { http.Error(w, "Company or thread not found", http.StatusNotFound) return } - - // Delete thread and posts (comments) associated to it - only if + + // Delete thread and posts (comments) associated to it - only if // thread was deleted sucessfully from speaker participation if _, err := mongodb.Threads.DeleteThread(threadID); err != nil { http.Error(w, "Thread not found", http.StatusNotFound) @@ -598,7 +598,7 @@ func deleteCompanyParticipationBilling(w http.ResponseWriter, r *http.Request) { backupBilling, _ := mongodb.Billings.GetBilling(billingID) - updatedCompany, err := mongodb.Companies.RemoveCompanyParticipationBilling(companyID) + updatedCompany, err := mongodb.Companies.RemoveCompanyParticipationBilling(companyID, backupBilling.Event) if err != nil { http.Error(w, "Could not remove billing from company participation", http.StatusExpectationFailed) return diff --git a/frontend/lib/routes/company/CompanyScreen.dart b/frontend/lib/routes/company/CompanyScreen.dart index aea6885e..495d4ed8 100644 --- a/frontend/lib/routes/company/CompanyScreen.dart +++ b/frontend/lib/routes/company/CompanyScreen.dart @@ -184,11 +184,14 @@ class _CompanyScreenState extends State company: widget.company, ), BillingScreen( - participations: widget.company.participations, - billingInfo: widget.company.billingInfo, - id: widget.company.id, - small: small, - ), + participations: widget.company.participations, + billingInfo: widget.company.billingInfo, + id: widget.company.id, + small: small, + onBillingDeleted: (billingId) => + companyChangedCallback(context, + fs: _companyService.deleteBilling( + widget.company.id, billingId))), ParticipationList( company: widget.company, onParticipationChanged: diff --git a/frontend/lib/routes/company/billing/AddBillingForm.dart b/frontend/lib/routes/company/billing/AddBillingForm.dart index 09dc556d..c32a548e 100644 --- a/frontend/lib/routes/company/billing/AddBillingForm.dart +++ b/frontend/lib/routes/company/billing/AddBillingForm.dart @@ -71,7 +71,8 @@ class _AddBillingFormState extends State { void _checkBillingEvent() async { int event = int.parse(_eventController.text); - int currentEvent = Provider.of(context, listen: false).event.id; + int currentEvent = + Provider.of(context, listen: false).event.id; if (event != currentEvent) { showDialog( context: context, @@ -87,6 +88,8 @@ class _AddBillingFormState extends State { }); }, ); + } else { + _submit(); } } diff --git a/frontend/lib/routes/company/billing/BillingCard.dart b/frontend/lib/routes/company/billing/BillingCard.dart index de099aef..a6d69f84 100644 --- a/frontend/lib/routes/company/billing/BillingCard.dart +++ b/frontend/lib/routes/company/billing/BillingCard.dart @@ -1,13 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:frontend/components/blurryDialog.dart'; import 'package:frontend/models/billing.dart'; +import 'package:frontend/routes/company/billing/editBillingForm.dart'; import 'package:intl/intl.dart'; class BillingCard extends StatefulWidget { Billing billing; final String id; final bool small; + final void Function(String) onDelete; + BillingCard( - {Key? key, required this.billing, required this.id, required this.small}) + {Key? key, + required this.billing, + required this.id, + required this.small, + required this.onDelete}) : super(key: key); @override @@ -19,6 +27,37 @@ class _BillingCardState extends State @override bool get wantKeepAlive => true; + void _editBillingModal(context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) { + return FractionallySizedBox( + heightFactor: 0.7, + child: Container( + child: EditBillingForm( + billing: widget.billing, + onBillingEdit: (context, _billing) { + billingChangedCallback(context, billing: _billing); + }), + )); + }, + ); + } + + void _deleteBillingDialog(context) { + showDialog( + context: context, + builder: (BuildContext context) { + return BlurryDialog('Warning', + 'Are you sure you want to delete SINFO ${widget.billing.event} billing?', + () { + widget.onDelete(widget.billing.id); + }); + }, + ); + } + Future billingChangedCallback(BuildContext context, {Billing? billing}) async { setState(() { @@ -63,7 +102,7 @@ class _BillingCardState extends State padding: const EdgeInsets.all(8.0), child: IconButton( onPressed: () { - // _editFlightModal(context); + _editBillingModal(context); }, color: const Color(0xff5c7ff2), icon: Icon(Icons.edit)), @@ -72,7 +111,7 @@ class _BillingCardState extends State padding: const EdgeInsets.all(8.0), child: IconButton( onPressed: () { - // _deleteFlightDialog(context); + _deleteBillingDialog(context); }, color: Colors.red, icon: Icon(Icons.delete)), diff --git a/frontend/lib/routes/company/billing/BillingScreen.dart b/frontend/lib/routes/company/billing/BillingScreen.dart index 11695084..bb63ca62 100644 --- a/frontend/lib/routes/company/billing/BillingScreen.dart +++ b/frontend/lib/routes/company/billing/BillingScreen.dart @@ -9,6 +9,7 @@ class BillingScreen extends StatefulWidget { final List? participations; final String id; final bool small; + final void Function(String) onBillingDeleted; CompanyBillingInfo? billingInfo; BillingScreen( @@ -16,7 +17,8 @@ class BillingScreen extends StatefulWidget { this.billingInfo, required this.participations, required this.small, - required this.id}) + required this.id, + required this.onBillingDeleted}) : super(key: key); @override @@ -136,7 +138,8 @@ class _BillingScreenState extends State (participation) => ParticipationBillingWidget( participation: participation, id: widget.id, - small: widget.small), + small: widget.small, + onDelete: widget.onBillingDeleted), ) .toList()), ); diff --git a/frontend/lib/routes/company/billing/editBillingForm.dart b/frontend/lib/routes/company/billing/editBillingForm.dart new file mode 100644 index 00000000..0e2b0d47 --- /dev/null +++ b/frontend/lib/routes/company/billing/editBillingForm.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:frontend/components/blurryDialog.dart'; +import 'package:frontend/components/eventNotifier.dart'; +import 'package:frontend/models/billing.dart'; +import 'package:frontend/services/billingService.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class EditBillingForm extends StatefulWidget { + final Billing billing; + final void Function(BuildContext, Billing?)? onBillingEdit; + EditBillingForm( + {Key? key, required this.billing, required this.onBillingEdit}) + : super(key: key); + + @override + _EditBillingFormState createState() => _EditBillingFormState(); +} + +class _EditBillingFormState extends State { + final _formKey = GlobalKey(); + + late TextEditingController _emissionController; + late TextEditingController _eventController; + late TextEditingController _invoiceNumberController; + late TextEditingController _notesController; + late TextEditingController _valueEurosController; + late TextEditingController _valueCentsController; + + late bool invoice; + late bool paid; + late bool proForma; + late bool receipt; + late bool visible; + + final _billingService = BillingService(); + + late DateTime _emission; + + @override + void initState() { + super.initState(); + + _emissionController = + TextEditingController(text: getDateTime(widget.billing.emission)); + _eventController = + TextEditingController(text: widget.billing.event.toString()); + _invoiceNumberController = + TextEditingController(text: widget.billing.invoiceNumber); + _notesController = TextEditingController(text: widget.billing.notes); + _valueEurosController = + TextEditingController(text: (widget.billing.value ~/ 100).toString()); + _valueCentsController = + TextEditingController(text: (widget.billing.value % 100).toString()); + + invoice = widget.billing.status.invoice; + paid = widget.billing.status.paid; + proForma = widget.billing.status.proForma; + receipt = widget.billing.status.receipt; + visible = widget.billing.visible; + + _emission = widget.billing.emission; + } + + String getDateTime(DateTime dateTime) { + return DateFormat('yyyy-MM-dd HH:mm').format(dateTime); + } + + void _checkBillingEvent() async { + int event = int.parse(_eventController.text); + int currentEvent = + Provider.of(context, listen: false).event.id; + if (event != currentEvent) { + showDialog( + context: context, + builder: (BuildContext context) { + return BlurryDialog( + 'Warning', + 'Are you sure you want to add a billing for SINFO ' + + event.toString() + + ' instead of SINFO ' + + currentEvent.toString() + + '?', () { + _submit(); + }); + }, + ); + } else { + _submit(); + } + } + + void _submit() async { + if (_formKey.currentState!.validate()) { + var event = int.parse(_eventController.text); + var invoiceNumber = _invoiceNumberController.text; + var notes = _notesController.text; + var value = int.parse(_valueEurosController.text) * 100 + + int.parse(_valueCentsController.text); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Updating Billing...')), + ); + + Billing? b = await _billingService.updateBilling( + id: widget.billing.id, + emission: _emission.toUtc(), + event: event, + invoiceNumber: invoiceNumber, + notes: notes, + invoice: invoice, + paid: paid, + proForma: proForma, + receipt: receipt, + value: value, + visible: visible); + + if (b != null) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + widget.onBillingEdit!(context, b); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Done'), + duration: Duration(seconds: 2), + ), + ); + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('An error occured.')), + ); + } + } + } + + Future _selectDateTime(BuildContext context) async { + final datePicker = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2025), + ); + + final timePicker = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + builder: (BuildContext context, Widget? child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), + child: child!, + ); + }); + + if (datePicker != null && timePicker != null) { + _emission = DateTime(datePicker.year, datePicker.month, datePicker.day, + timePicker.hour, timePicker.minute); + } + } + + Widget _buildForm() { + return Form( + key: _formKey, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _emissionController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the billing emission date'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.calendar_today), + labelText: "Emission Date *", + ), + readOnly: true, //prevents editing the date in the form field + onTap: () async { + await _selectDateTime(context); + String formattedDate = getDateTime(_emission!); + + setState(() { + _emissionController.text = formattedDate; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _eventController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a valid event'; + } + int event = int.parse(value); + if (event <= 0) { + return 'Please enter a valid event'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.money), + labelText: "Event *", + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _invoiceNumberController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the billing invoice number'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.title), + labelText: "Invoice Number *", + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _notesController, + decoration: const InputDecoration( + icon: const Icon(Icons.note), + labelText: "Additional notes (optional) *", + ), + ), + ), + Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _valueEurosController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the cost of the billing'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.money), + labelText: "Cost of billing (only euros) *", + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: TextFormField( + controller: _valueCentsController, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter the cost of the billing'; + } + return null; + }, + decoration: const InputDecoration( + icon: const Icon(Icons.money), + labelText: "Cost of billing (only cents) *", + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly + ], + ), + ), + ), + ], + ), + CheckboxListTile( + value: this.invoice, + onChanged: (val) { + setState(() { + this.invoice = !this.invoice; + }); + }, + title: new Text('Invoice'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.symmetric(horizontal: 4.0), + ), + CheckboxListTile( + value: this.paid, + onChanged: (val) { + setState(() { + this.paid = !this.paid; + }); + }, + title: new Text('Paid'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.symmetric(horizontal: 4.0), + ), + CheckboxListTile( + value: this.proForma, + onChanged: (val) { + setState(() { + this.proForma = !this.proForma; + }); + }, + title: new Text('ProForma'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.symmetric(horizontal: 4.0), + ), + CheckboxListTile( + value: this.receipt, + onChanged: (val) { + setState(() { + this.receipt = !this.receipt; + }); + }, + title: new Text('Receipt'), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.symmetric(horizontal: 4.0), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: ElevatedButton( + onPressed: () => _checkBillingEvent(), + child: const Text('Submit'), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return _buildForm(); + } +} diff --git a/frontend/lib/routes/company/billing/participationBillingWidget.dart b/frontend/lib/routes/company/billing/participationBillingWidget.dart index 6453c5a3..964e8914 100644 --- a/frontend/lib/routes/company/billing/participationBillingWidget.dart +++ b/frontend/lib/routes/company/billing/participationBillingWidget.dart @@ -7,12 +7,14 @@ class ParticipationBillingWidget extends StatelessWidget { final CompanyParticipation participation; final String id; final bool small; + final void Function(String) onDelete; ParticipationBillingWidget( {Key? key, required this.participation, required this.small, - required this.id}) + required this.id, + required this.onDelete}) : super(key: key); @override @@ -33,6 +35,7 @@ class ParticipationBillingWidget extends StatelessWidget { billing: bill, small: small, id: id, + onDelete: onDelete, ); } else { return Center(child: CircularProgressIndicator()); diff --git a/frontend/lib/services/billingService.dart b/frontend/lib/services/billingService.dart index 125f4163..7868ce69 100644 --- a/frontend/lib/services/billingService.dart +++ b/frontend/lib/services/billingService.dart @@ -39,10 +39,35 @@ class BillingService extends Service { } } - Future updateBilling(Billing billing) async { - var body = billing.toJson(); + Future updateBilling( + {required String id, + required DateTime emission, + required int event, + required String invoiceNumber, + String? notes, + required bool invoice, + required bool paid, + required bool proForma, + required bool receipt, + required int value, + bool? visible}) async { + var body = { + "emission": emission.toIso8601String(), + "event": event, + "invoiceNumber": invoiceNumber, + "notes": notes, + "status": { + "invoice": invoice, + "paid": paid, + "proForma": proForma, + "receipt": receipt + }, + "value": value, + "company": id, + "visible": visible + }; - Response response = await dio.put('/billings/${billing.id}', data: body); + Response response = await dio.put('/billings/' + id, data: body); try { return Billing.fromJson(json.decode(response.data!));