Complete guided Scheduling interface patterns
This commit is contained in:
@@ -134,6 +134,9 @@ poll-backed scheduling requests:
|
|||||||
|
|
||||||
## Interface workflow and contextual guidance
|
## Interface workflow and contextual guidance
|
||||||
|
|
||||||
|
The route-by-route migration record and verification contract are documented in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
The request detail projects the existing backend lifecycle into three stable
|
The request detail projects the existing backend lifecycle into three stable
|
||||||
user stages: prepare the request, collect participation, and decide. Draft,
|
user stages: prepare the request, collect participation, and decide. Draft,
|
||||||
collecting, closed, decided, handed-off, cancelled, and archived records remain
|
collecting, closed, decided, handed-off, cancelled, and archived records remain
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Scheduling Interface Pattern Migration
|
||||||
|
|
||||||
|
Scheduling implements the platform interface pattern language on its owned
|
||||||
|
surfaces without importing optional module internals.
|
||||||
|
|
||||||
|
## Surface Map
|
||||||
|
|
||||||
|
| Surface | Pattern | Consequential actions | Help context |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/scheduling` request list and detail | Persistent list-detail workspace with a lifecycle rail | Open, close, remind, decide, create holds, create final event | `scheduling.list`, `scheduling.request` |
|
||||||
|
| `/scheduling` create/edit | Bounded editor with unsaved-change guard and typed Core controls | Save or discard a request definition | `scheduling.editor` |
|
||||||
|
| `/scheduling/public/:requestId/:token` | Privacy-bounded public participation form | Submit or replace the invited participant's response | `scheduling.public-participation` |
|
||||||
|
| `scheduling.widget.open-requests` | Compact dashboard contribution | Navigate to the selected request | `scheduling.request` |
|
||||||
|
|
||||||
|
## Interaction Contract
|
||||||
|
|
||||||
|
- Closing a poll, creating reminder jobs or Calendar objects, and deciding a
|
||||||
|
final slot require a shared confirmation dialog. Invitation-link revocation
|
||||||
|
uses the same component with danger emphasis.
|
||||||
|
- Disabled actions expose the active busy, permission, immutable-response, or
|
||||||
|
optional-capability reason through Core's action-tooltip and blocker
|
||||||
|
components.
|
||||||
|
- Calendar selection uses the optional `calendar.picker` UI capability and
|
||||||
|
bounded Calendar scopes. Scheduling never imports Calendar WebUI code.
|
||||||
|
- Participant selection uses Core's provider-backed `PeoplePicker`; public
|
||||||
|
participation receives only the privacy-bounded request projection.
|
||||||
|
- Stable documentation topics are available from the list, editor, detail,
|
||||||
|
public participation page, and dashboard widget. Core resolves them through
|
||||||
|
Docs when enabled and through hosted documentation otherwise.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webui
|
||||||
|
npm run test:view-model
|
||||||
|
npm run test:ui-structure
|
||||||
|
```
|
||||||
|
|
||||||
|
The structural check guards shared components, optional-module boundaries,
|
||||||
|
confirmation gates, contextual documentation, public credential controls, and
|
||||||
|
request-specific widget navigation. Backend tests validate manifest metadata,
|
||||||
|
permission boundaries, lifecycle transitions, public participation, and
|
||||||
|
Calendar capability behavior.
|
||||||
@@ -7,6 +7,7 @@ from govoplan_core.core.calendar import CAPABILITY_CALENDAR_SCHEDULING
|
|||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
|
DocumentationCondition,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
@@ -108,8 +109,31 @@ DOCUMENTATION = (
|
|||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user", "organizer", "participant"),
|
audience=("user", "organizer", "participant"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, RESPOND_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
related_modules=("poll", "calendar", "notifications", "mail"),
|
related_modules=("poll", "calendar", "notifications", "mail"),
|
||||||
metadata={"kind": "reference"},
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/scheduling",
|
||||||
|
"screen": "Scheduling",
|
||||||
|
"help_contexts": [
|
||||||
|
"scheduling.list",
|
||||||
|
"scheduling.request",
|
||||||
|
"scheduling.editor",
|
||||||
|
"scheduling.public-participation",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Prepare candidate times and participation controls.",
|
||||||
|
"Collect and review availability.",
|
||||||
|
"Close the poll and confirm the selected time.",
|
||||||
|
"Hand the decision to Calendar when configured.",
|
||||||
|
],
|
||||||
|
"outcome": "A recorded scheduling decision with bounded participation and optional Calendar handoff.",
|
||||||
|
"verification": "The request detail shows the decided slot, lifecycle state, participant aggregate, and any Calendar event reference.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="scheduling.calendar-coordination",
|
id="scheduling.calendar-coordination",
|
||||||
@@ -124,7 +148,10 @@ DOCUMENTATION = (
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("organizer", "module_admin", "tenant_admin"),
|
audience=("organizer", "module_admin", "tenant_admin"),
|
||||||
related_modules=("calendar", "access", "policy"),
|
related_modules=("calendar", "access", "policy"),
|
||||||
metadata={"kind": "reference", "context_ids": ["scheduling.calendar-integration"]},
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["scheduling.calendar-integration", "scheduling.calendar-coordination"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="scheduling.participation-governance",
|
id="scheduling.participation-governance",
|
||||||
@@ -139,7 +166,13 @@ DOCUMENTATION = (
|
|||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("operator", "module_admin", "tenant_admin"),
|
audience=("operator", "module_admin", "tenant_admin"),
|
||||||
related_modules=("poll", "policy", "access"),
|
related_modules=("poll", "policy", "access"),
|
||||||
metadata={"kind": "pattern", "context_ids": ["scheduling.public-participation-blocker"]},
|
metadata={
|
||||||
|
"kind": "pattern",
|
||||||
|
"help_contexts": [
|
||||||
|
"scheduling.public-participation-blocker",
|
||||||
|
"scheduling.public-participation",
|
||||||
|
],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
self.assertEqual("0.1.11", required_interfaces[interface_name].version_min)
|
self.assertEqual("0.1.11", required_interfaces[interface_name].version_min)
|
||||||
|
|
||||||
|
documentation = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
workflow = documentation["scheduling.find-and-decide-meeting-time"]
|
||||||
|
self.assertEqual("workflow", workflow.metadata["kind"])
|
||||||
|
self.assertEqual("/scheduling", workflow.metadata["route"])
|
||||||
|
self.assertIn("scheduling.request", workflow.metadata["help_contexts"])
|
||||||
|
self.assertIn("scheduling.public-participation", workflow.metadata["help_contexts"])
|
||||||
|
self.assertIn(
|
||||||
|
"scheduling.calendar-coordination",
|
||||||
|
documentation["scheduling.calendar-coordination"].metadata["help_contexts"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_access.backend.db.models import Account, User
|
from govoplan_access.backend.db.models import Account, User
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import (
|
||||||
|
CalendarCollection,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarMigrationBatch,
|
||||||
|
CalendarOutboxOperation,
|
||||||
|
CalendarSyncSource,
|
||||||
|
)
|
||||||
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
|
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
|
||||||
from govoplan_poll.backend.db.models import (
|
from govoplan_poll.backend.db.models import (
|
||||||
Poll,
|
Poll,
|
||||||
@@ -129,6 +135,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
CalendarSyncSource.__table__,
|
CalendarSyncSource.__table__,
|
||||||
CalendarOutboxOperation.__table__,
|
CalendarOutboxOperation.__table__,
|
||||||
|
CalendarMigrationBatch.__table__,
|
||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
Account.__table__,
|
Account.__table__,
|
||||||
User.__table__,
|
User.__table__,
|
||||||
@@ -155,6 +162,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
User.__table__,
|
User.__table__,
|
||||||
Account.__table__,
|
Account.__table__,
|
||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
|
CalendarMigrationBatch.__table__,
|
||||||
CalendarOutboxOperation.__table__,
|
CalendarOutboxOperation.__table__,
|
||||||
CalendarSyncSource.__table__,
|
CalendarSyncSource.__table__,
|
||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPag
|
|||||||
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
|
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
|
||||||
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
|
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
|
||||||
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
|
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
|
||||||
|
const widgetPath = fileURLToPath(new URL("../src/features/scheduling/SchedulingRequestsWidget.tsx", import.meta.url));
|
||||||
const page = readFileSync(pagePath, "utf8");
|
const page = readFileSync(pagePath, "utf8");
|
||||||
const publicPage = readFileSync(publicPagePath, "utf8");
|
const publicPage = readFileSync(publicPagePath, "utf8");
|
||||||
const api = readFileSync(apiPath, "utf8");
|
const api = readFileSync(apiPath, "utf8");
|
||||||
const moduleSource = readFileSync(modulePath, "utf8");
|
const moduleSource = readFileSync(modulePath, "utf8");
|
||||||
|
const widget = readFileSync(widgetPath, "utf8");
|
||||||
|
|
||||||
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
|
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
|
||||||
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
||||||
@@ -101,10 +103,18 @@ assert.match(participantGrid, /minimumSlots=\{3\}/);
|
|||||||
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
|
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
|
||||||
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
|
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
|
||||||
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
|
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
|
||||||
assert.match(participantGrid, /disabledReason: copyDisabledReason/);
|
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : copyDisabledReason/);
|
||||||
assert.match(participantGrid, /disabledReason: deliveryDisabledReason/);
|
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : deliveryDisabledReason/);
|
||||||
assert.match(participantGrid, /disabledReason: revokeDisabledReason/);
|
assert.match(participantGrid, /disabledReason: saving \? I18N\.saving : revokeDisabledReason/);
|
||||||
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
|
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
|
||||||
|
assert.match(page, /setConsequentialAction\(\{ kind: "close"/);
|
||||||
|
assert.match(page, /setConsequentialAction\(\{ kind: "reminder"/);
|
||||||
|
assert.match(page, /setConsequentialAction\(\{ kind: "holds"/);
|
||||||
|
assert.match(page, /setConsequentialAction\(\{ kind: "final-event"/);
|
||||||
|
assert.match(page, /setDecisionTarget\(\{ requestId: selected\.id, slot \}\)/);
|
||||||
|
assert.match(page, /open=\{Boolean\(consequentialAction && consequentialActionCopy\)\}/);
|
||||||
|
assert.match(page, /open=\{Boolean\(decisionTarget\)\}/);
|
||||||
|
assert.match(page, /disabledReason=\{saving \? I18N\.saving/);
|
||||||
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
|
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
|
||||||
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
|
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
|
||||||
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
|
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
|
||||||
@@ -155,7 +165,11 @@ assert.match(page, /Promise\.allSettled/);
|
|||||||
|
|
||||||
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
|
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
|
||||||
assert.match(moduleSource, /SchedulingPublicPage/);
|
assert.match(moduleSource, /SchedulingPublicPage/);
|
||||||
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*from "@govoplan\/core-webui"/);
|
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*DocumentationHelpLink,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*PasswordField,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
|
assert.match(publicPage, /<PasswordField[\s\S]*autoComplete="current-password"/);
|
||||||
|
assert.doesNotMatch(publicPage, /<input[\s\S]{0,120}type="password"/);
|
||||||
|
assert.match(publicPage, /topicId: "scheduling\.find-and-decide-meeting-time"/);
|
||||||
|
assert.match(publicPage, /disabledReason=\{saving \? I18N\.saving/);
|
||||||
assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
|
assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
|
||||||
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
|
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
|
||||||
assert.match(publicPage, /option_revision: slot\.revision/);
|
assert.match(publicPage, /option_revision: slot\.revision/);
|
||||||
@@ -163,4 +177,9 @@ assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
|
|||||||
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
|
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
|
||||||
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
|
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
|
||||||
|
|
||||||
|
assert.match(widget, /DocumentationHelpLink/);
|
||||||
|
assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/);
|
||||||
|
assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/);
|
||||||
|
assert.doesNotMatch(widget, /Loading scheduling requests|Open scheduling|No scheduling requests are awaiting responses/);
|
||||||
|
|
||||||
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");
|
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ type InvitationRevokeTarget = {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
participant: SchedulingParticipant;
|
participant: SchedulingParticipant;
|
||||||
};
|
};
|
||||||
|
type ConsequentialAction = {
|
||||||
|
kind: "close" | "reminder" | "holds" | "final-event";
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
type DecisionTarget = {
|
||||||
|
requestId: string;
|
||||||
|
slot: SchedulingCandidateSlot;
|
||||||
|
};
|
||||||
|
|
||||||
const I18N = {
|
const I18N = {
|
||||||
actions: "i18n:govoplan-core.actions.c3cd636a",
|
actions: "i18n:govoplan-core.actions.c3cd636a",
|
||||||
@@ -133,6 +141,7 @@ const I18N = {
|
|||||||
configuredCalendar: "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5",
|
configuredCalendar: "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5",
|
||||||
calendarDescription: "i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa",
|
calendarDescription: "i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa",
|
||||||
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
|
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
|
||||||
|
finalEventEffect: "i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252",
|
||||||
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
|
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
|
||||||
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
|
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
|
||||||
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
|
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
|
||||||
@@ -143,12 +152,14 @@ const I18N = {
|
|||||||
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
|
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
|
||||||
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
|
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
|
||||||
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
|
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
|
||||||
|
closePollEffect: "i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519",
|
||||||
closed: "i18n:govoplan-scheduling.closed.88d86b77",
|
closed: "i18n:govoplan-scheduling.closed.88d86b77",
|
||||||
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
|
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
|
||||||
description: "i18n:govoplan-scheduling.description.55f8ebc8",
|
description: "i18n:govoplan-scheduling.description.55f8ebc8",
|
||||||
determined: "i18n:govoplan-scheduling.determined.9f23293d",
|
determined: "i18n:govoplan-scheduling.determined.9f23293d",
|
||||||
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
|
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
|
||||||
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
|
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
|
||||||
|
decideOn: "i18n:govoplan-scheduling.decide_on_value.196409cd",
|
||||||
discard: "i18n:govoplan-scheduling.discard.36fff63c",
|
discard: "i18n:govoplan-scheduling.discard.36fff63c",
|
||||||
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
|
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
|
||||||
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
|
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
|
||||||
@@ -157,6 +168,7 @@ const I18N = {
|
|||||||
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
|
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
|
||||||
free: "i18n:govoplan-scheduling.free.75f52718",
|
free: "i18n:govoplan-scheduling.free.75f52718",
|
||||||
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
|
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
|
||||||
|
holdsEffect: "i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884",
|
||||||
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
|
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
|
||||||
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
|
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
|
||||||
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
|
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
|
||||||
@@ -216,6 +228,7 @@ const I18N = {
|
|||||||
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
|
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
|
||||||
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
|
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
|
||||||
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
|
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
|
||||||
|
reminderEffect: "i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797",
|
||||||
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
|
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
|
||||||
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
|
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
|
||||||
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
|
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
|
||||||
@@ -301,6 +314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
|
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
|
||||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||||
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
|
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
|
||||||
|
const [consequentialAction, setConsequentialAction] = useState<ConsequentialAction | null>(null);
|
||||||
|
const [decisionTarget, setDecisionTarget] = useState<DecisionTarget | null>(null);
|
||||||
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
|
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
|
||||||
const detailLoadSequence = useRef(0);
|
const detailLoadSequence = useRef(0);
|
||||||
|
|
||||||
@@ -365,6 +380,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
() => availabilityComment !== savedAvailabilityComment || !availabilityValuesEqual(availability, savedAvailability),
|
() => availabilityComment !== savedAvailabilityComment || !availabilityValuesEqual(availability, savedAvailability),
|
||||||
[availability, availabilityComment, savedAvailability, savedAvailabilityComment]
|
[availability, availabilityComment, savedAvailability, savedAvailabilityComment]
|
||||||
);
|
);
|
||||||
|
const consequentialActionCopy = consequentialAction
|
||||||
|
? schedulingActionConfirmation(consequentialAction.kind)
|
||||||
|
: null;
|
||||||
const editorOriginal = editorMode === "edit"
|
const editorOriginal = editorMode === "edit"
|
||||||
? requests.find((request) => request.id === editingRequestId) ?? null
|
? requests.find((request) => request.id === editingRequestId) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -798,6 +816,31 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
|
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmSchedulingAction() {
|
||||||
|
if (!consequentialAction || selected?.id !== consequentialAction.requestId) return;
|
||||||
|
const action = consequentialAction;
|
||||||
|
setConsequentialAction(null);
|
||||||
|
if (action.kind === "close") {
|
||||||
|
await runAction(() => closeSchedulingRequest(settings, action.requestId));
|
||||||
|
} else if (action.kind === "reminder") {
|
||||||
|
await runAction(() => createSchedulingNotifications(settings, action.requestId, "reminder"));
|
||||||
|
} else if (action.kind === "holds") {
|
||||||
|
await runAction(() => createSchedulingHolds(settings, action.requestId));
|
||||||
|
} else {
|
||||||
|
await runAction(() => createSchedulingCalendarEvent(settings, action.requestId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSchedulingDecision() {
|
||||||
|
if (!decisionTarget || selected?.id !== decisionTarget.requestId) return;
|
||||||
|
const target = decisionTarget;
|
||||||
|
setDecisionTarget(null);
|
||||||
|
await runAction(() => decideSchedulingRequest(settings, target.requestId, {
|
||||||
|
slot_id: target.slot.id,
|
||||||
|
handoff_to_calendar: selected.create_calendar_event_on_decision
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
|
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await persistAvailability();
|
await persistAvailability();
|
||||||
@@ -859,9 +902,20 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
label={I18N.refresh}
|
label={I18N.refresh}
|
||||||
icon={<RefreshCw aria-hidden="true" size={16} />}
|
icon={<RefreshCw aria-hidden="true" size={16} />}
|
||||||
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
|
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
|
||||||
disabled={loading || saving} />
|
disabled={loading || saving}
|
||||||
|
disabledReason={saving ? I18N.saving : undefined} />
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
topicId: "scheduling.find-and-decide-meeting-time",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
{canCreateOrWrite ? (
|
{canCreateOrWrite ? (
|
||||||
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={beginCreate}
|
||||||
|
disabled={saving}
|
||||||
|
disabledReason={saving ? I18N.saving : undefined}>
|
||||||
<Plus aria-hidden="true" size={16} /> {I18N.add}
|
<Plus aria-hidden="true" size={16} /> {I18N.add}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -909,8 +963,24 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="scheduling-page-actions">
|
<div className="scheduling-page-actions">
|
||||||
<Button type="button" onClick={discardEditor} disabled={saving}>{I18N.discard}</Button>
|
<DocumentationHelpLink
|
||||||
<Button type="submit" form="scheduling-editor-form" variant="primary" disabled={saving || !canCreateOrWrite}>
|
reference={{
|
||||||
|
topicId: "scheduling.find-and-decide-meeting-time",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={discardEditor}
|
||||||
|
disabled={saving}
|
||||||
|
disabledReason={saving ? I18N.saving : undefined}>
|
||||||
|
{I18N.discard}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="scheduling-editor-form"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving || !canCreateOrWrite}
|
||||||
|
disabledReason={saving ? I18N.saving : !canCreateOrWrite ? I18N.unavailable : undefined}>
|
||||||
<Save aria-hidden="true" size={16} /> {saving ? I18N.saving : I18N.save}
|
<Save aria-hidden="true" size={16} /> {saving ? I18N.saving : I18N.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1092,7 +1162,11 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
documentationType: "user"
|
documentationType: "user"
|
||||||
}} />
|
}} />
|
||||||
{canEditSelected ? (
|
{canEditSelected ? (
|
||||||
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => beginEdit(selected)}
|
||||||
|
disabled={saving}
|
||||||
|
disabledReason={saving ? I18N.saving : undefined}>
|
||||||
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
|
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1101,8 +1175,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
request={selected}
|
request={selected}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onOpen={() => void runAction(() => openSchedulingRequest(settings, selected.id))}
|
onOpen={() => void runAction(() => openSchedulingRequest(settings, selected.id))}
|
||||||
onClose={() => requestNavigation(() => void runAction(() => closeSchedulingRequest(settings, selected.id)))}
|
onClose={() => requestNavigation(() => setConsequentialAction({ kind: "close", requestId: selected.id }))}
|
||||||
onReminder={() => void runAction(() => createSchedulingNotifications(settings, selected.id, "reminder"))} />
|
onReminder={() => setConsequentialAction({ kind: "reminder", requestId: selected.id })} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}>
|
)}>
|
||||||
@@ -1154,7 +1228,16 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
form="scheduling-response-form"
|
form="scheduling-response-form"
|
||||||
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
|
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
|
||||||
|
disabledReason={saving
|
||||||
|
? I18N.saving
|
||||||
|
: availabilityLoading
|
||||||
|
? I18N.loading
|
||||||
|
: !canRespond
|
||||||
|
? I18N.unavailable
|
||||||
|
: selectedParticipant.status === "responded" && !selected.allow_participant_updates
|
||||||
|
? I18N.responseRecorded
|
||||||
|
: undefined}>
|
||||||
<Send aria-hidden="true" size={16} />
|
<Send aria-hidden="true" size={16} />
|
||||||
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
|
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1210,10 +1293,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
saving={saving}
|
saving={saving}
|
||||||
allowMaybe={selected.allow_maybe}
|
allowMaybe={selected.allow_maybe}
|
||||||
maxParticipantsPerOption={selected.max_participants_per_option}
|
maxParticipantsPerOption={selected.max_participants_per_option}
|
||||||
onDecide={(slot) => void runAction(() => decideSchedulingRequest(settings, selected.id, {
|
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
|
||||||
slot_id: slot.id,
|
|
||||||
handoff_to_calendar: selected.create_calendar_event_on_decision
|
|
||||||
}))} />
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
|
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
|
||||||
@@ -1225,7 +1305,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving || !canReadAvailability}
|
disabled={saving || !canReadAvailability}
|
||||||
disabledReason={!canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
|
disabledReason={saving ? I18N.saving : !canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
|
||||||
onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected.id))}>
|
onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected.id))}>
|
||||||
<RefreshCw aria-hidden="true" size={16} /> {I18N.checkFreeBusy}
|
<RefreshCw aria-hidden="true" size={16} /> {I18N.checkFreeBusy}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1234,8 +1314,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving || !canWriteCalendarEvent}
|
disabled={saving || !canWriteCalendarEvent}
|
||||||
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
|
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
|
||||||
onClick={() => void runAction(() => createSchedulingHolds(settings, selected.id))}>
|
onClick={() => setConsequentialAction({ kind: "holds", requestId: selected.id })}>
|
||||||
<Clock aria-hidden="true" size={16} /> {I18N.holds}
|
<Clock aria-hidden="true" size={16} /> {I18N.holds}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1243,8 +1323,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={saving || !canWriteCalendarEvent}
|
disabled={saving || !canWriteCalendarEvent}
|
||||||
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
|
disabledReason={saving ? I18N.saving : !canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
|
||||||
onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected.id))}>
|
onClick={() => setConsequentialAction({ kind: "final-event", requestId: selected.id })}>
|
||||||
<CalendarCheck aria-hidden="true" size={16} /> {I18N.finalEventLabel}
|
<CalendarCheck aria-hidden="true" size={16} /> {I18N.finalEventLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1326,6 +1406,25 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
busy={saving}
|
busy={saving}
|
||||||
onCancel={() => setRevokeInvitationTarget(null)}
|
onCancel={() => setRevokeInvitationTarget(null)}
|
||||||
onConfirm={() => void confirmRevokeInvitation()} />
|
onConfirm={() => void confirmRevokeInvitation()} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(consequentialAction && consequentialActionCopy)}
|
||||||
|
title={consequentialActionCopy?.title ?? I18N.unavailable}
|
||||||
|
message={consequentialActionCopy?.message ?? I18N.unavailable}
|
||||||
|
confirmLabel={consequentialActionCopy?.confirmLabel}
|
||||||
|
tone={consequentialAction?.kind === "close" ? "danger" : "default"}
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => setConsequentialAction(null)}
|
||||||
|
onConfirm={() => void confirmSchedulingAction()} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(decisionTarget)}
|
||||||
|
title={I18N.decision}
|
||||||
|
message={i18nMessage(I18N.decideOn, { value0: decisionTarget?.slot.label ?? "" })}
|
||||||
|
confirmLabel={decisionTarget
|
||||||
|
? i18nMessage(I18N.decideOn, { value0: decisionTarget.slot.label })
|
||||||
|
: I18N.decision}
|
||||||
|
busy={saving}
|
||||||
|
onCancel={() => setDecisionTarget(null)}
|
||||||
|
onConfirm={() => void confirmSchedulingDecision()} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1613,6 +1712,23 @@ function ParticipationStats({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
} {
|
||||||
|
if (kind === "close") {
|
||||||
|
return { title: I18N.closePoll, message: I18N.closePollEffect, confirmLabel: I18N.closePoll };
|
||||||
|
}
|
||||||
|
if (kind === "reminder") {
|
||||||
|
return { title: I18N.reminder, message: I18N.reminderEffect, confirmLabel: I18N.reminder };
|
||||||
|
}
|
||||||
|
if (kind === "holds") {
|
||||||
|
return { title: I18N.holds, message: I18N.holdsEffect, confirmLabel: I18N.holds };
|
||||||
|
}
|
||||||
|
return { title: I18N.finalEventLabel, message: I18N.finalEventEffect, confirmLabel: I18N.finalEventLabel };
|
||||||
|
}
|
||||||
|
|
||||||
function LifecycleActions({
|
function LifecycleActions({
|
||||||
request,
|
request,
|
||||||
saving,
|
saving,
|
||||||
@@ -1627,13 +1743,13 @@ function LifecycleActions({
|
|||||||
onReminder: () => void;
|
onReminder: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (request.status === "draft") {
|
if (request.status === "draft") {
|
||||||
return <Button type="button" variant="primary" disabled={saving} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
|
return <Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
|
||||||
}
|
}
|
||||||
if (request.status === "collecting") {
|
if (request.status === "collecting") {
|
||||||
return (
|
return (
|
||||||
<div className="scheduling-actions">
|
<div className="scheduling-actions">
|
||||||
<Button type="button" disabled={saving} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
|
<Button type="button" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
|
||||||
<Button type="button" variant="primary" disabled={saving} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
|
<Button type="button" variant="primary" disabled={saving} disabledReason={saving ? I18N.saving : undefined} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1859,7 +1975,7 @@ function CandidateSlotsGrid({
|
|||||||
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
|
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
|
||||||
icon: <Check aria-hidden="true" size={16} />,
|
icon: <Check aria-hidden="true" size={16} />,
|
||||||
disabled: saving || !decisionEnabled,
|
disabled: saving || !decisionEnabled,
|
||||||
disabledReason: !decisionEnabled ? I18N.decideUnavailable : undefined,
|
disabledReason: saving ? I18N.saving : !decisionEnabled ? I18N.decideUnavailable : undefined,
|
||||||
onClick: () => onDecide(slot)
|
onClick: () => onDecide(slot)
|
||||||
}]} />
|
}]} />
|
||||||
} satisfies DataGridColumn<SchedulingCandidateSlot>] : [])
|
} satisfies DataGridColumn<SchedulingCandidateSlot>] : [])
|
||||||
@@ -1925,7 +2041,7 @@ function ParticipantsGrid({
|
|||||||
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
|
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
|
||||||
icon: <Copy aria-hidden="true" size={16} />,
|
icon: <Copy aria-hidden="true" size={16} />,
|
||||||
disabled: saving || Boolean(copyDisabledReason),
|
disabled: saving || Boolean(copyDisabledReason),
|
||||||
disabledReason: copyDisabledReason,
|
disabledReason: saving ? I18N.saving : copyDisabledReason,
|
||||||
onClick: () => onCopy(participant)
|
onClick: () => onCopy(participant)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1933,7 +2049,7 @@ function ParticipantsGrid({
|
|||||||
label: i18nMessage(I18N.sendInvitation, { value0: label }),
|
label: i18nMessage(I18N.sendInvitation, { value0: label }),
|
||||||
icon: <Send aria-hidden="true" size={16} />,
|
icon: <Send aria-hidden="true" size={16} />,
|
||||||
disabled: saving || Boolean(deliveryDisabledReason),
|
disabled: saving || Boolean(deliveryDisabledReason),
|
||||||
disabledReason: deliveryDisabledReason,
|
disabledReason: saving ? I18N.saving : deliveryDisabledReason,
|
||||||
onClick: () => onSend(participant)
|
onClick: () => onSend(participant)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1942,7 +2058,7 @@ function ParticipantsGrid({
|
|||||||
icon: <Link2Off aria-hidden="true" size={16} />,
|
icon: <Link2Off aria-hidden="true" size={16} />,
|
||||||
variant: "danger",
|
variant: "danger",
|
||||||
disabled: saving || Boolean(revokeDisabledReason),
|
disabled: saving || Boolean(revokeDisabledReason),
|
||||||
disabledReason: revokeDisabledReason,
|
disabledReason: saving ? I18N.saving : revokeDisabledReason,
|
||||||
onClick: () => onRevoke(participant)
|
onClick: () => onRevoke(participant)
|
||||||
}
|
}
|
||||||
]} />
|
]} />
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
FormField,
|
FormField,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
|
PasswordField,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo
|
type AuthInfo
|
||||||
@@ -44,6 +46,7 @@ const I18N = {
|
|||||||
password: "i18n:govoplan-scheduling.guest_password.94545e82",
|
password: "i18n:govoplan-scheduling.guest_password.94545e82",
|
||||||
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
|
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
|
||||||
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
|
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
|
||||||
|
saving: "i18n:govoplan-scheduling.saving.56a2285c",
|
||||||
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
|
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
|
||||||
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
|
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
|
||||||
} as const;
|
} as const;
|
||||||
@@ -166,7 +169,15 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!response && !loading && (
|
{!response && !loading && (
|
||||||
<Card title={I18N.accessDetails}>
|
<Card
|
||||||
|
title={I18N.accessDetails}
|
||||||
|
actions={(
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
topicId: "scheduling.find-and-decide-meeting-time",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
|
)}>
|
||||||
<form className="scheduling-public-access-form" onSubmit={openRequest}>
|
<form className="scheduling-public-access-form" onSubmit={openRequest}>
|
||||||
<p className="muted">{I18N.accessHelp}</p>
|
<p className="muted">{I18N.accessHelp}</p>
|
||||||
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
|
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
|
||||||
@@ -180,16 +191,20 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label={I18N.password}>
|
<FormField label={I18N.password}>
|
||||||
<input
|
<PasswordField
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onValueChange={setPassword} />
|
||||||
/>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="scheduling-public-actions">
|
<div className="scheduling-public-actions">
|
||||||
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={loading}
|
||||||
|
disabledReason={loading ? I18N.loading : undefined}>
|
||||||
|
{I18N.accessRequest}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -197,7 +212,15 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
|
|||||||
|
|
||||||
{response && (
|
{response && (
|
||||||
<form className="scheduling-public-content" onSubmit={saveResponse}>
|
<form className="scheduling-public-content" onSubmit={saveResponse}>
|
||||||
<Card title={response.title}>
|
<Card
|
||||||
|
title={response.title}
|
||||||
|
actions={(
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
topicId: "scheduling.find-and-decide-meeting-time",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
|
)}>
|
||||||
{response.description && <p className="scheduling-public-description">{response.description}</p>}
|
{response.description && <p className="scheduling-public-description">{response.description}</p>}
|
||||||
<dl className="scheduling-public-summary">
|
<dl className="scheduling-public-summary">
|
||||||
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
|
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
|
||||||
@@ -263,7 +286,13 @@ export default function SchedulingPublicPage({ settings, auth }: SchedulingPubli
|
|||||||
)}
|
)}
|
||||||
{collecting && (
|
{collecting && (
|
||||||
<div className="scheduling-public-actions">
|
<div className="scheduling-public-actions">
|
||||||
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saving}
|
||||||
|
disabledReason={saving ? I18N.saving : undefined}>
|
||||||
|
{I18N.submit}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, type ReactNode } from "react";
|
||||||
import { CalendarClock } from "lucide-react";
|
import { CalendarClock } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
DashboardWidgetList,
|
DashboardWidgetList,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DocumentationHelpLink,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
formatDateTime,
|
||||||
useDashboardWidgetData,
|
useDashboardWidgetData,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type DashboardWidgetConfiguration
|
type DashboardWidgetConfiguration
|
||||||
@@ -15,6 +17,17 @@ import {
|
|||||||
type SchedulingRequest
|
type SchedulingRequest
|
||||||
} from "../../api/scheduling";
|
} from "../../api/scheduling";
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
|
||||||
|
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
|
||||||
|
draft: "i18n:govoplan-scheduling.draft.23d33e22",
|
||||||
|
empty: "i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664",
|
||||||
|
loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
|
||||||
|
open: "i18n:govoplan-scheduling.open.cf9b7706",
|
||||||
|
openScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
|
||||||
|
responded: "i18n:govoplan-scheduling.responded.4f218211"
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default function SchedulingRequestsWidget({
|
export default function SchedulingRequestsWidget({
|
||||||
settings,
|
settings,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
@@ -43,14 +56,14 @@ export default function SchedulingRequestsWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingFrame loading={loading} label="Loading scheduling requests">
|
<LoadingFrame loading={loading} label={I18N.loading}>
|
||||||
{error && (
|
{error && (
|
||||||
<DismissibleAlert tone="warning" resetKey={error}>
|
<DismissibleAlert tone="warning" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
)}
|
)}
|
||||||
<DashboardWidgetList
|
<DashboardWidgetList
|
||||||
emptyText="No scheduling requests are awaiting responses."
|
emptyText={I18N.empty}
|
||||||
items={(requests ?? []).map((request) => ({
|
items={(requests ?? []).map((request) => ({
|
||||||
id: request.id,
|
id: request.id,
|
||||||
title: request.title,
|
title: request.title,
|
||||||
@@ -60,15 +73,20 @@ export default function SchedulingRequestsWidget({
|
|||||||
trailing: (
|
trailing: (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={request.status}
|
status={request.status}
|
||||||
label={request.status === "collecting" ? "Open" : "Draft"}
|
label={request.status === "collecting" ? I18N.open : I18N.draft}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
to: "/scheduling"
|
to: `/scheduling?request_id=${encodeURIComponent(request.id)}`
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<div className="dashboard-contribution-footer">
|
<div className="dashboard-contribution-footer">
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
topicId: "scheduling.find-and-decide-meeting-time",
|
||||||
|
documentationType: "user"
|
||||||
|
}} />
|
||||||
<Link className="btn btn-secondary" to="/scheduling">
|
<Link className="btn btn-secondary" to="/scheduling">
|
||||||
Open scheduling
|
{I18N.openScheduling}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
@@ -94,17 +112,17 @@ function compareRequests(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function responseLabel(request: SchedulingRequest): string {
|
function responseLabel(request: SchedulingRequest): ReactNode {
|
||||||
const responded = request.participant_aggregate.status_counts.responded ?? 0;
|
const responded = request.participant_aggregate.status_counts.responded ?? 0;
|
||||||
return `${responded} of ${request.participant_aggregate.total} responded`;
|
return <>{responded}/{request.participant_aggregate.total} {I18N.responded}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deadlineLabel(request: SchedulingRequest): string {
|
function deadlineLabel(request: SchedulingRequest): ReactNode {
|
||||||
if (!request.deadline_at) return `${request.slots.length} options`;
|
if (!request.deadline_at) return <>{request.slots.length} {I18N.candidateSlots}</>;
|
||||||
return `Due ${new Intl.DateTimeFormat(undefined, {
|
return <>{I18N.deadline}: {formatDateTime(request.deadline_at, {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short"
|
month: "short"
|
||||||
}).format(new Date(request.deadline_at))}`;
|
})}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberSetting(
|
function numberSetting(
|
||||||
|
|||||||
Reference in New Issue
Block a user