diff --git a/frontend/pshared/lib/api/responses/invitations.dart b/frontend/pshared/lib/api/responses/invitations.dart new file mode 100644 index 0000000..911850e --- /dev/null +++ b/frontend/pshared/lib/api/responses/invitations.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/base.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/invitation/invitation.dart'; + +part 'invitations.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class InvitationsResponse extends BaseAuthorizedResponse { + final List invitations; + + const InvitationsResponse({ + required super.accessToken, + required this.invitations, + }); + + factory InvitationsResponse.fromJson(Map json) => _$InvitationsResponseFromJson(json); + @override + Map toJson() => _$InvitationsResponseToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/invitation/invitation.dart b/frontend/pshared/lib/data/dto/invitation/invitation.dart new file mode 100644 index 0000000..da63a80 --- /dev/null +++ b/frontend/pshared/lib/data/dto/invitation/invitation.dart @@ -0,0 +1,56 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/data/dto/date_time.dart'; +import 'package:pshared/data/dto/permissions/bound.dart'; + +part 'invitation.g.dart'; + +@JsonSerializable() +class InvitationContentDTO { + final String email; + final String name; + final String comment; + + const InvitationContentDTO({ + required this.email, + required this.name, + required this.comment, + }); + + factory InvitationContentDTO.fromJson(Map json) => _$InvitationContentDTOFromJson(json); + Map toJson() => _$InvitationContentDTOToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class InvitationDTO extends PermissionBoundDTO { + final String roleRef; + final String inviterRef; + final String status; + + @UtcIso8601Converter() + final DateTime expiresAt; + + @JsonKey(name: 'description') + final InvitationContentDTO content; + + @JsonKey(defaultValue: false) + final bool isArchived; + + const InvitationDTO({ + required super.id, + required super.createdAt, + required super.updatedAt, + required super.permissionRef, + required super.organizationRef, + required this.roleRef, + required this.inviterRef, + required this.status, + required this.expiresAt, + required this.content, + this.isArchived = false, + }); + + factory InvitationDTO.fromJson(Map json) => _$InvitationDTOFromJson(json); + @override + Map toJson() => _$InvitationDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/mapper/invitation/invitation.dart b/frontend/pshared/lib/data/mapper/invitation/invitation.dart new file mode 100644 index 0000000..85d2265 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/invitation/invitation.dart @@ -0,0 +1,78 @@ +import 'package:pshared/data/dto/invitation/invitation.dart'; +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/models/invitation/status.dart'; +import 'package:pshared/models/organization/bound.dart'; +import 'package:pshared/models/permissions/bound.dart'; +import 'package:pshared/models/storable.dart'; + + +extension InvitationModelMapper on Invitation { + InvitationDTO toDTO() => InvitationDTO( + id: storable.id, + createdAt: storable.createdAt, + updatedAt: storable.updatedAt, + permissionRef: permissionBound.permissionRef, + organizationRef: permissionBound.organizationRef, + roleRef: roleRef, + inviterRef: inviterRef, + status: _statusToValue(status), + expiresAt: expiresAt, + content: InvitationContentDTO( + email: content.email, + name: content.name, + comment: content.comment, + ), + isArchived: isArchived, + ); +} + +extension InvitationDTOMapper on InvitationDTO { + Invitation toDomain() => Invitation( + storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt), + permissionBound: newPermissionBound( + organizationBound: newOrganizationBound(organizationRef: organizationRef), + permissionRef: permissionRef, + ), + roleRef: roleRef, + inviterRef: inviterRef, + status: _statusFromValue(status), + expiresAt: expiresAt.toUtc(), + content: InvitationContent( + email: content.email, + name: content.name, + comment: content.comment, + ), + isArchived: isArchived, + ); +} + +InvitationStatus _statusFromValue(String value) { + switch (value) { + case 'sent': + return InvitationStatus.sent; + case 'accepted': + return InvitationStatus.accepted; + case 'declined': + return InvitationStatus.declined; + case 'revoked': + return InvitationStatus.revoked; + case 'created': + default: + return InvitationStatus.created; + } +} + +String _statusToValue(InvitationStatus status) { + switch (status) { + case InvitationStatus.sent: + return 'sent'; + case InvitationStatus.accepted: + return 'accepted'; + case InvitationStatus.declined: + return 'declined'; + case InvitationStatus.revoked: + return 'revoked'; + case InvitationStatus.created: + return 'created'; + } +} diff --git a/frontend/pshared/lib/models/invitation/invitation.dart b/frontend/pshared/lib/models/invitation/invitation.dart new file mode 100644 index 0000000..f613f5f --- /dev/null +++ b/frontend/pshared/lib/models/invitation/invitation.dart @@ -0,0 +1,111 @@ +import 'package:pshared/models/organization/bound.dart'; +import 'package:pshared/models/permissions/bound.dart'; +import 'package:pshared/models/permissions/bound/storable.dart'; +import 'package:pshared/models/storable.dart'; +import 'package:pshared/models/invitation/status.dart'; + + +class InvitationContent { + final String email; + final String name; + final String comment; + + const InvitationContent({ + required this.email, + required this.name, + required this.comment, + }); + + InvitationContent copyWith({ + String? email, + String? name, + String? comment, + }) => InvitationContent( + email: email ?? this.email, + name: name ?? this.name, + comment: comment ?? this.comment, + ); +} + +class Invitation implements PermissionBoundStorable { + final Storable storable; + final PermissionBound permissionBound; + final String roleRef; + final String inviterRef; + final InvitationStatus status; + final DateTime expiresAt; + final InvitationContent content; + final bool isArchived; + + Invitation({ + required this.storable, + required this.permissionBound, + required this.roleRef, + required this.inviterRef, + required this.status, + required this.expiresAt, + required this.content, + this.isArchived = false, + }); + + @override + String get id => storable.id; + @override + DateTime get createdAt => storable.createdAt; + @override + DateTime get updatedAt => storable.updatedAt; + + @override + String get permissionRef => permissionBound.permissionRef; + @override + String get organizationRef => permissionBound.organizationRef; + + String get inviteeDisplayName => content.name.isNotEmpty ? content.name : content.email; + bool get isExpired => expiresAt.isBefore(DateTime.now().toUtc()); + bool get isPending => status == InvitationStatus.created || status == InvitationStatus.sent; + + Invitation copyWith({ + Storable? storable, + PermissionBound? permissionBound, + String? roleRef, + String? inviterRef, + InvitationStatus? status, + DateTime? expiresAt, + InvitationContent? content, + bool? isArchived, + }) => Invitation( + storable: storable ?? this.storable, + permissionBound: permissionBound ?? this.permissionBound, + roleRef: roleRef ?? this.roleRef, + inviterRef: inviterRef ?? this.inviterRef, + status: status ?? this.status, + expiresAt: expiresAt ?? this.expiresAt, + content: content ?? this.content, + isArchived: isArchived ?? this.isArchived, + ); +} + +Invitation newInvitation({ + required String organizationRef, + required String roleRef, + required String inviterRef, + required String email, + String name = '', + String comment = '', + InvitationStatus status = InvitationStatus.created, + DateTime? expiresAt, + bool isArchived = false, + String? permissionRef, +}) => Invitation( + storable: newStorable(), + permissionBound: newPermissionBound( + organizationBound: newOrganizationBound(organizationRef: organizationRef), + permissionRef: permissionRef, + ), + roleRef: roleRef, + inviterRef: inviterRef, + status: status, + expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(days: 7)), + content: InvitationContent(email: email, name: name, comment: comment), + isArchived: isArchived, +); diff --git a/frontend/pshared/lib/models/invitation/status.dart b/frontend/pshared/lib/models/invitation/status.dart new file mode 100644 index 0000000..b4fb55a --- /dev/null +++ b/frontend/pshared/lib/models/invitation/status.dart @@ -0,0 +1,7 @@ +enum InvitationStatus { + created, + sent, + accepted, + declined, + revoked, +} diff --git a/frontend/pshared/lib/provider/invitations.dart b/frontend/pshared/lib/provider/invitations.dart new file mode 100644 index 0000000..74ffe4e --- /dev/null +++ b/frontend/pshared/lib/provider/invitations.dart @@ -0,0 +1,64 @@ +import 'package:pshared/data/mapper/invitation/invitation.dart'; +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/models/invitation/status.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/template.dart'; +import 'package:pshared/service/invitation/service.dart'; + + +class InvitationsProvider extends GenericProvider { + InvitationsProvider() : super(service: InvitationService.basicService); + + late OrganizationsProvider _organizations; + String? _loadedOrganizationId; + + List get invitations => List.unmodifiable(items); + + void updateProviders(OrganizationsProvider organizations) { + _organizations = organizations; + if (_organizations.isOrganizationSet) { + final organizationId = _organizations.current.id; + if (_loadedOrganizationId != organizationId) { + _loadedOrganizationId = organizationId; + load(organizationId, organizationId); + } + } + } + + Future sendInvitation({ + required String email, + required String roleRef, + required String inviterRef, + String name = '', + String comment = '', + DateTime? expiresAt, + }) async { + final invitation = newInvitation( + organizationRef: _organizations.current.id, + roleRef: roleRef, + inviterRef: inviterRef, + email: email, + name: name, + comment: comment, + expiresAt: expiresAt, + ); + + return createObject(_organizations.current.id, invitation.toDTO().toJson()); + } + + Future updateInvitation(Invitation invitation) { + return update(invitation.toDTO().toJson()); + } + + Future revokeInvitation(Invitation invitation) { + return updateInvitation(invitation.copyWith(status: InvitationStatus.revoked)); + } + + Future setInvitationArchived(Invitation invitation, bool archived) { + return setArchived( + organizationRef: _organizations.current.id, + objectRef: invitation.id, + newIsArchived: archived, + ); + } +} diff --git a/frontend/pshared/lib/service/invitation/service.dart b/frontend/pshared/lib/service/invitation/service.dart new file mode 100644 index 0000000..faa7710 --- /dev/null +++ b/frontend/pshared/lib/service/invitation/service.dart @@ -0,0 +1,45 @@ +import 'package:pshared/api/responses/invitations.dart'; +import 'package:pshared/data/mapper/invitation/invitation.dart'; +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/service/services.dart'; +import 'package:pshared/service/template.dart'; + + +class InvitationService { + static const String _objectType = Services.invitations; + + static final BasicService _basicService = BasicService( + objectType: _objectType, + fromJson: (json) => InvitationsResponse.fromJson(json).invitations.map((dto) => dto.toDomain()).toList(), + ); + + static BasicService get basicService => _basicService; + + static Future> list(String organizationRef, String parentRef) { + return _basicService.list(organizationRef, parentRef); + } + + static Future> create(String organizationRef, Invitation invitation) { + return _basicService.create(organizationRef, invitation.toDTO().toJson()); + } + + static Future> update(Invitation invitation) { + return _basicService.update(invitation.toDTO().toJson()); + } + + static Future> delete(Invitation invitation) { + return _basicService.delete(invitation.id); + } + + static Future> archive({ + required String organizationRef, + required Invitation invitation, + required bool archived, + }) { + return _basicService.archive( + organizationRef: organizationRef, + objectRef: invitation.id, + newIsArchived: archived, + ); + } +} diff --git a/frontend/pweb/lib/app/router/payout_routes.dart b/frontend/pweb/lib/app/router/payout_routes.dart index 95c3038..508acdd 100644 --- a/frontend/pweb/lib/app/router/payout_routes.dart +++ b/frontend/pweb/lib/app/router/payout_routes.dart @@ -13,6 +13,7 @@ class PayoutRoutes { static const dashboard = 'dashboard'; static const sendPayout = payment; static const recipients = 'payout-recipients'; + static const invitations = 'payout-invitations'; static const addRecipient = 'payout-add-recipient'; static const payment = 'payout-payment'; static const settings = 'payout-settings'; @@ -26,6 +27,7 @@ class PayoutRoutes { static const dashboardPath = '/dashboard'; static const recipientsPath = '/dashboard/recipients'; + static const invitationsPath = '/dashboard/invitations'; static const addRecipientPath = '/dashboard/recipients/add'; static const paymentPath = '/dashboard/payment'; static const settingsPath = '/dashboard/settings'; @@ -39,14 +41,16 @@ class PayoutRoutes { case PayoutDestination.dashboard: return dashboard; case PayoutDestination.sendPayout: - return payment; - case PayoutDestination.recipients: - return recipients; - case PayoutDestination.addrecipient: - return addRecipient; - case PayoutDestination.payment: - return payment; - case PayoutDestination.settings: + return payment; + case PayoutDestination.recipients: + return recipients; + case PayoutDestination.invitations: + return invitations; + case PayoutDestination.addrecipient: + return addRecipient; + case PayoutDestination.payment: + return payment; + case PayoutDestination.settings: return settings; case PayoutDestination.reports: return reports; @@ -64,14 +68,16 @@ class PayoutRoutes { case PayoutDestination.dashboard: return dashboardPath; case PayoutDestination.sendPayout: - return paymentPath; - case PayoutDestination.recipients: - return recipientsPath; - case PayoutDestination.addrecipient: - return addRecipientPath; - case PayoutDestination.payment: - return paymentPath; - case PayoutDestination.settings: + return paymentPath; + case PayoutDestination.recipients: + return recipientsPath; + case PayoutDestination.invitations: + return invitationsPath; + case PayoutDestination.addrecipient: + return addRecipientPath; + case PayoutDestination.payment: + return paymentPath; + case PayoutDestination.settings: return settingsPath; case PayoutDestination.reports: return reportsPath; @@ -89,13 +95,15 @@ class PayoutRoutes { case dashboard: return PayoutDestination.dashboard; case sendPayout: - return PayoutDestination.payment; - case recipients: - return PayoutDestination.recipients; - case addRecipient: - return PayoutDestination.addrecipient; - case settings: - return PayoutDestination.settings; + return PayoutDestination.payment; + case recipients: + return PayoutDestination.recipients; + case invitations: + return PayoutDestination.invitations; + case addRecipient: + return PayoutDestination.addrecipient; + case settings: + return PayoutDestination.settings; case reports: return PayoutDestination.reports; case methods: @@ -174,4 +182,4 @@ extension PayoutNavigation on BuildContext { PayoutRoutes.editWallet, queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo), ); -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 7662df4..12834f8 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -14,6 +14,7 @@ import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/address_book/form/page.dart'; import 'package:pweb/pages/address_book/page/page.dart'; import 'package:pweb/pages/dashboard/dashboard.dart'; +import 'package:pweb/pages/invitations/page.dart'; import 'package:pweb/pages/payment_methods/page.dart'; import 'package:pweb/pages/payout_page/page.dart'; import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; @@ -86,6 +87,13 @@ RouteBase payoutShellRoute() => ShellRoute( ); }, ), + GoRoute( + name: PayoutRoutes.invitations, + path: PayoutRoutes.invitationsPath, + pageBuilder: (_, __) => const NoTransitionPage( + child: InvitationsPage(), + ), + ), GoRoute( name: PayoutRoutes.addRecipient, path: PayoutRoutes.addRecipientPath, diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 91d4d02..4d45768 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -49,7 +49,7 @@ "errorVerificationTokenNotFound": "Account for verification not found. Sign up again", "created": "Created", "edited": "Edited", - "errorDataConflict": "We can’t process your data because it has conflicting or contradictory information.", + "errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.", "errorAccessDenied": "You do not have permission to access this resource. If you need access, please contact an administrator.", "errorBrokenPayload": "The data you sent is invalid or incomplete. Please check your submission and try again.", "errorInvalidArgument": "One or more arguments are invalid. Verify your input and try again.", @@ -66,6 +66,7 @@ "showDetailsAction": "Show Details", "errorLogin": "Error logging in", "errorCreatingInvitation": "Failed to create invitaiton", + "errorLoadingInvitations": "Failed to load invitations", "@errorCreatingInvitation": { "description": "Error message displayed when invitation creation fails" }, @@ -93,6 +94,7 @@ "payoutNavDashboard": "Dashboard", "payoutNavSendPayout": "Send payout", "payoutNavRecipients": "Recipients", + "payoutNavInvitations": "Invitations", "payoutNavReports": "Reports", "payoutNavSettings": "Settings", "payoutNavLogout": "Logout", @@ -185,6 +187,47 @@ "cancel": "Cancel", "confirm": "Confirm", "back": "Back", + "invitationsTitle": "Invite your teammates", + "invitationsSubtitle": "Send invitations for restricted employee accounts and see their status in one place.", + "invitationCreateTitle": "New invitation", + "invitationEmailLabel": "Work email", + "invitationNameLabel": "Full name", + "invitationRoleLabel": "Role", + "invitationMessageLabel": "Message (optional)", + "invitationExpiresIn": "Expires in {days} days", + "@invitationExpiresIn": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "invitationSendButton": "Send invitation", + "invitationCreatedSuccess": "Invitation sent", + "invitationSearchHint": "Search invitations", + "invitationFilterAll": "All", + "invitationFilterPending": "Pending", + "invitationFilterAccepted": "Accepted", + "invitationFilterDeclined": "Declined", + "invitationFilterRevoked": "Revoked", + "invitationFilterExpired": "Expired", + "invitationFilterArchived": "Archived", + "invitationListEmpty": "No invitations yet", + "invitationStatusPending": "Pending", + "invitationStatusAccepted": "Accepted", + "invitationStatusDeclined": "Declined", + "invitationStatusRevoked": "Revoked", + "invitationStatusExpired": "Expired", + "invitationExpires": "Expires {date}", + "invitationExpired": "Expired {date}", + "invitationInvitedBy": "Invited by", + "invitationArchiveAction": "Archive", + "invitationRevokeAction": "Revoke", + "invitationArchived": "Invitation archived", + "invitationRevoked": "Invitation revoked", + "invitationArchiveFailed": "Could not archive the invitation", + "invitationRevokeFailed": "Could not revoke the invitation", + "invitationUnknownRole": "Unknown role", "operationfryTitle": "Operation history", "@operationfryTitle": { diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a53bf96..faaccc8 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -49,7 +49,7 @@ "errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова", "created": "Создано", "edited": "Изменено", - "errorDataConflict": "Мы не можем обработать ваши данные, так как они содержат конфликтующую или противоречивую информацию.", + "errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.", "errorAccessDenied": "У вас нет разрешения на доступ к этому ресурсу. Если вам нужен доступ, пожалуйста, обратитесь к администратору.", "errorBrokenPayload": "Отправленные данные недействительны или неполны. Пожалуйста, проверьте введенные данные и попробуйте снова.", "errorInvalidArgument": "Один или несколько аргументов недействительны. Проверьте введенные данные и попробуйте снова.", @@ -66,6 +66,7 @@ "showDetailsAction": "Показать детали", "errorLogin": "Ошибка входа", "errorCreatingInvitation": "Не удалось создать приглашение", + "errorLoadingInvitations": "Не удалось загрузить приглашения", "@errorCreatingInvitation": { "description": "Сообщение об ошибке, отображаемое при неудачном создании приглашения" }, @@ -93,6 +94,7 @@ "payoutNavDashboard": "Дашборд", "payoutNavSendPayout": "Отправить выплату", "payoutNavRecipients": "Получатели", + "payoutNavInvitations": "Приглашения", "payoutNavReports": "Отчеты", "payoutNavSettings": "Настройки", "payoutNavLogout": "Выйти", @@ -185,6 +187,47 @@ "cancel": "Отмена", "confirm": "Подтвердить", "back": "Назад", + "invitationsTitle": "Пригласите сотрудников", + "invitationsSubtitle": "Отправляйте приглашения сотрудникам с ограниченными аккаунтами и отслеживайте статусы.", + "invitationCreateTitle": "Новое приглашение", + "invitationEmailLabel": "Рабочий email", + "invitationNameLabel": "Полное имя", + "invitationRoleLabel": "Роль", + "invitationMessageLabel": "Сообщение (необязательно)", + "invitationExpiresIn": "Истекает через {days} дн.", + "@invitationExpiresIn": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "invitationSendButton": "Отправить приглашение", + "invitationCreatedSuccess": "Приглашение отправлено", + "invitationSearchHint": "Поиск приглашений", + "invitationFilterAll": "Все", + "invitationFilterPending": "В ожидании", + "invitationFilterAccepted": "Принятые", + "invitationFilterDeclined": "Отклоненные", + "invitationFilterRevoked": "Отозванные", + "invitationFilterExpired": "Истекшие", + "invitationFilterArchived": "Архив", + "invitationListEmpty": "Пока нет приглашений", + "invitationStatusPending": "В ожидании", + "invitationStatusAccepted": "Принято", + "invitationStatusDeclined": "Отклонено", + "invitationStatusRevoked": "Отозвано", + "invitationStatusExpired": "Истекло", + "invitationExpires": "Истекает {date}", + "invitationExpired": "Истекло {date}", + "invitationInvitedBy": "Пригласил", + "invitationArchiveAction": "Архивировать", + "invitationRevokeAction": "Отозвать", + "invitationArchived": "Приглашение архивировано", + "invitationRevoked": "Приглашение отозвано", + "invitationArchiveFailed": "Не удалось архивировать приглашение", + "invitationRevokeFailed": "Не удалось отозвать приглашение", + "invitationUnknownRole": "Неизвестная роль", "operationfryTitle": "История операций", "@operationfryTitle": { diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index c313c4d..b7c85e6 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -12,6 +12,7 @@ import 'package:pshared/provider/locale.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/account.dart'; import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/accounts/employees.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/provider.dart'; @@ -19,6 +20,7 @@ import 'package:pshared/provider/payment/quotation.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/invitations.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/service/payment/wallets.dart'; @@ -72,6 +74,10 @@ void main() async { create: (_) => PermissionsProvider(), update: (context, orgnization, provider) => provider!..update(orgnization), ), + ChangeNotifierProxyProvider( + create: (_) => EmployeesProvider(), + update: (context, organizations, provider) => provider!..updateProviders(organizations), + ), ChangeNotifierProvider(create: (_) => CarouselIndexProvider()), ChangeNotifierProvider( @@ -81,6 +87,10 @@ void main() async { create: (_) => RecipientsProvider(), update: (context, organizations, provider) => provider!..updateProviders(organizations), ), + ChangeNotifierProxyProvider( + create: (_) => InvitationsProvider(), + update: (context, organizations, provider) => provider!..updateProviders(organizations), + ), ChangeNotifierProxyProvider2( create: (_) => PaymentMethodsProvider(), update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), diff --git a/frontend/pweb/lib/models/invitation_filter.dart b/frontend/pweb/lib/models/invitation_filter.dart new file mode 100644 index 0000000..83c5d39 --- /dev/null +++ b/frontend/pweb/lib/models/invitation_filter.dart @@ -0,0 +1,9 @@ +enum InvitationFilter { + all, + pending, + accepted, + declined, + revoked, + expired, + archived, +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/invitations/page.dart b/frontend/pweb/lib/pages/invitations/page.dart new file mode 100644 index 0000000..2e72468 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/page.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/models/resources.dart'; +import 'package:pshared/provider/account.dart'; +import 'package:pshared/provider/invitations.dart'; +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/pages/invitations/widgets/header.dart'; +import 'package:pweb/pages/invitations/widgets/form/form.dart'; +import 'package:pweb/pages/invitations/widgets/list/list.dart'; +import 'package:pweb/pages/loader.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationsPage extends StatefulWidget { + const InvitationsPage({super.key}); + + @override + State createState() => _InvitationsPageState(); +} + +class _InvitationsPageState extends State { + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _nameController = TextEditingController(); + final TextEditingController _messageController = TextEditingController(); + + String? _selectedRoleRef; + int _expiryDays = 7; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _bootstrapRoleSelection(); + } + + void _bootstrapRoleSelection() { + final roles = context.read().roleDescriptions; + if (_selectedRoleRef == null && roles.isNotEmpty) { + _selectedRoleRef = roles.first.storable.id; + } + } + + @override + void dispose() { + _emailController.dispose(); + _nameController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + Future _sendInvitation() async { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + + final account = context.read().account; + if (account == null) return; + final permissions = context.read(); + final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id; + if (roleRef == null) return; + + final invitations = context.read(); + final loc = AppLocalizations.of(context)!; + + await executeActionWithNotification( + context: context, + action: () => invitations.sendInvitation( + email: _emailController.text.trim(), + name: _nameController.text.trim(), + comment: _messageController.text.trim(), + roleRef: roleRef, + inviterRef: account.id, + expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)), + ), + successMessage: loc.invitationCreatedSuccess, + errorMessage: loc.errorCreatingInvitation, + ); + + _emailController.clear(); + _nameController.clear(); + _messageController.clear(); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final permissions = context.watch(); + + if (!permissions.canRead(ResourceType.invitations)) { + return PageViewLoader( + child: Center(child: Text(loc.errorAccessDenied)), + ); + } + + return PageViewLoader( + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InvitationsHeader(loc: loc), + const SizedBox(height: 16), + InvitationsForm( + formKey: _formKey, + emailController: _emailController, + nameController: _nameController, + messageController: _messageController, + expiryDays: _expiryDays, + onExpiryChanged: (value) => setState(() => _expiryDays = value), + selectedRoleRef: _selectedRoleRef, + onRoleChanged: (role) => setState(() => _selectedRoleRef = role), + canCreate: permissions.canCreate(ResourceType.invitations), + onSubmit: _sendInvitation, + ), + const SizedBox(height: 24), + const InvitationsList(), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart b/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart new file mode 100644 index 0000000..446b96d --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/actions.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/provider/invitations.dart'; + +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationCardActions extends StatelessWidget { + final Invitation invitation; + + const InvitationCardActions({super.key, required this.invitation}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (invitation.isPending && !invitation.isExpired) + TextButton.icon( + onPressed: () => _revokeInvitation(context), + icon: const Icon(Icons.block), + label: Text(loc.invitationRevokeAction), + ), + if (!invitation.isArchived) + TextButton.icon( + onPressed: () => _archiveInvitation(context), + icon: const Icon(Icons.archive_outlined), + label: Text(loc.invitationArchiveAction), + ), + ], + ); + } + + Future _archiveInvitation(BuildContext context) async { + final loc = AppLocalizations.of(context)!; + final provider = context.read(); + + await executeActionWithNotification( + context: context, + action: () => provider.setInvitationArchived(invitation, true), + successMessage: loc.invitationArchived, + errorMessage: loc.invitationArchiveFailed, + ); + } + + Future _revokeInvitation(BuildContext context) async { + final loc = AppLocalizations.of(context)!; + final provider = context.read(); + + await executeActionWithNotification( + context: context, + action: () => provider.revokeInvitation(invitation), + successMessage: loc.invitationRevoked, + errorMessage: loc.invitationRevokeFailed, + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/card.dart b/frontend/pweb/lib/pages/invitations/widgets/card/card.dart new file mode 100644 index 0000000..0ba6934 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/card.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; + +import 'package:pweb/pages/invitations/widgets/card/view.dart'; + + +class InvitationsCard extends StatelessWidget { + final Invitation invitation; + + const InvitationsCard({super.key, required this.invitation}); + + @override + Widget build(BuildContext context) => InvitationCardView(invitation: invitation); +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/details.dart b/frontend/pweb/lib/pages/invitations/widgets/card/details.dart new file mode 100644 index 0000000..4351c35 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/details.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; + +import 'package:pweb/pages/invitations/widgets/card/helpers.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationCardDetails extends StatelessWidget { + final Invitation invitation; + final String roleLabel; + final String inviterName; + final DateFormat dateFormat; + final AppLocalizations loc; + + const InvitationCardDetails({ + super.key, + required this.invitation, + required this.roleLabel, + required this.inviterName, + required this.dateFormat, + required this.loc, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 20, + runSpacing: 10, + children: [ + InvitationInfoRow( + icon: Icons.badge_outlined, + label: loc.invitationRoleLabel, + value: roleLabel, + ), + InvitationInfoRow( + icon: Icons.schedule_outlined, + label: invitation.isExpired + ? loc.invitationExpired(dateFormat.format(invitation.expiresAt.toLocal())) + : loc.invitationExpires(dateFormat.format(invitation.expiresAt.toLocal())), + value: '', + ), + InvitationInfoRow( + icon: Icons.person_outline, + label: loc.invitationInvitedBy, + value: inviterName, + ), + ], + ), + if (invitation.content.comment.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + invitation.content.comment, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/header.dart b/frontend/pweb/lib/pages/invitations/widgets/card/header.dart new file mode 100644 index 0000000..cbb2e42 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/header.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; + + +class InvitationCardHeader extends StatelessWidget { + final Invitation invitation; + final String statusLabel; + final Color statusColor; + + const InvitationCardHeader({ + super.key, + required this.invitation, + required this.statusLabel, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + invitation.inviteeDisplayName, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + invitation.content.email, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor), + ), + ], + ), + ), + Chip( + backgroundColor: statusColor.withAlpha(40), + label: Text( + statusLabel, + style: TextStyle(color: statusColor, fontWeight: FontWeight.w600), + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/helpers.dart b/frontend/pweb/lib/pages/invitations/widgets/card/helpers.dart new file mode 100644 index 0000000..54fb4fb --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/helpers.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/models/invitation/status.dart'; +import 'package:pshared/models/permissions/descriptions/role.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationInfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const InvitationInfoRow({ + super.key, + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: theme.hintColor), + const SizedBox(width: 6), + if (value.isEmpty) + Text(label, style: theme.textTheme.bodyMedium) + else + RichText( + text: TextSpan( + style: theme.textTheme.bodyMedium, + children: [ + TextSpan(text: '$label: ', style: const TextStyle(fontWeight: FontWeight.w600)), + TextSpan(text: value), + ], + ), + ), + ], + ); + } +} + +String invitationStatusLabel(Invitation invitation, AppLocalizations loc) { + if (invitation.isExpired && invitation.isPending) { + return loc.invitationStatusExpired; + } + switch (invitation.status) { + case InvitationStatus.created: + case InvitationStatus.sent: + return loc.invitationStatusPending; + case InvitationStatus.accepted: + return loc.invitationStatusAccepted; + case InvitationStatus.declined: + return loc.invitationStatusDeclined; + case InvitationStatus.revoked: + return loc.invitationStatusRevoked; + } +} + +Color invitationStatusColor(ThemeData theme, Invitation invitation) { + if (invitation.isExpired && invitation.isPending) { + return theme.disabledColor; + } + switch (invitation.status) { + case InvitationStatus.created: + case InvitationStatus.sent: + return Colors.amber.shade800; + case InvitationStatus.accepted: + return Colors.green.shade700; + case InvitationStatus.declined: + case InvitationStatus.revoked: + return Colors.red.shade700; + } +} + +String invitationRoleLabel(List roles, Invitation invitation, AppLocalizations loc) { + final role = roles.firstWhereOrNull((r) => r.storable.id == invitation.roleRef); + return role?.describable.name ?? loc.invitationUnknownRole; +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/card/view.dart b/frontend/pweb/lib/pages/invitations/widgets/card/view.dart new file mode 100644 index 0000000..4769b28 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/card/view.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/models/resources.dart'; +import 'package:pshared/provider/accounts/employees.dart'; +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/pages/invitations/widgets/card/actions.dart'; +import 'package:pweb/pages/invitations/widgets/card/details.dart'; +import 'package:pweb/pages/invitations/widgets/card/header.dart'; +import 'package:pweb/pages/invitations/widgets/card/helpers.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationCardView extends StatelessWidget { + final Invitation invitation; + + const InvitationCardView({super.key, required this.invitation}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final permissions = context.watch(); + final employees = context.watch(); + final dateFormat = DateFormat.yMMMd().add_Hm(); + + final statusLabel = invitationStatusLabel(invitation, loc); + final statusColor = invitationStatusColor(theme, invitation); + final roleLabel = invitationRoleLabel(permissions.roleDescriptions, invitation, loc); + final inviterName = employees.getEmployee(invitation.inviterRef)?.fullName ?? loc.unknown; + final canUpdate = permissions.canUpdate(ResourceType.invitations); + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: theme.dividerColor.withAlpha(20)), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InvitationCardHeader( + invitation: invitation, + statusLabel: statusLabel, + statusColor: statusColor, + ), + const SizedBox(height: 12), + InvitationCardDetails( + invitation: invitation, + roleLabel: roleLabel, + inviterName: inviterName, + dateFormat: dateFormat, + loc: loc, + ), + if (canUpdate) ...[ + const SizedBox(height: 10), + InvitationCardActions(invitation: invitation), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart b/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart new file mode 100644 index 0000000..4a5283c --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/filter/chips.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/invitation_filter.dart'; +import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationFilterChips extends StatelessWidget { + final InvitationFilter selectedFilter; + final ValueChanged onSelected; + + const InvitationFilterChips({ + super.key, + required this.selectedFilter, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Wrap( + spacing: 8, + runSpacing: 8, + children: InvitationFilter.values.map( + (filter) => ChoiceChip( + label: Text(invitationFilterLabel(filter, loc)), + selected: selectedFilter == filter, + onSelected: (_) => onSelected(filter), + ), + ).toList(), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart b/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart new file mode 100644 index 0000000..a79aaea --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/filter/invitation_filter.dart @@ -0,0 +1,45 @@ +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/models/invitation/status.dart'; + +import 'package:pweb/models/invitation_filter.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String invitationFilterLabel(InvitationFilter filter, AppLocalizations loc) { + switch (filter) { + case InvitationFilter.all: + return loc.invitationFilterAll; + case InvitationFilter.pending: + return loc.invitationFilterPending; + case InvitationFilter.accepted: + return loc.invitationFilterAccepted; + case InvitationFilter.declined: + return loc.invitationFilterDeclined; + case InvitationFilter.revoked: + return loc.invitationFilterRevoked; + case InvitationFilter.expired: + return loc.invitationFilterExpired; + case InvitationFilter.archived: + return loc.invitationFilterArchived; + } +} + +bool invitationFilterMatches(InvitationFilter filter, Invitation inv) { + switch (filter) { + case InvitationFilter.pending: + return inv.isPending && !inv.isExpired; + case InvitationFilter.accepted: + return inv.status == InvitationStatus.accepted; + case InvitationFilter.declined: + return inv.status == InvitationStatus.declined; + case InvitationFilter.revoked: + return inv.status == InvitationStatus.revoked; + case InvitationFilter.expired: + return inv.isExpired && inv.isPending; + case InvitationFilter.archived: + return inv.isArchived; + case InvitationFilter.all: + return true; + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/actions.dart b/frontend/pweb/lib/pages/invitations/widgets/form/actions.dart new file mode 100644 index 0000000..85a5d68 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/form/actions.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationFormActions extends StatelessWidget { + final int expiryDays; + final ValueChanged onExpiryChanged; + final bool canCreate; + final bool hasRoles; + final VoidCallback onSubmit; + + const InvitationFormActions({ + super.key, + required this.expiryDays, + required this.onExpiryChanged, + required this.canCreate, + required this.hasRoles, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Row( + children: [ + Text(loc.invitationExpiresIn(expiryDays)), + Expanded( + child: Slider( + label: '$expiryDays', + min: 1, + max: 30, + value: expiryDays.toDouble(), + onChanged: (value) => onExpiryChanged(value.round()), + ), + ), + FilledButton.icon( + onPressed: canCreate && hasRoles ? onSubmit : null, + icon: const Icon(Icons.send_outlined), + label: Text(loc.invitationSendButton), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart new file mode 100644 index 0000000..2864a17 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/permissions/descriptions/role.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationFormFields extends StatelessWidget { + final List roles; + final TextEditingController emailController; + final TextEditingController nameController; + final TextEditingController messageController; + final String? selectedRoleRef; + final ValueChanged onRoleChanged; + + const InvitationFormFields({ + super.key, + required this.roles, + required this.emailController, + required this.nameController, + required this.messageController, + required this.selectedRoleRef, + required this.onRoleChanged, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + return Column( + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: 320, + child: TextFormField( + controller: emailController, + decoration: InputDecoration( + labelText: loc.invitationEmailLabel, + prefixIcon: const Icon(Icons.alternate_email_outlined), + ), + keyboardType: TextInputType.emailAddress, + validator: (value) => (value == null || value.trim().isEmpty) + ? loc.errorEmailMissing + : null, + ), + ), + SizedBox( + width: 280, + child: TextFormField( + controller: nameController, + decoration: InputDecoration( + labelText: loc.invitationNameLabel, + prefixIcon: const Icon(Icons.person_outline), + ), + ), + ), + SizedBox( + width: 260, + child: DropdownButtonFormField( + initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null), + items: roles.map((role) => DropdownMenuItem( + value: role.storable.id, + child: Text(role.describable.name), + )).toList(), + onChanged: roles.isEmpty ? null : onRoleChanged, + decoration: InputDecoration( + labelText: loc.invitationRoleLabel, + prefixIcon: const Icon(Icons.security_outlined), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: messageController, + minLines: 2, + maxLines: 3, + decoration: InputDecoration( + labelText: loc.invitationMessageLabel, + prefixIcon: const Icon(Icons.chat_bubble_outline), + ), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/form.dart b/frontend/pweb/lib/pages/invitations/widgets/form/form.dart new file mode 100644 index 0000000..f0e7bc2 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/form/form.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/invitations/widgets/form/view.dart'; + + +class InvitationsForm extends StatelessWidget { + final GlobalKey formKey; + final TextEditingController emailController; + final TextEditingController nameController; + final TextEditingController messageController; + final int expiryDays; + final ValueChanged onExpiryChanged; + final String? selectedRoleRef; + final ValueChanged onRoleChanged; + final bool canCreate; + final VoidCallback onSubmit; + + const InvitationsForm({ + super.key, + required this.formKey, + required this.emailController, + required this.nameController, + required this.messageController, + required this.expiryDays, + required this.onExpiryChanged, + required this.selectedRoleRef, + required this.onRoleChanged, + required this.canCreate, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) => InvitationFormView( + formKey: formKey, + emailController: emailController, + nameController: nameController, + messageController: messageController, + expiryDays: expiryDays, + onExpiryChanged: onExpiryChanged, + selectedRoleRef: selectedRoleRef, + onRoleChanged: onRoleChanged, + canCreate: canCreate, + onSubmit: onSubmit, + ); +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/view.dart b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart new file mode 100644 index 0000000..555ecef --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/form/view.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/provider/permissions.dart'; + +import 'package:pweb/pages/invitations/widgets/form/actions.dart'; +import 'package:pweb/pages/invitations/widgets/form/fields.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationFormView extends StatelessWidget { + final GlobalKey formKey; + final TextEditingController emailController; + final TextEditingController nameController; + final TextEditingController messageController; + final int expiryDays; + final ValueChanged onExpiryChanged; + final String? selectedRoleRef; + final ValueChanged onRoleChanged; + final bool canCreate; + final VoidCallback onSubmit; + + const InvitationFormView({ + super.key, + required this.formKey, + required this.emailController, + required this.nameController, + required this.messageController, + required this.expiryDays, + required this.onExpiryChanged, + required this.selectedRoleRef, + required this.onRoleChanged, + required this.canCreate, + required this.onSubmit, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + final roles = context.watch().roleDescriptions; + + return Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerHighest.withAlpha(40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.invitationCreateTitle, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + InvitationFormFields( + roles: roles, + emailController: emailController, + nameController: nameController, + messageController: messageController, + selectedRoleRef: selectedRoleRef, + onRoleChanged: onRoleChanged, + ), + const SizedBox(height: 12), + InvitationFormActions( + expiryDays: expiryDays, + onExpiryChanged: onExpiryChanged, + canCreate: canCreate, + hasRoles: roles.isNotEmpty, + onSubmit: onSubmit, + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/header.dart b/frontend/pweb/lib/pages/invitations/widgets/header.dart new file mode 100644 index 0000000..0ba8c48 --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/header.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationsHeader extends StatelessWidget { + final AppLocalizations loc; + + const InvitationsHeader({super.key, required this.loc}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.invitationsTitle, + style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + loc.invitationsSubtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).hintColor), + ), + ], + ); +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/body.dart b/frontend/pweb/lib/pages/invitations/widgets/list/body.dart new file mode 100644 index 0000000..9c62aef --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/list/body.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; + +import 'package:pweb/pages/invitations/widgets/card/card.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationListBody extends StatelessWidget { + final List invitations; + + const InvitationListBody({super.key, required this.invitations}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + + if (invitations.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Text(loc.invitationListEmpty), + ), + ); + } + + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: invitations.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (_, index) => InvitationsCard(invitation: invitations[index]), + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/list.dart b/frontend/pweb/lib/pages/invitations/widgets/list/list.dart new file mode 100644 index 0000000..9590d4f --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/list/list.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/pages/invitations/widgets/list/view.dart'; + + +class InvitationsList extends StatefulWidget { + const InvitationsList({super.key}); + + @override + State createState() => _InvitationsListState(); +} + +class _InvitationsListState extends State { + @override + Widget build(BuildContext context) => const InvitationListView(); +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/list/view.dart b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart new file mode 100644 index 0000000..80970ec --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/list/view.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/models/invitation/invitation.dart'; +import 'package:pshared/provider/invitations.dart'; + +import 'package:pweb/models/invitation_filter.dart'; +import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart'; +import 'package:pweb/pages/invitations/widgets/filter/chips.dart'; +import 'package:pweb/pages/invitations/widgets/list/body.dart'; +import 'package:pweb/pages/invitations/widgets/search_field.dart'; +import 'package:pweb/widgets/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class InvitationListView extends StatefulWidget { + const InvitationListView({super.key}); + + @override + State createState() => _InvitationListViewState(); +} + +class _InvitationListViewState extends State { + final TextEditingController _searchController = TextEditingController(); + + InvitationFilter _filter = InvitationFilter.all; + String _query = ''; + Object? _lastError; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _setQuery(String query) { + setState(() => _query = query.trim().toLowerCase()); + } + + void _setFilter(InvitationFilter filter) { + setState(() => _filter = filter); + } + + void _notifyError(BuildContext context, Object error, AppLocalizations loc) { + if (identical(error, _lastError)) { + return; + } + _lastError = error; + postNotifyUserOfErrorX( + context: context, + errorSituation: loc.errorLoadingInvitations, + exception: error, + ); + } + + List _filteredInvitations(List invitations) { + final showArchived = _filter == InvitationFilter.archived; + Iterable filtered = invitations + .where((inv) => showArchived ? inv.isArchived : !inv.isArchived) + .where((inv) => invitationFilterMatches(_filter, inv)); + + if (_query.isNotEmpty) { + filtered = filtered.where((inv) { + return inv.inviteeDisplayName.toLowerCase().contains(_query) + || inv.content.email.toLowerCase().contains(_query); + }); + } + + final sorted = filtered.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return sorted; + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final provider = context.watch(); + + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.error != null) { + _notifyError(context, provider.error!, loc); + } else { + _lastError = null; + } + + final invitations = _filteredInvitations(provider.invitations); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InvitationSearchField( + controller: _searchController, + hintText: loc.invitationSearchHint, + onChanged: _setQuery, + ), + const SizedBox(height: 12), + InvitationFilterChips( + selectedFilter: _filter, + onSelected: _setFilter, + ), + const SizedBox(height: 16), + InvitationListBody(invitations: invitations), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/invitations/widgets/search_field.dart b/frontend/pweb/lib/pages/invitations/widgets/search_field.dart new file mode 100644 index 0000000..fe6b3cc --- /dev/null +++ b/frontend/pweb/lib/pages/invitations/widgets/search_field.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + + +class InvitationSearchField extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged onChanged; + + const InvitationSearchField({ + super.key, + required this.controller, + required this.hintText, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + hintText: hintText, + ), + onChanged: onChanged, + ); + } +} diff --git a/frontend/pweb/lib/widgets/sidebar/destinations.dart b/frontend/pweb/lib/widgets/sidebar/destinations.dart index c651cd7..e998428 100644 --- a/frontend/pweb/lib/widgets/sidebar/destinations.dart +++ b/frontend/pweb/lib/widgets/sidebar/destinations.dart @@ -7,6 +7,7 @@ enum PayoutDestination { dashboard(Icons.dashboard_outlined, 'dashboard'), sendPayout(Icons.send_outlined, 'sendPayout'), recipients(Icons.people_outline, 'recipients'), + invitations(Icons.mark_email_read_outlined, 'invitations'), reports(Icons.insert_chart, 'reports'), settings(Icons.settings_outlined, 'settings'), methods(Icons.credit_card, 'methods'), @@ -29,13 +30,15 @@ enum PayoutDestination { case PayoutDestination.sendPayout: return loc.payoutNavSendPayout; case PayoutDestination.recipients: - return loc.payoutNavRecipients; + return loc.payoutNavRecipients; case PayoutDestination.reports: return loc.payoutNavReports; case PayoutDestination.settings: return loc.payoutNavSettings; case PayoutDestination.methods: return loc.payoutNavMethods; + case PayoutDestination.invitations: + return loc.payoutNavInvitations; case PayoutDestination.payment: return loc.payout; case PayoutDestination.addrecipient: diff --git a/frontend/pweb/lib/widgets/sidebar/page.dart b/frontend/pweb/lib/widgets/sidebar/page.dart index 0850ce2..da667f7 100644 --- a/frontend/pweb/lib/widgets/sidebar/page.dart +++ b/frontend/pweb/lib/widgets/sidebar/page.dart @@ -41,6 +41,7 @@ class PageSelector extends StatelessWidget { PayoutDestination.methods, PayoutDestination.editwallet, PayoutDestination.walletTopUp, + PayoutDestination.invitations, } : PayoutDestination.values.toSet(); @@ -103,6 +104,9 @@ class PageSelector extends StatelessWidget { if (location.startsWith(PayoutRoutes.recipientsPath)) { return PayoutDestination.recipients; } + if (location.startsWith(PayoutRoutes.invitationsPath)) { + return PayoutDestination.invitations; + } if (location.startsWith(PayoutRoutes.settingsPath)) { return PayoutDestination.settings; } diff --git a/frontend/pweb/lib/widgets/sidebar/sidebar.dart b/frontend/pweb/lib/widgets/sidebar/sidebar.dart index 8bcb435..b3e354a 100644 --- a/frontend/pweb/lib/widgets/sidebar/sidebar.dart +++ b/frontend/pweb/lib/widgets/sidebar/sidebar.dart @@ -44,6 +44,7 @@ class PayoutSidebar extends StatelessWidget { [ PayoutDestination.dashboard, PayoutDestination.recipients, + PayoutDestination.invitations, PayoutDestination.methods, PayoutDestination.reports, ];