Files
fregat/flutter-frontend/lib/modules/client/client_module.dart
T

590 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/app_state.dart';
import '../../domain/entities.dart';
import '../../graphql/__generated__/schema.schema.gql.dart';
import '../../graphql/operations/client/__generated__/client_products.data.gql.dart';
import '../../graphql/operations/client/__generated__/my_cart.data.gql.dart';
import '../../graphql/operations/client/__generated__/my_orders.data.gql.dart';
import '../../ui/common.dart';
class ClientDashboardScreen extends ConsumerWidget {
const ClientDashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider).asData?.value;
final cart = ref.watch(myCartProvider).asData?.value;
final orders = ref.watch(myOrdersProvider).asData?.value ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ScreenTitle(title: 'Сводка клиента'),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
MetricTile(
label: me?.company?.name ?? 'Клиент',
value: me?.company?.inn ?? '-',
icon: Icons.account_balance_wallet_outlined,
),
MetricTile(
label: 'Позиции в корзине',
value: '${cart?.items.length ?? 0}',
icon: Icons.assignment_outlined,
),
MetricTile(
label: 'Заказы',
value: '${orders.length}',
icon: Icons.local_shipping_outlined,
),
const MetricTile(
label: 'Основной канал',
value: 'Email-код',
icon: Icons.mail_outline,
),
],
),
const SizedBox(height: 24),
_TwoColumn(
left: _ListBlock(
title: 'Корзина',
children: [
for (final item
in cart?.items ?? const <GMyCartData_myCart_items>[])
EntityCard(
title: item.productName,
subtitle:
'${item.sku} · ${item.quantity.toStringAsFixed(0)} шт.',
trailing: const StatusPill('В заявке'),
),
],
),
right: _ListBlock(
title: 'Заказы',
children: [
for (final order in orders)
EntityCard(
title: order.code,
subtitle:
'${orderMoney(order.totalPrice)} · обновлено ${shortDate(order.updatedAt.value)}',
trailing: StatusPill(orderStatusLabel(order.status)),
),
],
),
),
],
);
}
}
class ClientConstructorScreen extends ConsumerWidget {
const ClientConstructorScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(clientProductsProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ScreenTitle(title: 'Создать заявку'),
products.when(
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Загрузка каталога...'),
),
),
error: (error, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(error.toString()),
),
),
data: (items) => Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final product in groupClientProducts(items))
SizedBox(
width: 340,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.type,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text('${product.products.length} поз.'),
const Divider(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final parameter in product.parameters)
StatusPill(parameter),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () {
final first = product.products.first;
ref
.read(graphqlRepositoryProvider)
.addProductToCart(first.id)
.then((_) {
ref.invalidate(myCartProvider);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Позиция добавлена в корзину заявки',
),
),
);
},
icon: const Icon(Icons.add),
label: const Text('В корзину'),
),
],
),
),
),
),
],
),
),
],
);
}
}
class ClientProductGroup {
const ClientProductGroup({required this.type, required this.products});
final String type;
final List<GClientProductsData_clientProducts> products;
List<String> get parameters {
final result = <String>[];
if (products.any((item) => item.widthMm != null)) result.add('ширина');
if (products.any((item) => item.lengthM != null)) result.add('длина');
if (products.any((item) => item.thicknessMicron != null)) {
result.add('толщина');
}
if (products.any(
(item) => normalizeClientText(item.sleeveBrand).isNotEmpty,
)) {
result.add('втулка');
}
if (products.any(
(item) => normalizeClientText(item.quantityPerBox).isNotEmpty,
)) {
result.add('короб');
}
return result;
}
}
List<ClientProductGroup> groupClientProducts(
List<GClientProductsData_clientProducts> products,
) {
final grouped = <String, List<GClientProductsData_clientProducts>>{};
for (final product in products) {
final type = normalizeClientText(product.productType);
grouped
.putIfAbsent(type.isEmpty ? 'Без типа' : type, () => [])
.add(product);
}
final result = grouped.entries
.map(
(entry) => ClientProductGroup(type: entry.key, products: entry.value),
)
.toList();
result.sort((a, b) => a.type.compareTo(b.type));
return result;
}
String normalizeClientText(String? value) {
return (value ?? '').replaceAll(RegExp(r'\s+'), ' ').trim();
}
String clientProductParameters(GClientProductsData_clientProducts product) {
final parts = [
if (product.widthMm != null) '${product.widthMm} мм',
if (product.lengthM != null) '${product.lengthM} м',
if (product.thicknessMicron != null) '${product.thicknessMicron} мкм',
if (normalizeClientText(product.sleeveBrand).isNotEmpty)
product.sleeveBrand!,
];
return parts.join(', ');
}
class ClientCartScreen extends ConsumerWidget {
const ClientCartScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cart = ref.watch(myCartProvider);
return cart.when(
loading: () => const _LoadingBlock(title: 'Корзина заявки'),
error: (error, _) => _ErrorBlock(title: 'Корзина заявки', error: error),
data: (data) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScreenTitle(
title: 'Корзина заявки',
action: FilledButton.icon(
onPressed: data.items.isEmpty
? null
: () async {
await ref
.read(graphqlRepositoryProvider)
.submitReadyOrder(data.items.toList());
ref
..invalidate(myCartProvider)
..invalidate(myOrdersProvider);
if (context.mounted) navigateToPath(ref, '/orders');
},
icon: const Icon(Icons.send_outlined),
label: const Text('Отправить'),
),
),
if (data.items.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(20),
child: Text('Корзина пуста'),
),
)
else
Card(
child: Column(
children: [
for (final item in data.items)
ListTile(
title: Text(item.productName),
subtitle: Text(item.sku),
trailing: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
IconButton(
onPressed: () async {
await ref
.read(graphqlRepositoryProvider)
.updateCartItemQuantity(
productId: item.productId,
quantity: item.quantity - 1,
);
ref.invalidate(myCartProvider);
},
icon: const Icon(Icons.remove),
),
Text(item.quantity.toStringAsFixed(0)),
IconButton(
onPressed: () async {
await ref
.read(graphqlRepositoryProvider)
.updateCartItemQuantity(
productId: item.productId,
quantity: item.quantity + 1,
);
ref.invalidate(myCartProvider);
},
icon: const Icon(Icons.add),
),
],
),
),
const Divider(height: 1),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () async {
await ref.read(graphqlRepositoryProvider).clearCart();
ref.invalidate(myCartProvider);
},
icon: const Icon(Icons.delete_outline),
label: const Text('Очистить'),
),
),
],
),
),
],
),
);
}
}
class ClientRequestsScreen extends ConsumerWidget {
const ClientRequestsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final orders = ref.watch(myOrdersProvider);
return orders.when(
loading: () => const _LoadingBlock(title: 'Мои заявки'),
error: (error, _) => _ErrorBlock(title: 'Мои заявки', error: error),
data: (items) {
final requests = items
.where((item) => item.kind == GOrderKind.CALCULATION)
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ScreenTitle(title: 'Мои заявки'),
for (final request in requests)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: EntityCard(
title: request.code,
subtitle:
'${orderMoney(request.totalPrice)} · ${shortDate(request.updatedAt.value)}',
trailing: StatusPill(orderStatusLabel(request.status)),
onTap: () => navigateToPath(ref, '/orders/${request.id}'),
),
),
if (requests.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Заявок пока нет'),
),
),
],
);
},
);
}
}
class ClientOrdersScreen extends ConsumerWidget {
const ClientOrdersScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final orders = ref.watch(myOrdersProvider);
return orders.when(
loading: () => const _LoadingBlock(title: 'Заказы'),
error: (error, _) => _ErrorBlock(title: 'Заказы', error: error),
data: (items) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ScreenTitle(title: 'Заказы'),
for (final order in items)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: EntityCard(
title: order.code,
subtitle:
'${orderMoney(order.totalPrice)} · ${shortDate(order.updatedAt.value)}',
trailing: StatusPill(orderStatusLabel(order.status)),
onTap: () => navigateToPath(ref, '/orders/${order.id}'),
),
),
if (items.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Заказов пока нет'),
),
),
],
),
);
}
}
class ClientProfileScreen extends ConsumerWidget {
const ClientProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider).asData?.value;
final connections =
ref.watch(myMessengerConnectionsProvider).asData?.value ?? const [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ScreenTitle(title: 'Профиль'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
ListTile(
title: Text(me?.company?.name ?? me?.fullName ?? 'Профиль'),
subtitle: Text('ИНН ${me?.company?.inn ?? '-'}'),
),
ListTile(
title: const Text('Email для входа'),
subtitle: Text(me?.email ?? '-'),
trailing: const StatusPill('6-значный код'),
),
for (final connection in connections)
ListTile(
title: Text(connection.type.name),
subtitle: Text(
connection.displayName ?? connection.channelId,
),
trailing: StatusPill(
connection.isActive ? 'Активен' : 'Отключен',
),
),
],
),
),
),
],
);
}
}
String orderStatusLabel(GOrderStatus status) {
if (status == GOrderStatus.NEW) return 'Новый';
if (status == GOrderStatus.MANAGER_PROCESSING) return 'В работе';
if (status == GOrderStatus.WAITING_DOUBLE_CONFIRM) return 'На согласовании';
if (status == GOrderStatus.CLIENT_REJECTED) return 'Отклонен клиентом';
if (status == GOrderStatus.MANAGER_REJECTED) return 'Отклонен менеджером';
if (status == GOrderStatus.MANAGER_BLOCKED) return 'Заблокирован';
if (status == GOrderStatus.CONFIRMED) return 'Подтвержден';
if (status == GOrderStatus.IN_PROGRESS) return 'В производстве';
if (status == GOrderStatus.COMPLETED) return 'Завершен';
return status.name;
}
String orderKindLabel(GOrderKind kind) {
if (kind == GOrderKind.READY) return 'Заказ';
if (kind == GOrderKind.CALCULATION) return 'Заявка';
return kind.name;
}
String orderMoney(double? value) {
if (value == null) return '-';
return money(value.round());
}
String shortDate(String value) =>
value.length >= 10 ? value.substring(0, 10) : value;
List<RequestDraftItem> orderItemsToDraftItems(
Iterable<GMyOrdersData_myOrders_items> items,
) {
return [
for (final item in items)
RequestDraftItem(
productTitle: item.productName,
quantity: item.quantity.round(),
parameters: item.unitPrice == null
? '-'
: '${orderMoney(item.unitPrice)} за шт.',
comment: item.lineTotal == null
? ''
: 'Итого ${orderMoney(item.lineTotal)}',
),
];
}
class _LoadingBlock extends StatelessWidget {
const _LoadingBlock({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScreenTitle(title: title),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
),
],
);
}
}
class _ErrorBlock extends StatelessWidget {
const _ErrorBlock({required this.title, required this.error});
final String title;
final Object error;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScreenTitle(title: title),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(error.toString()),
),
),
],
);
}
}
class _TwoColumn extends StatelessWidget {
const _TwoColumn({required this.left, required this.right});
final Widget left;
final Widget right;
@override
Widget build(BuildContext context) {
final wide = MediaQuery.sizeOf(context).width >= 860;
if (!wide) {
return Column(children: [left, const SizedBox(height: 16), right]);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: left),
const SizedBox(width: 16),
Expanded(child: right),
],
);
}
}
class _ListBlock extends StatelessWidget {
const _ListBlock({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
for (final child in children)
Padding(padding: const EdgeInsets.only(bottom: 8), child: child),
],
);
}
}