feat: introduce completion review UI components for actions, amount, info, search, and worker listing.
This commit is contained in:
@@ -541,8 +541,8 @@
|
|||||||
"min_break": "min break"
|
"min_break": "min break"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve_pay": "Approve & Process Payment",
|
"approve_pay": "Approve",
|
||||||
"flag_review": "Flag for Review",
|
"flag_review": "Review",
|
||||||
"download_pdf": "Download Invoice PDF"
|
"download_pdf": "Download Invoice PDF"
|
||||||
},
|
},
|
||||||
"flag_dialog": {
|
"flag_dialog": {
|
||||||
|
|||||||
@@ -536,8 +536,8 @@
|
|||||||
"min_break": "min de descanso"
|
"min_break": "min de descanso"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"approve_pay": "Aprobar y Procesar Pago",
|
"approve_pay": "Aprobar",
|
||||||
"flag_review": "Marcar para Revisi\u00f3n",
|
"flag_review": "Revisi\u00f3n",
|
||||||
"download_pdf": "Descargar PDF de Factura"
|
"download_pdf": "Descargar PDF de Factura"
|
||||||
},
|
},
|
||||||
"flag_dialog": {
|
"flag_dialog": {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
import '../blocs/billing_bloc.dart';
|
|
||||||
import '../blocs/billing_event.dart';
|
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
import '../widgets/completion_review/completion_review_actions.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_amount.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_info.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||||
|
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||||
|
|
||||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||||
|
|
||||||
@@ -26,7 +30,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Use widget.invoice if provided, else try to get from arguments
|
// Use widget.invoice if provided, else try to get from arguments
|
||||||
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice;
|
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -46,390 +50,45 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
|||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Expanded(
|
child: SingleChildScrollView(
|
||||||
child: SingleChildScrollView(
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
child: Column(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
_buildInvoiceInfoCard(),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
_buildAmountCard(),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
_buildWorkersHeader(),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
_buildSearchAndTabs(),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
...filteredWorkers.map(
|
|
||||||
(BillingWorkerRecord worker) => _buildWorkerCard(worker),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space6),
|
|
||||||
_buildActionButtons(context),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
_buildDownloadLink(),
|
|
||||||
const SizedBox(height: UiConstants.space8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInvoiceInfoCard() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: UiConstants.space1,
|
|
||||||
children: <Widget>[
|
|
||||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
|
||||||
_buildInfoRow(
|
|
||||||
UiIcons.clock,
|
|
||||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
|
||||||
),
|
|
||||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoRow(IconData icon, String text) {
|
|
||||||
return Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Text(text, style: UiTypography.body2r.textSecondary),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAmountCard() {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEFF6FF),
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: const Color(0xFFDBEAFE)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
t.client_billing.total_amount_label,
|
|
||||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space2),
|
|
||||||
Text(
|
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
|
||||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
|
||||||
style: UiTypography.footnote2b.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWorkersHeader() {
|
|
||||||
return Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Text(
|
|
||||||
t.client_billing.workers_tab.title(count: invoice.workersCount),
|
|
||||||
style: UiTypography.title2b.textPrimary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSearchAndTabs() {
|
|
||||||
return Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF1F5F9),
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
),
|
|
||||||
child: TextField(
|
|
||||||
onChanged: (String val) => setState(() => searchQuery = val),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
icon: const Icon(
|
|
||||||
UiIcons.search,
|
|
||||||
size: 18,
|
|
||||||
color: UiColors.iconSecondary,
|
|
||||||
),
|
|
||||||
hintText: t.client_billing.workers_tab.search_hint,
|
|
||||||
hintStyle: UiTypography.body2r.textSecondary,
|
|
||||||
border: InputBorder.none,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Expanded(
|
|
||||||
child: _buildTabButton(
|
|
||||||
t.client_billing.workers_tab.needs_review(count: 0),
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: _buildTabButton(
|
|
||||||
t.client_billing.workers_tab.all(count: invoice.workersCount),
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTabButton(String text, int index) {
|
|
||||||
final bool isSelected = selectedTab == index;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => setState(() => selectedTab = index),
|
|
||||||
child: Container(
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: UiTypography.body2b.copyWith(
|
|
||||||
color: isSelected ? Colors.white : UiColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildWorkerCard(BillingWorkerRecord worker) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusLg,
|
|
||||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
CircleAvatar(
|
const SizedBox(height: UiConstants.space4),
|
||||||
radius: 20,
|
CompletionReviewInfo(invoice: invoice),
|
||||||
backgroundColor: UiColors.bgSecondary,
|
const SizedBox(height: UiConstants.space4),
|
||||||
backgroundImage: worker.workerAvatarUrl != null
|
CompletionReviewAmount(invoice: invoice),
|
||||||
? NetworkImage(worker.workerAvatarUrl!)
|
const SizedBox(height: UiConstants.space6),
|
||||||
: null,
|
CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||||
child: worker.workerAvatarUrl == null
|
const SizedBox(height: UiConstants.space4),
|
||||||
? const Icon(
|
CompletionReviewSearchAndTabs(
|
||||||
UiIcons.user,
|
selectedTab: selectedTab,
|
||||||
size: 20,
|
workersCount: invoice.workersCount,
|
||||||
color: UiColors.iconSecondary,
|
onTabChanged: (int index) =>
|
||||||
)
|
setState(() => selectedTab = index),
|
||||||
: null,
|
onSearchChanged: (String val) =>
|
||||||
|
setState(() => searchQuery = val),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Expanded(
|
...filteredWorkers.map(
|
||||||
child: Column(
|
(BillingWorkerRecord worker) =>
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
CompletionReviewWorkerCard(worker: worker),
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
worker.workerName,
|
|
||||||
style: UiTypography.body1b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
worker.roleName,
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
|
||||||
style: UiTypography.body1b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${worker.startTime} - ${worker.endTime}',
|
|
||||||
style: UiTypography.footnote2b.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: UiColors.border),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
UiIcons.coffee,
|
|
||||||
size: 12,
|
|
||||||
color: UiColors.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
|
||||||
const SizedBox(width: UiConstants.space2),
|
|
||||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButtons(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
children: <Widget>[
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: UiButton.primary(
|
|
||||||
text: t.client_billing.actions.approve_pay,
|
|
||||||
leadingIcon: UiIcons.checkCircle,
|
|
||||||
onPressed: () {
|
|
||||||
Modular.get<BillingBloc>().add(
|
|
||||||
BillingInvoiceApproved(invoice.id),
|
|
||||||
);
|
|
||||||
Modular.to.pop();
|
|
||||||
UiSnackbar.show(
|
|
||||||
context,
|
|
||||||
message: t.client_billing.approved_success,
|
|
||||||
type: UiSnackbarType.success,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
size: UiButtonSize.large,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF22C55E),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
border: Border.all(color: Colors.orange, width: 2),
|
|
||||||
),
|
|
||||||
child: UiButton.secondary(
|
|
||||||
text: t.client_billing.actions.flag_review,
|
|
||||||
leadingIcon: UiIcons.warning,
|
|
||||||
onPressed: () => _showFlagDialog(context),
|
|
||||||
size: UiButtonSize.large,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.orange,
|
|
||||||
side: BorderSide.none,
|
|
||||||
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDownloadLink() {
|
|
||||||
return Center(
|
|
||||||
child: TextButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
|
|
||||||
label: Text(
|
|
||||||
t.client_billing.actions.download_pdf,
|
|
||||||
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
bottomNavigationBar: Container(
|
||||||
}
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
void _showFlagDialog(BuildContext context) {
|
color: Colors.white,
|
||||||
final controller = TextEditingController();
|
border: Border(
|
||||||
showDialog(
|
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => AlertDialog(
|
|
||||||
title: Text(t.client_billing.flag_dialog.title),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: t.client_billing.flag_dialog.hint,
|
|
||||||
),
|
),
|
||||||
maxLines: 3,
|
|
||||||
),
|
),
|
||||||
actions: [
|
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (controller.text.isNotEmpty) {
|
|
||||||
Modular.get<BillingBloc>().add(
|
|
||||||
BillingInvoiceDisputed(invoice.id, controller.text),
|
|
||||||
);
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
Modular.to.pop();
|
|
||||||
UiSnackbar.show(
|
|
||||||
context,
|
|
||||||
message: t.client_billing.flagged_success,
|
|
||||||
type: UiSnackbarType.warning,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Text(t.client_billing.flag_dialog.button),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class InvoiceReadyPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<BillingBloc>.value(
|
return BlocProvider<BillingBloc>.value(
|
||||||
value: Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
|
value: Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
|
||||||
child: const Placeholder(),
|
child: const InvoiceReadyView(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true),
|
appBar: const UiAppBar(title: 'Invoices Ready', showBackButton: true),
|
||||||
body: BlocBuilder<BillingBloc, BillingState>(
|
body: BlocBuilder<BillingBloc, BillingState>(
|
||||||
builder: (context, state) {
|
builder: (BuildContext context, BillingState state) {
|
||||||
if (state.status == BillingStatus.loading) {
|
if (state.status == BillingStatus.loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -56,9 +56,10 @@ class InvoiceReadyView extends StatelessWidget {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
itemCount: state.invoiceHistory.length,
|
itemCount: state.invoiceHistory.length,
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
separatorBuilder: (BuildContext context, int index) =>
|
||||||
itemBuilder: (context, index) {
|
const SizedBox(height: 16),
|
||||||
final invoice = state.invoiceHistory[index];
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||||
return _InvoiceSummaryCard(invoice: invoice);
|
return _InvoiceSummaryCard(invoice: invoice);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
import '../../blocs/billing_bloc.dart';
|
||||||
|
import '../../blocs/billing_event.dart';
|
||||||
|
|
||||||
|
class CompletionReviewActions extends StatelessWidget {
|
||||||
|
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||||
|
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
spacing: UiConstants.space2,
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: UiButton.secondary(
|
||||||
|
text: t.client_billing.actions.flag_review,
|
||||||
|
leadingIcon: UiIcons.warning,
|
||||||
|
onPressed: () => _showFlagDialog(context),
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: UiColors.destructive,
|
||||||
|
side: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: UiButton.primary(
|
||||||
|
text: t.client_billing.actions.approve_pay,
|
||||||
|
leadingIcon: UiIcons.checkCircle,
|
||||||
|
onPressed: () {
|
||||||
|
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoiceId));
|
||||||
|
Modular.to.pop();
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: t.client_billing.approved_success,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFlagDialog(BuildContext context) {
|
||||||
|
final TextEditingController controller = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext dialogContext) => AlertDialog(
|
||||||
|
title: Text(t.client_billing.flag_dialog.title),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: t.client_billing.flag_dialog.hint,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (controller.text.isNotEmpty) {
|
||||||
|
Modular.get<BillingBloc>().add(
|
||||||
|
BillingInvoiceDisputed(invoiceId, controller.text),
|
||||||
|
);
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
Modular.to.pop();
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: t.client_billing.flagged_success,
|
||||||
|
type: UiSnackbarType.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(t.client_billing.flag_dialog.button),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
class CompletionReviewAmount extends StatelessWidget {
|
||||||
|
const CompletionReviewAmount({required this.invoice, super.key});
|
||||||
|
|
||||||
|
final BillingInvoice invoice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: const Color(0xFFDBEAFE)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.client_billing.total_amount_label,
|
||||||
|
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||||
|
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||||
|
style: UiTypography.footnote2b.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
class CompletionReviewInfo extends StatelessWidget {
|
||||||
|
const CompletionReviewInfo({required this.invoice, super.key});
|
||||||
|
|
||||||
|
final BillingInvoice invoice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: UiConstants.space1,
|
||||||
|
children: <Widget>[
|
||||||
|
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||||
|
_buildInfoRow(
|
||||||
|
UiIcons.clock,
|
||||||
|
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||||
|
),
|
||||||
|
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Text(text, style: UiTypography.body2r.textSecondary),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CompletionReviewSearchAndTabs extends StatelessWidget {
|
||||||
|
const CompletionReviewSearchAndTabs({
|
||||||
|
required this.selectedTab,
|
||||||
|
required this.onTabChanged,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.workersCount,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int selectedTab;
|
||||||
|
final ValueChanged<int> onTabChanged;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final int workersCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF1F5F9),
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
icon: const Icon(
|
||||||
|
UiIcons.search,
|
||||||
|
size: 18,
|
||||||
|
color: UiColors.iconSecondary,
|
||||||
|
),
|
||||||
|
hintText: t.client_billing.workers_tab.search_hint,
|
||||||
|
hintStyle: UiTypography.body2r.textSecondary,
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: _buildTabButton(
|
||||||
|
t.client_billing.workers_tab.needs_review(count: 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: _buildTabButton(
|
||||||
|
t.client_billing.workers_tab.all(count: workersCount),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabButton(String text, int index) {
|
||||||
|
final bool isSelected = selectedTab == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onTabChanged(index),
|
||||||
|
child: Container(
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? const Color(0xFF2563EB) : UiColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: UiTypography.body2b.copyWith(
|
||||||
|
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||||
|
const CompletionReviewWorkerCard({required this.worker, super.key});
|
||||||
|
|
||||||
|
final BillingWorkerRecord worker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: UiColors.bgSecondary,
|
||||||
|
backgroundImage: worker.workerAvatarUrl != null
|
||||||
|
? NetworkImage(worker.workerAvatarUrl!)
|
||||||
|
: null,
|
||||||
|
child: worker.workerAvatarUrl == null
|
||||||
|
? const Icon(
|
||||||
|
UiIcons.user,
|
||||||
|
size: 20,
|
||||||
|
color: UiColors.iconSecondary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
worker.workerName,
|
||||||
|
style: UiTypography.body1b.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
worker.roleName,
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
||||||
|
style: UiTypography.body1b.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${worker.startTime} - ${worker.endTime}',
|
||||||
|
style: UiTypography.footnote2b.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.coffee,
|
||||||
|
size: 12,
|
||||||
|
color: UiColors.iconSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class CompletionReviewWorkersHeader extends StatelessWidget {
|
||||||
|
const CompletionReviewWorkersHeader({required this.workersCount, super.key});
|
||||||
|
|
||||||
|
final int workersCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
t.client_billing.workers_tab.title(count: workersCount),
|
||||||
|
style: UiTypography.title2b.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user