feat(scheduling): render signed public response links
This commit is contained in:
@@ -15,6 +15,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
PublicFrontendRoute,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -162,6 +163,13 @@ manifest = ModuleManifest(
|
|||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
package_name="@govoplan/scheduling-webui",
|
package_name="@govoplan/scheduling-webui",
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/scheduling/public/:requestId/:token",
|
||||||
|
component="SchedulingPublicPage",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||||
),
|
),
|
||||||
route_factory=_scheduling_router,
|
route_factory=_scheduling_router,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ class SchedulingManifestTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNotNone(manifest.frontend)
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertEqual(
|
||||||
|
["/scheduling/public/:requestId/:token"],
|
||||||
|
[route.path for route in manifest.frontend.public_routes],
|
||||||
|
)
|
||||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.response_collection", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { readFileSync } from "node:fs";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
|
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.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 page = readFileSync(pagePath, "utf8");
|
const page = readFileSync(pagePath, "utf8");
|
||||||
|
const publicPage = readFileSync(publicPagePath, "utf8");
|
||||||
const api = readFileSync(apiPath, "utf8");
|
const api = readFileSync(apiPath, "utf8");
|
||||||
|
const moduleSource = readFileSync(modulePath, "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"\)/);
|
||||||
@@ -112,4 +116,14 @@ assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(reques
|
|||||||
assert.match(page, /useSearchParams\(\)/);
|
assert.match(page, /useSearchParams\(\)/);
|
||||||
assert.match(page, /Promise\.allSettled/);
|
assert.match(page, /Promise\.allSettled/);
|
||||||
|
|
||||||
console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract.");
|
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
|
||||||
|
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, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
|
||||||
|
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
|
||||||
|
assert.match(publicPage, /option_revision: slot\.revision/);
|
||||||
|
assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
|
||||||
|
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
|
||||||
|
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
|
||||||
|
|
||||||
|
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");
|
||||||
|
|||||||
268
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal file
268
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingFrame,
|
||||||
|
formatDateTime,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getPublicSchedulingParticipation,
|
||||||
|
submitPublicSchedulingParticipation,
|
||||||
|
type SchedulingAvailabilityValue,
|
||||||
|
type SchedulingPublicParticipationAccessPayload,
|
||||||
|
type SchedulingPublicParticipationResponse
|
||||||
|
} from "../../api/scheduling";
|
||||||
|
import { applySchedulingAvailabilityChoice } from "./schedulingViewModel";
|
||||||
|
|
||||||
|
type SchedulingPublicPageProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const I18N = {
|
||||||
|
accessDetails: "i18n:govoplan-scheduling.access_details.79c06b89",
|
||||||
|
accessHelp: "i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c",
|
||||||
|
accessRequest: "i18n:govoplan-scheduling.open_scheduling_request.31829cce",
|
||||||
|
alreadySubmitted: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe",
|
||||||
|
answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
|
||||||
|
available: "i18n:govoplan-scheduling.available.7c62a142",
|
||||||
|
backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
|
||||||
|
comment: "i18n:govoplan-scheduling.comment.d03495b1",
|
||||||
|
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
|
||||||
|
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
|
||||||
|
invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197",
|
||||||
|
loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b",
|
||||||
|
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
|
||||||
|
noLongerOpen: "i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a",
|
||||||
|
password: "i18n:govoplan-scheduling.guest_password.94545e82",
|
||||||
|
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
|
||||||
|
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
|
||||||
|
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
|
||||||
|
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function accessPayload(email: string, password: string): SchedulingPublicParticipationAccessPayload {
|
||||||
|
return {
|
||||||
|
participant_email: email.trim() || null,
|
||||||
|
password: password || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialAvailability(response: SchedulingPublicParticipationResponse): Record<string, SchedulingAvailabilityValue | ""> {
|
||||||
|
const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value]));
|
||||||
|
return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function newIdempotencyKey(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||||
|
return `scheduling-response-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchedulingPublicPage({ settings, auth }: SchedulingPublicPageProps) {
|
||||||
|
const { requestId = "", token = "" } = useParams();
|
||||||
|
const [response, setResponse] = useState<SchedulingPublicParticipationResponse | null>(null);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [accessAttempted, setAccessAttempted] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const slotIds = useMemo(() => response?.slots.map((slot) => slot.id) ?? [], [response]);
|
||||||
|
const collecting = response?.status === "collecting";
|
||||||
|
|
||||||
|
function applyResponse(next: SchedulingPublicParticipationResponse) {
|
||||||
|
setResponse(next);
|
||||||
|
setAvailability(initialAvailability(next));
|
||||||
|
setComment(next.comment ?? "");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setResponse(null);
|
||||||
|
setAccessAttempted(false);
|
||||||
|
setError("");
|
||||||
|
void getPublicSchedulingParticipation(settings, requestId, token, {})
|
||||||
|
.then((next) => {
|
||||||
|
if (!cancelled) applyResponse(next);
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [requestId, settings.apiBaseUrl, settings.apiKey, token]);
|
||||||
|
|
||||||
|
async function openRequest(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setAccessAttempted(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
applyResponse(await getPublicSchedulingParticipation(settings, requestId, token, accessPayload(email, password)));
|
||||||
|
} catch {
|
||||||
|
setResponse(null);
|
||||||
|
setError(I18N.invalidAccess);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResponse(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!response) return;
|
||||||
|
if (!response.slots.some((slot) => availability[slot.id])) {
|
||||||
|
setError(I18N.answerRequired);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
try {
|
||||||
|
const next = await submitPublicSchedulingParticipation(settings, requestId, token, {
|
||||||
|
...accessPayload(email, password),
|
||||||
|
answers: response.slots
|
||||||
|
.filter((slot) => availability[slot.id])
|
||||||
|
.map((slot) => ({
|
||||||
|
slot_id: slot.id,
|
||||||
|
value: availability[slot.id] as SchedulingAvailabilityValue,
|
||||||
|
option_revision: slot.revision
|
||||||
|
})),
|
||||||
|
comment: response.allow_comments ? comment : null,
|
||||||
|
idempotency_key: newIdempotencyKey()
|
||||||
|
});
|
||||||
|
applyResponse(next);
|
||||||
|
setSuccess(I18N.saved);
|
||||||
|
} catch {
|
||||||
|
setError(I18N.invalidAccess);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="scheduling-public-page">
|
||||||
|
<LoadingFrame loading={loading} label={I18N.loading}>
|
||||||
|
{auth && (
|
||||||
|
<div className="scheduling-public-deep-link">
|
||||||
|
<Link className="btn btn-secondary" to={`/scheduling?request_id=${encodeURIComponent(requestId)}`}>
|
||||||
|
{I18N.backToScheduling}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!response && !loading && (
|
||||||
|
<Card title={I18N.accessDetails}>
|
||||||
|
<form className="scheduling-public-access-form" onSubmit={openRequest}>
|
||||||
|
<p className="muted">{I18N.accessHelp}</p>
|
||||||
|
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
|
||||||
|
<div className="form-grid two-col">
|
||||||
|
<FormField label={I18N.email}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label={I18N.password}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="scheduling-public-actions">
|
||||||
|
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<form className="scheduling-public-content" onSubmit={saveResponse}>
|
||||||
|
<Card title={response.title}>
|
||||||
|
{response.description && <p className="scheduling-public-description">{response.description}</p>}
|
||||||
|
<dl className="scheduling-public-summary">
|
||||||
|
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
|
||||||
|
{response.deadline_at && <><dt>{I18N.deadline}</dt><dd>{formatDateTime(response.deadline_at)}</dd></>}
|
||||||
|
</dl>
|
||||||
|
{!collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.noLongerOpen}</DismissibleAlert>}
|
||||||
|
{response.has_response && collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.alreadySubmitted}</DismissibleAlert>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
|
||||||
|
{success && <DismissibleAlert tone="success">{success}</DismissibleAlert>}
|
||||||
|
|
||||||
|
<Card title={I18N.response}>
|
||||||
|
<div className="scheduling-public-slots">
|
||||||
|
{response.slots.map((slot) => (
|
||||||
|
<fieldset className="scheduling-public-slot" key={slot.id} disabled={!collecting || saving}>
|
||||||
|
<legend>{slot.label}</legend>
|
||||||
|
<p>{formatDateTime(slot.start_at)} – {formatDateTime(slot.end_at)}</p>
|
||||||
|
{slot.description && <p className="muted">{slot.description}</p>}
|
||||||
|
{(slot.location || response.location) && <p className="muted">{slot.location || response.location}</p>}
|
||||||
|
<div className="scheduling-public-choice-group">
|
||||||
|
{([
|
||||||
|
["available", I18N.available],
|
||||||
|
...(response.allow_maybe ? [["maybe", I18N.maybe] as const] : []),
|
||||||
|
["unavailable", I18N.unavailable]
|
||||||
|
] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => (
|
||||||
|
<label key={value}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`slot-${slot.id}`}
|
||||||
|
value={value}
|
||||||
|
checked={availability[slot.id] === value}
|
||||||
|
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
|
||||||
|
slotIds,
|
||||||
|
current,
|
||||||
|
slot.id,
|
||||||
|
value,
|
||||||
|
response.single_choice
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{response.allow_comments && (
|
||||||
|
<FormField label={I18N.comment}>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
maxLength={4000}
|
||||||
|
disabled={!collecting || saving}
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
{collecting && (
|
||||||
|
<div className="scheduling-public-actions">
|
||||||
|
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</LoadingFrame>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
export const generatedTranslations = {
|
export const generatedTranslations = {
|
||||||
en: {
|
en: {
|
||||||
|
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
|
||||||
|
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Enter the details supplied with the invitation. For privacy, invalid and expired links use the same response.",
|
||||||
|
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Loading scheduling request…",
|
||||||
|
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling",
|
||||||
|
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request",
|
||||||
|
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Response deadline",
|
||||||
|
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "This scheduling link is invalid, expired, or the access details do not match.",
|
||||||
|
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "This scheduling request is no longer accepting responses.",
|
||||||
|
"i18n:govoplan-scheduling.your_availability.f86c8215": "Your availability",
|
||||||
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
|
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
|
||||||
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
|
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
|
||||||
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
|
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
|
||||||
@@ -140,6 +149,15 @@ export const generatedTranslations = {
|
|||||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
|
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
|
||||||
|
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Geben Sie die mit der Einladung übermittelten Daten ein. Aus Datenschutzgründen wird für ungültige und abgelaufene Links dieselbe Meldung angezeigt.",
|
||||||
|
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Terminanfrage wird geladen …",
|
||||||
|
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen",
|
||||||
|
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen",
|
||||||
|
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Antwortfrist",
|
||||||
|
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "Dieser Terminlink ist ungültig oder abgelaufen, oder die Zugangsdaten stimmen nicht überein.",
|
||||||
|
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "Diese Terminanfrage nimmt keine Antworten mehr an.",
|
||||||
|
"i18n:govoplan-scheduling.your_availability.f86c8215": "Ihre Verfügbarkeit",
|
||||||
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",
|
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",
|
||||||
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
|
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
|
||||||
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
|
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
|
|||||||
import "./styles/scheduling.css";
|
import "./styles/scheduling.css";
|
||||||
|
|
||||||
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
|
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
|
||||||
|
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
|
||||||
|
|
||||||
const scheduleRead = ["scheduling:schedule:read"];
|
const scheduleRead = ["scheduling:schedule:read"];
|
||||||
|
|
||||||
@@ -17,6 +18,13 @@ export const schedulingModule: PlatformWebModule = {
|
|||||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
|
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
||||||
|
],
|
||||||
|
publicRoutes: [
|
||||||
|
{
|
||||||
|
path: "/scheduling/public/:requestId/:token",
|
||||||
|
order: 10,
|
||||||
|
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,110 @@
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scheduling-public-page {
|
||||||
|
width: min(920px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-page .loading-frame {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-content,
|
||||||
|
.scheduling-public-access-form,
|
||||||
|
.scheduling-public-slots {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-deep-link,
|
||||||
|
.scheduling-public-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-actions {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
gap: 6px 14px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-summary dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-summary dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-slot {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-slot legend {
|
||||||
|
padding: 0 4px;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-slot p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-choice-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-choice-group label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-choice-group label:has(input:checked) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.scheduling-public-page {
|
||||||
|
width: min(100% - 20px, 920px);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-page .form-grid.two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-public-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.scheduling-page *,
|
.scheduling-page *,
|
||||||
.scheduling-page *::before,
|
.scheduling-page *::before,
|
||||||
.scheduling-page *::after {
|
.scheduling-page *::after {
|
||||||
|
|||||||
Reference in New Issue
Block a user