590 lines
20 KiB
Dart
590 lines
20 KiB
Dart
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),
|
||
],
|
||
);
|
||
}
|
||
}
|