512 lines
17 KiB
Dart
512 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:tm_app/core/theme/colors.dart';
|
|
import 'package:tm_app/core/utils/date_utils.dart';
|
|
import 'package:tm_app/data/services/model/tender_submission/tender_submission.dart';
|
|
import 'package:tm_app/view_models/your_tenders_view_model.dart';
|
|
|
|
class SubmissionWorkflow extends StatelessWidget {
|
|
const SubmissionWorkflow({super.key});
|
|
|
|
static const _stageLabels = <String, String>{
|
|
'opportunity': 'Opportunity',
|
|
'in_progress': 'In Progress',
|
|
'submitted': 'Submitted',
|
|
'not_applied': 'Not Applied',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<YourTendersViewModel>(
|
|
builder: (context, viewModel, _) {
|
|
final submissions = viewModel.submissionsData?.data ?? const [];
|
|
return Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 54,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 6,
|
|
),
|
|
children:
|
|
YourTendersViewModel.submissionStages.map((stage) {
|
|
final selected =
|
|
viewModel.selectedSubmissionStage == stage;
|
|
final count =
|
|
viewModel.submissionStats?.byStage[stage] ?? 0;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: ChoiceChip(
|
|
selected: selected,
|
|
onSelected:
|
|
(_) => viewModel.selectSubmissionStage(stage),
|
|
label: Text('${_stageLabels[stage]} ($count)'),
|
|
selectedColor: AppColors.secondary20,
|
|
side: BorderSide(
|
|
color:
|
|
selected
|
|
? AppColors.secondary50
|
|
: AppColors.grey30,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
if (viewModel.submissionActionError != null)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 4, 8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.red0,
|
|
border: Border.all(color: AppColors.red10),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: AppColors.error, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
viewModel.submissionActionError!,
|
|
style: TextStyle(color: AppColors.error, fontSize: 12),
|
|
),
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: viewModel.dismissSubmissionActionError,
|
|
icon: const Icon(Icons.close, size: 18),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (viewModel.isLoading && submissions.isEmpty)
|
|
const Expanded(
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
color: AppColors.secondary50,
|
|
),
|
|
),
|
|
)
|
|
else if (submissions.isEmpty)
|
|
Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
'No ${_stageLabels[viewModel.selectedSubmissionStage]?.toLowerCase()} submissions.',
|
|
style: TextStyle(color: AppColors.grey60),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: viewModel.loadSubmissions,
|
|
child: ListView.builder(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
|
itemCount: submissions.length,
|
|
itemBuilder:
|
|
(context, index) =>
|
|
_SubmissionCard(submission: submissions[index]),
|
|
),
|
|
),
|
|
),
|
|
if ((viewModel.submissionsData?.meta?.pages ?? 1) > 1)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Previous page',
|
|
onPressed:
|
|
viewModel.currentPage > 1 && !viewModel.isLoading
|
|
? () => viewModel.jumpToPage(
|
|
viewModel.currentPage - 1,
|
|
)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_left),
|
|
),
|
|
Text(
|
|
'Page ${viewModel.currentPage} of ${viewModel.totalPages}',
|
|
style: TextStyle(color: AppColors.grey60),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Next page',
|
|
onPressed:
|
|
viewModel.currentPage < viewModel.totalPages &&
|
|
!viewModel.isLoading
|
|
? () => viewModel.jumpToPage(
|
|
viewModel.currentPage + 1,
|
|
)
|
|
: null,
|
|
icon: const Icon(Icons.chevron_right),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SubmissionCard extends StatelessWidget {
|
|
const _SubmissionCard({required this.submission});
|
|
|
|
final TenderSubmission submission;
|
|
|
|
static const _statusLabels = <String, String>{
|
|
'opportunity': 'Opportunity',
|
|
'validating': 'Validating',
|
|
'partner_pending': 'Partner Pending',
|
|
'applying': 'Applying',
|
|
'sent_waiting': 'Sent / Waiting',
|
|
'rejected': 'Lost',
|
|
'contract': 'Contract Awarded',
|
|
'not_applied': 'Not Applied',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final viewModel = context.watch<YourTendersViewModel>();
|
|
final tender = submission.tender;
|
|
final busy = viewModel.updatingSubmissionId == submission.id;
|
|
final deadline = tender?.submissionDeadline ?? tender?.tenderDeadline;
|
|
|
|
return Card(
|
|
color: AppColors.cardBackground,
|
|
elevation: 0,
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: AppColors.borderColor),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
tender?.title?.trim().isNotEmpty == true
|
|
? tender!.title!
|
|
: 'Tender ${submission.tenderId}',
|
|
style: TextStyle(
|
|
color: AppColors.grey80,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_StatusBadge(
|
|
label: _statusLabels[submission.status] ?? submission.status,
|
|
status: submission.status,
|
|
),
|
|
],
|
|
),
|
|
if (tender?.description?.trim().isNotEmpty == true) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
tender!.description!,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: AppColors.grey70, height: 1.4),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 16,
|
|
runSpacing: 6,
|
|
children: [
|
|
if (tender?.countryCode?.isNotEmpty == true)
|
|
_Meta(
|
|
icon: Icons.location_on_outlined,
|
|
text: tender!.countryCode!,
|
|
),
|
|
if (deadline != null)
|
|
_Meta(
|
|
icon: Icons.event_outlined,
|
|
text: 'Deadline ${timeConvertor(deadline)}',
|
|
),
|
|
if (tender?.buyerOrganization?.name?.isNotEmpty == true)
|
|
_Meta(
|
|
icon: Icons.business_outlined,
|
|
text: tender!.buyerOrganization!.name!,
|
|
),
|
|
],
|
|
),
|
|
if (submission.statusHistory.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
ExpansionTile(
|
|
tilePadding: EdgeInsets.zero,
|
|
childrenPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
title: Text(
|
|
'Activity (${submission.statusHistory.length})',
|
|
style: TextStyle(color: AppColors.grey60, fontSize: 13),
|
|
),
|
|
children:
|
|
submission.statusHistory.reversed
|
|
.map(
|
|
(item) => ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.circle, size: 8),
|
|
title: Text(
|
|
_statusLabels[item.status] ?? item.status,
|
|
style: TextStyle(color: AppColors.grey80),
|
|
),
|
|
subtitle: Text(
|
|
[
|
|
timeConvertor(item.changedAt, hasTime: true),
|
|
if (item.description?.isNotEmpty == true)
|
|
item.description!,
|
|
if (item.reason?.isNotEmpty == true)
|
|
item.reason!,
|
|
].join(' · '),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
],
|
|
if (!submission.isTerminal) ...[
|
|
const SizedBox(height: 12),
|
|
if (busy)
|
|
const LinearProgressIndicator(color: AppColors.secondary50)
|
|
else
|
|
_Actions(submission: submission),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Actions extends StatelessWidget {
|
|
const _Actions({required this.submission});
|
|
|
|
final TenderSubmission submission;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final primary = <({String label, String status, String description})>[];
|
|
switch (submission.status) {
|
|
case 'opportunity':
|
|
primary.add((
|
|
label: 'Start validation',
|
|
status: 'validating',
|
|
description: 'User accepted tender',
|
|
));
|
|
case 'validating':
|
|
if (submission.submissionMode == 'partnership') {
|
|
primary.add((
|
|
label: 'Wait for partner',
|
|
status: 'partner_pending',
|
|
description: 'Validation completed; waiting for partner documents',
|
|
));
|
|
} else if (submission.submissionMode == 'self-apply') {
|
|
primary.add((
|
|
label: 'Start applying',
|
|
status: 'applying',
|
|
description: 'Validation completed',
|
|
));
|
|
} else {
|
|
primary.addAll([
|
|
(
|
|
label: 'Start applying',
|
|
status: 'applying',
|
|
description: 'Validation completed',
|
|
),
|
|
(
|
|
label: 'Wait for partner',
|
|
status: 'partner_pending',
|
|
description:
|
|
'Validation completed; waiting for partner documents',
|
|
),
|
|
]);
|
|
}
|
|
case 'partner_pending':
|
|
primary.add((
|
|
label: 'Partner docs ready',
|
|
status: 'applying',
|
|
description: 'Partner documents received',
|
|
));
|
|
case 'applying':
|
|
primary.add((
|
|
label: 'Submitted to buyer',
|
|
status: 'sent_waiting',
|
|
description: 'Application filed with procurement authority',
|
|
));
|
|
case 'sent_waiting':
|
|
primary.addAll([
|
|
(
|
|
label: 'Mark won',
|
|
status: 'contract',
|
|
description: 'Contract awarded',
|
|
),
|
|
(
|
|
label: 'Mark lost',
|
|
status: 'rejected',
|
|
description: 'Buyer rejected bid',
|
|
),
|
|
]);
|
|
}
|
|
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
for (final action in primary)
|
|
FilledButton(
|
|
onPressed:
|
|
() => _confirmUpdate(
|
|
context,
|
|
status: action.status,
|
|
label: action.label,
|
|
description: action.description,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.secondary50,
|
|
),
|
|
child: Text(action.label),
|
|
),
|
|
if (submission.status != 'sent_waiting')
|
|
OutlinedButton(
|
|
onPressed: () => _reject(context),
|
|
style: OutlinedButton.styleFrom(foregroundColor: AppColors.error),
|
|
child: const Text('Do not apply'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmUpdate(
|
|
BuildContext context, {
|
|
required String status,
|
|
required String label,
|
|
required String description,
|
|
}) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(dialogContext) => AlertDialog(
|
|
title: Text(label),
|
|
content: const Text('Update this tender submission?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: const Text('Confirm'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true && context.mounted) {
|
|
await context.read<YourTendersViewModel>().updateSubmissionStatus(
|
|
submission: submission,
|
|
status: status,
|
|
description: description,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _reject(BuildContext context) async {
|
|
final controller = TextEditingController();
|
|
final reason = await showDialog<String>(
|
|
context: context,
|
|
builder:
|
|
(dialogContext) => AlertDialog(
|
|
title: const Text('Do not apply'),
|
|
content: TextField(
|
|
controller: controller,
|
|
maxLength: 500,
|
|
maxLines: 3,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Reason (optional)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(dialogContext, controller.text),
|
|
child: const Text('Confirm'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
controller.dispose();
|
|
if (reason != null && context.mounted) {
|
|
await context.read<YourTendersViewModel>().updateSubmissionStatus(
|
|
submission: submission,
|
|
status: 'not_applied',
|
|
reason: reason,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
const _StatusBadge({required this.label, required this.status});
|
|
final String label;
|
|
final String status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final terminal = status == 'rejected' || status == 'not_applied';
|
|
final won = status == 'contract';
|
|
final color =
|
|
terminal
|
|
? AppColors.error
|
|
: (won ? AppColors.green30 : AppColors.blue10);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: .12),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Meta extends StatelessWidget {
|
|
const _Meta({required this.icon, required this.text});
|
|
final IconData icon;
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 16, color: AppColors.grey60),
|
|
const SizedBox(width: 4),
|
|
Text(text, style: TextStyle(color: AppColors.grey60, fontSize: 12)),
|
|
],
|
|
);
|
|
}
|