feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/ideas_provider.dart';
import '../../../models/idea.dart';
import '../../../core/utils/validators.dart';
class IdeaCreateScreen extends ConsumerStatefulWidget {
const IdeaCreateScreen({super.key});
@override
ConsumerState<IdeaCreateScreen> createState() => _IdeaCreateScreenState();
}
class _IdeaCreateScreenState extends ConsumerState<IdeaCreateScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Новая идея'),
actions: [
TextButton(
onPressed: state.isLoading
? null
: () async {
if (_formKey.currentState!.validate()) {
final success = await notifier.createIdea(
CreateIdeaRequest(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
),
);
if (success && mounted) context.pop();
}
},
child: state.isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Сохранить'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Название',
hintText: 'О чём ваша идея?',
),
validator: (v) => Validators.required(v, 'Название'),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Описание',
hintText: 'Подробно опишите идею...',
alignLabelWithHint: true,
),
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../providers/ideas_provider.dart';
import '../../../models/idea.dart';
class IdeaEditScreen extends ConsumerStatefulWidget {
final String id;
const IdeaEditScreen({super.key, required this.id});
@override
ConsumerState<IdeaEditScreen> createState() => _IdeaEditScreenState();
}
class _IdeaEditScreenState extends ConsumerState<IdeaEditScreen> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
bool _initialized = false;
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(ideasProvider.notifier).loadIdea(widget.id);
});
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
if (!_initialized && state.selectedIdea != null) {
_titleController.text = state.selectedIdea!.title;
_descriptionController.text = state.selectedIdea!.description;
_initialized = true;
}
return Scaffold(
appBar: AppBar(
title: const Text('Редактировать идею'),
actions: [
TextButton(
onPressed: state.isLoading
? null
: () async {
if (_formKey.currentState!.validate()) {
final success = await notifier.updateIdea(
widget.id,
UpdateIdeaRequest(
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
),
);
if (success && mounted) context.pop();
}
},
child: state.isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Сохранить'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Название'),
textCapitalization: TextCapitalization.sentences,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: 'Описание',
alignLabelWithHint: true,
),
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
),
],
),
),
),
);
}
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/ideas_provider.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../widgets/empty_state.dart';
import '../../../widgets/funnel_status_badge.dart';
import '../../../core/theme/app_theme.dart';
import '../widgets/funnel_kanban.dart';
class IdeaListScreen extends ConsumerWidget {
const IdeaListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(ideasProvider);
final notifier = ref.read(ideasProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Идеи'),
actions: [
IconButton(
icon: Icon(state.viewMode == 'list'
? Icons.view_kanban
: Icons.view_list),
onPressed: () {
notifier.setViewMode(
state.viewMode == 'list' ? 'kanban' : 'list');
},
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => context.push('/ideas/create'),
),
],
),
body: RefreshIndicator(
onRefresh: () => notifier.loadIdeas(),
child: _buildBody(context, state, notifier),
),
);
}
Widget _buildBody(
BuildContext context, IdeasState state, IdeasNotifier notifier) {
if (state.isLoading && state.ideas.isEmpty) {
return const ShimmerList();
}
if (state.error != null && state.ideas.isEmpty) {
return ErrorDisplay(
message: state.error!,
onRetry: () => notifier.loadIdeas(),
);
}
if (state.ideas.isEmpty) {
return EmptyState(
icon: Icons.lightbulb_outline,
title: 'Идей пока нет',
actionLabel: 'Создать',
onAction: () => context.push('/ideas/create'),
);
}
if (state.viewMode == 'kanban') {
return IdeaFunnelKanban(ideas: state.ideas);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.ideas.length,
itemBuilder: (_, i) {
final idea = state.ideas[i];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(idea.title, maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: Text(idea.description, maxLines: 2,
overflow: TextOverflow.ellipsis),
trailing: idea.funnelStatus != null
? FunnelStatusBadge(status: idea.funnelStatus)
: null,
onTap: () => context.push('/ideas/${idea.id}'),
),
).animate().fadeIn(duration: 200.ms, delay: (i * 50).ms);
},
);
}
}
@@ -0,0 +1,411 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../providers/ideas_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../models/idea.dart';
import '../../../models/collaboration.dart';
import '../../../widgets/vote_buttons.dart';
import '../../../widgets/comment_thread.dart';
import '../../../widgets/funnel_status_badge.dart';
import '../../../widgets/loading_indicator.dart';
import '../../../widgets/error_display.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/api/endpoints.dart';
import '../../../core/api/client.dart';
import '../../../providers/disk_provider.dart';
class IdeaViewScreen extends ConsumerStatefulWidget {
final String id;
const IdeaViewScreen({super.key, required this.id});
@override
ConsumerState<IdeaViewScreen> createState() => _IdeaViewScreenState();
}
class _IdeaViewScreenState extends ConsumerState<IdeaViewScreen> {
final _commentController = TextEditingController();
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(ideasProvider.notifier).loadIdea(widget.id);
ref.read(ideaCommentsProvider(widget.id).notifier).loadComments();
ref.read(ideaActivityProvider(widget.id).notifier).loadActivities();
});
}
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
void _showPresentationOptions(BuildContext context) {
final diskState = ref.read(diskProvider);
final hasDrive = diskState.connectedProviders.isNotEmpty;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Создать презентацию',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.download, color: VoIdeaColors.primary),
title: const Text('Скачать HTML'),
subtitle: const Text('Откроется в браузере'),
onTap: () {
Navigator.pop(ctx);
_generateAndDownload();
},
),
if (hasDrive)
...diskState.connectedProviders.map((p) {
final provider = p['provider'] as String? ?? '';
return ListTile(
leading: Icon(_providerIcon(provider),
color: VoIdeaColors.success),
title: Text('Отправить на ${_providerLabel(provider)}'),
subtitle: Text(p['email'] ?? ''),
onTap: () {
Navigator.pop(ctx);
_generateAndUpload(provider);
},
);
}),
if (!hasDrive)
const Padding(
padding: EdgeInsets.all(12),
child: Text(
'Подключите облачный диск в настройках, чтобы сохранять презентации в облако',
style: TextStyle(fontSize: 12, color: VoIdeaColors.muted),
textAlign: TextAlign.center,
),
),
],
),
),
),
);
}
IconData _providerIcon(String provider) {
switch (provider) {
case 'google': return Icons.g_mobiledata;
case 'yandex': return Icons.cloud;
case 'apple': return Icons.apple;
default: return Icons.cloud_outlined;
}
}
String _providerLabel(String provider) {
switch (provider) {
case 'google': return 'Google Диск';
case 'yandex': return 'Яндекс Диск';
case 'apple': return 'iCloud';
default: return provider;
}
}
Future<void> _generateAndDownload() async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=download',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Презентация скачана')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка: $e')),
);
}
}
}
Future<void> _generateAndUpload(String provider) async {
try {
final api = ref.read(apiClientProvider);
final resp = await api.post(
'${Endpoints.ideaPresentation(widget.id)}?destination=drive&drive_provider=$provider',
);
if (mounted) {
final data = resp.data as Map<String, dynamic>;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Презентация отправлена на ${_providerLabel(provider)}'),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ошибка загрузки: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
final ideaState = ref.watch(ideasProvider);
final commentsState = ref.watch(ideaCommentsProvider(widget.id));
final activityState = ref.watch(ideaActivityProvider(widget.id));
final authState = ref.watch(authProvider);
final ideasNotifier = ref.read(ideasProvider.notifier);
final commentsNotifier =
ref.read(ideaCommentsProvider(widget.id).notifier);
final currentUserId = authState.user?.id ?? '';
final idea = ideaState.selectedIdea;
if (ideaState.isLoading && idea == null) {
return Scaffold(
appBar: AppBar(),
body: const LoadingIndicator(),
);
}
if (idea == null) {
return Scaffold(
appBar: AppBar(),
body: ErrorDisplay(
message: ideaState.error ?? 'Идея не найдена',
onRetry: () =>
ref.read(ideasProvider.notifier).loadIdea(widget.id),
),
);
}
final funnelIndex = AppConstants.funnelStates.indexOf(idea.funnelStatus ?? '');
final prevStatus = funnelIndex > 0
? AppConstants.funnelStates[funnelIndex - 1]
: null;
final nextStatus = funnelIndex < AppConstants.funnelStates.length - 1
? AppConstants.funnelStates[funnelIndex + 1]
: null;
return Scaffold(
appBar: AppBar(
title: const Text('Идея'),
actions: [
if (idea.userId == currentUserId)
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => context.push('/ideas/${widget.id}/edit'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
idea.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (idea.funnelStatus != null)
FunnelStatusBadge(status: idea.funnelStatus),
],
).animate().fadeIn(),
const SizedBox(height: 8),
Row(
children: [
Text(
idea.userName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
const SizedBox(width: 16),
Text(
DateFormat('d MMM yyyy').format(idea.createdAt),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: VoIdeaColors.muted,
),
),
],
).animate().fadeIn(delay: 100.ms),
const SizedBox(height: 16),
if (idea.description.isNotEmpty)
Text(
idea.description,
style: Theme.of(context).textTheme.bodyLarge,
).animate().fadeIn(delay: 200.ms),
const SizedBox(height: 24),
Row(
children: [
VoteButtons(
upvotes: idea.upvotes,
downvotes: idea.downvotes,
onUpvote: () =>
commentsNotifier.toggleVote(widget.id),
onDownvote: () =>
commentsNotifier.toggleVote(widget.id),
),
const Spacer(),
if (prevStatus != null)
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, prevStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[prevStatus]}',
),
if (nextStatus != null)
IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () =>
ideasNotifier.updateFunnel(widget.id, nextStatus),
tooltip: 'Переместить в ${AppConstants.funnelLabels[nextStatus]}',
),
],
).animate().fadeIn(delay: 300.ms),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.slideshow, size: 18),
label: const Text('Презентация'),
onPressed: () => _showPresentationOptions(context),
),
),
],
),
const Divider(height: 32),
Text(
'Комментарии',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _commentController,
decoration: const InputDecoration(
hintText: 'Напишите комментарий...',
isDense: true,
),
textCapitalization: TextCapitalization.sentences,
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
color: VoIdeaColors.primary,
onPressed: () {
if (_commentController.text.isNotEmpty) {
commentsNotifier
.addComment(_commentController.text.trim())
.then((_) =>
_commentController.clear());
}
},
),
],
),
const SizedBox(height: 16),
if (commentsState.isLoading)
const LoadingIndicator()
else
CommentThread(
comments: commentsState.comments,
currentUserId: currentUserId,
onAddComment: (body, {parentId}) =>
commentsNotifier.addComment(body, parentId: parentId),
).animate().fadeIn(delay: 500.ms),
const Divider(height: 32),
Text(
'Активность',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
).animate().fadeIn(delay: 600.ms),
const SizedBox(height: 12),
if (activityState.isLoading)
const LoadingIndicator()
else
...activityState.activities.map(
(a) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor:
VoIdeaColors.primary.withOpacity(0.1),
child: Text(
a.userName.isNotEmpty
? a.userName[0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 10,
color: VoIdeaColors.primary),
),
),
const SizedBox(width: 8),
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: a.userName,
style: const TextStyle(
fontWeight: FontWeight.w500),
),
TextSpan(
text: ' ${a.action}',
),
if (a.description != null)
TextSpan(
text: ': ${a.description}',
style: TextStyle(
color: VoIdeaColors.muted),
),
],
),
style: const TextStyle(fontSize: 13),
),
),
Text(
DateFormat('HH:mm').format(a.createdAt),
style: const TextStyle(
fontSize: 11, color: VoIdeaColors.muted),
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../models/idea.dart';
import '../../../providers/ideas_provider.dart';
import '../../../core/constants/app_constants.dart';
import '../../../core/theme/app_theme.dart';
import '../../../widgets/funnel_status_badge.dart';
class IdeaFunnelKanban extends ConsumerWidget {
final List<Idea> ideas;
const IdeaFunnelKanban({super.key, required this.ideas});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(ideasProvider.notifier);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: AppConstants.funnelStates.map((status) {
final columnIdeas =
ideas.where((i) => i.funnelStatus == status).toList();
final color = AppConstants.funnelColors[status]!;
final label = AppConstants.funnelLabels[status]!;
return Container(
width: 280,
margin: const EdgeInsets.all(8),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(label,
style: TextStyle(
fontWeight: FontWeight.w600, color: color)),
const Spacer(),
Text(
'${columnIdeas.length}',
style: TextStyle(color: color, fontSize: 12),
),
],
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: VoIdeaColors.surface,
border: Border.all(color: color.withOpacity(0.2)),
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(12)),
),
child: columnIdeas.isEmpty
? Center(
child: Text('Нет идей',
style: TextStyle(
color: VoIdeaColors.muted, fontSize: 12)),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: columnIdeas.length,
itemBuilder: (_, i) {
final idea = columnIdeas[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
context.push('/ideas/${idea.id}'),
onLongPress: () =>
_showKanbanContextMenu(context, ref, idea),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
idea.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_iconText(
Icons.thumb_up_outlined,
idea.upvotes.toString()),
const SizedBox(width: 8),
_iconText(
Icons.comment_outlined,
idea.commentCount
.toString()),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (i > 0 ||
AppConstants.funnelStates
.indexOf(status) >
0)
InkWell(
onTap: () => notifier
.updateFunnel(
idea.id,
AppConstants
.funnelStates[
AppConstants
.funnelStates
.indexOf(
status) -
1]),
child: const Icon(
Icons.chevron_left,
size: 20),
),
InkWell(
onTap: () => notifier
.updateFunnel(
idea.id,
AppConstants
.funnelStates[
AppConstants
.funnelStates
.indexOf(
status) +
1]),
child: const Icon(
Icons.chevron_right,
size: 20),
),
],
),
],
),
],
),
),
),
).animate().fadeIn(
duration: 200.ms,
delay: (i * 30).ms);
},
),
),
),
],
),
);
}).toList(),
),
);
}
Widget _iconText(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: VoIdeaColors.muted),
const SizedBox(width: 2),
Text(text,
style:
const TextStyle(fontSize: 11, color: VoIdeaColors.muted)),
],
);
}
void _showKanbanContextMenu(BuildContext context, WidgetRef ref, Idea idea) {
final notifier = ref.read(ideasProvider.notifier);
final funnelIdx = AppConstants.funnelStates.indexOf(idea.funnelStatus ?? '');
final canMoveLeft = funnelIdx > 0;
final canMoveRight =
funnelIdx < AppConstants.funnelStates.length - 1;
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.slideshow, color: VoIdeaColors.primary),
title: const Text('Создать презентацию'),
onTap: () {
Navigator.pop(ctx);
context.push('/ideas/${idea.id}');
},
),
if (canMoveLeft)
ListTile(
leading: const Icon(Icons.arrow_back, color: VoIdeaColors.warning),
title: Text(
'Переместить в "${AppConstants.funnelLabels[AppConstants.funnelStates[funnelIdx - 1]]}"'),
onTap: () {
Navigator.pop(ctx);
notifier.updateFunnel(idea.id, AppConstants.funnelStates[funnelIdx - 1]);
},
),
if (canMoveRight)
ListTile(
leading: const Icon(Icons.arrow_forward, color: VoIdeaColors.success),
title: Text(
'Переместить в "${AppConstants.funnelLabels[AppConstants.funnelStates[funnelIdx + 1]]}"'),
onTap: () {
Navigator.pop(ctx);
notifier.updateFunnel(idea.id, AppConstants.funnelStates[funnelIdx + 1]);
},
),
],
),
),
),
);
}
}