Compare commits
3 Commits
2f1b7fb6b8
...
887e064ae9
| Author | SHA1 | Date | |
|---|---|---|---|
| 887e064ae9 | |||
| fc418e63ac | |||
| de7e68c97a |
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
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.modules import (
|
||||
DocumentationTopic,
|
||||
@@ -17,6 +18,7 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
|
||||
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||
|
||||
MODULE_ID = "scheduling"
|
||||
@@ -128,7 +130,12 @@ manifest = ModuleManifest(
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("poll",),
|
||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_CALENDAR_SCHEDULING,
|
||||
),
|
||||
required_capabilities=(CAPABILITY_POLL_SCHEDULING,),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", version="0.1.8"),
|
||||
@@ -140,6 +147,7 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
|
||||
@@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingAvailabilityResponseRequest,
|
||||
SchedulingAddressLookupCandidate,
|
||||
SchedulingAddressLookupResponse,
|
||||
SchedulingCalendarActionResponse,
|
||||
@@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingRequestListResponse,
|
||||
SchedulingRequestResponse,
|
||||
SchedulingRequestUpdateRequest,
|
||||
SchedulingPollSummaryResponse,
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import get_registry
|
||||
from govoplan_scheduling.backend.service import (
|
||||
SchedulingError,
|
||||
SchedulingPermissionError,
|
||||
cancel_scheduling_request,
|
||||
close_scheduling_request,
|
||||
create_final_calendar_event,
|
||||
@@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import (
|
||||
create_tentative_calendar_holds,
|
||||
decide_scheduling_request,
|
||||
evaluate_calendar_freebusy,
|
||||
get_scheduling_request,
|
||||
list_scheduling_notifications,
|
||||
list_scheduling_requests,
|
||||
get_visible_scheduling_request,
|
||||
list_visible_scheduling_notifications,
|
||||
list_visible_scheduling_requests,
|
||||
open_scheduling_request,
|
||||
refresh_participant_response_state,
|
||||
require_visible_scheduling_results,
|
||||
scheduling_notification_response,
|
||||
scheduling_request_response,
|
||||
scheduling_request_summary,
|
||||
submit_scheduling_availability,
|
||||
update_scheduling_request,
|
||||
)
|
||||
|
||||
@@ -88,15 +94,45 @@ def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _principal_actor_ids(principal: ApiPrincipal) -> tuple[str, ...]:
|
||||
candidates = (
|
||||
principal.account_id,
|
||||
principal.membership_id,
|
||||
getattr(principal.user, "id", None),
|
||||
principal.identity_id,
|
||||
principal.principal.service_account_id,
|
||||
principal.email,
|
||||
)
|
||||
return tuple(dict.fromkeys(str(value) for value in candidates if value))
|
||||
|
||||
|
||||
def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
|
||||
return has_scope(principal, WRITE_SCOPE) or has_scope(principal, ADMIN_SCOPE)
|
||||
|
||||
|
||||
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
if isinstance(exc, SchedulingPermissionError):
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
if str(exc) == "Scheduling request not found":
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if str(exc) == "Scheduling results are not visible":
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
def _request_response(request, *, invitation_tokens: dict[str, str] | None = None) -> SchedulingRequestResponse:
|
||||
def _request_response(
|
||||
request,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
invitation_tokens: dict[str, str] | None = None,
|
||||
) -> SchedulingRequestResponse:
|
||||
return SchedulingRequestResponse.model_validate(
|
||||
scheduling_request_response(request, invitation_tokens=invitation_tokens)
|
||||
scheduling_request_response(
|
||||
request,
|
||||
invitation_tokens=invitation_tokens,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -125,8 +161,27 @@ def api_list_scheduling_requests(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingRequestListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
|
||||
return SchedulingRequestListResponse(requests=[_request_response(request) for request in requests])
|
||||
requests = list_visible_scheduling_requests(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
status=status_filter,
|
||||
)
|
||||
actor_ids = _principal_actor_ids(principal)
|
||||
for request in requests:
|
||||
refresh_participant_response_state(
|
||||
session,
|
||||
request=request,
|
||||
actor_ids=actor_ids,
|
||||
)
|
||||
response = SchedulingRequestListResponse(
|
||||
requests=[_request_response(request, principal=principal) for request in requests]
|
||||
)
|
||||
# Poll responses are authoritative, while Scheduling keeps a durable
|
||||
# participant projection used by its task-oriented list.
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -145,7 +200,36 @@ def api_create_scheduling_request(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return _request_response(request, invitation_tokens=invitation_tokens)
|
||||
response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/responses", response_model=SchedulingStatusResponse)
|
||||
def api_submit_scheduling_availability(
|
||||
request_id: str,
|
||||
payload: SchedulingAvailabilityResponseRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingStatusResponse:
|
||||
_require_scope(principal, RESPOND_SCOPE)
|
||||
try:
|
||||
request = submit_scheduling_availability(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
respondent_id=principal.account_id,
|
||||
respondent_label=principal.display_name or principal.email,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = SchedulingStatusResponse(
|
||||
request=_request_response(request, principal=principal)
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||
@@ -156,9 +240,23 @@ def api_get_scheduling_request(
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
try:
|
||||
return _request_response(get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id))
|
||||
request = get_visible_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
refresh_participant_response_state(
|
||||
session,
|
||||
request=request,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||
@@ -170,11 +268,17 @@ def api_update_scheduling_request(
|
||||
) -> SchedulingRequestResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
try:
|
||||
return _request_response(
|
||||
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
|
||||
request = update_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
|
||||
@@ -188,7 +292,9 @@ def api_open_scheduling_request(
|
||||
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/close", response_model=SchedulingStatusResponse)
|
||||
@@ -202,7 +308,9 @@ def api_close_scheduling_request(
|
||||
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
|
||||
@@ -220,10 +328,16 @@ def api_decide_scheduling_request(
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
user_id=principal.account_id,
|
||||
allow_calendar_handoff=has_scope(
|
||||
principal,
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
),
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/freebusy", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -233,15 +347,18 @@ def api_evaluate_calendar_freebusy(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||
try:
|
||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -251,6 +368,7 @@ def api_create_tentative_calendar_holds(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||
session,
|
||||
@@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
created_event_ids=created_event_ids,
|
||||
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
|
||||
@@ -275,6 +395,7 @@ def api_create_final_calendar_event(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingCalendarActionResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
request, event_id, warnings = create_final_calendar_event(
|
||||
session,
|
||||
@@ -284,11 +405,13 @@ def api_create_final_calendar_event(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingCalendarActionResponse(
|
||||
request=_request_response(request),
|
||||
response = SchedulingCalendarActionResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
created_event_ids=[event_id] if event_id else [],
|
||||
warnings=warnings,
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
|
||||
@@ -302,7 +425,9 @@ def api_cancel_scheduling_request(
|
||||
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingStatusResponse(request=_request_response(request))
|
||||
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
|
||||
@@ -313,14 +438,30 @@ def api_scheduling_summary(
|
||||
) -> SchedulingSummaryResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
try:
|
||||
request = get_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
request = get_visible_scheduling_request(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
require_visible_scheduling_results(
|
||||
session,
|
||||
request=request,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
)
|
||||
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingSummaryResponse(
|
||||
request=_request_response(request),
|
||||
poll_summary=PollResultSummaryResponse.model_validate(summary),
|
||||
response = SchedulingSummaryResponse(
|
||||
request=_request_response(request, principal=principal),
|
||||
poll_summary=SchedulingPollSummaryResponse.model_validate(summary),
|
||||
)
|
||||
# Refreshing the summary synchronizes participant response state and can
|
||||
# enqueue response notifications, so persist that work before teardown.
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
|
||||
@@ -331,9 +472,11 @@ def api_list_scheduling_notifications(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingNotificationListResponse:
|
||||
_require_scope(principal, READ_SCOPE)
|
||||
notifications = list_scheduling_notifications(
|
||||
notifications = list_visible_scheduling_notifications(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
actor_ids=_principal_actor_ids(principal),
|
||||
can_manage=_can_manage_scheduling(principal),
|
||||
request_id=request_id,
|
||||
status=status_filter,
|
||||
)
|
||||
@@ -364,9 +507,11 @@ def api_create_scheduling_notifications(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
return SchedulingNotificationListResponse(
|
||||
response = SchedulingNotificationListResponse(
|
||||
notifications=[
|
||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||
for notification in notifications
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -5,13 +5,12 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
|
||||
|
||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
||||
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
||||
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||
|
||||
|
||||
class SchedulingCalendarPreferences(BaseModel):
|
||||
@@ -164,9 +163,48 @@ class SchedulingDecisionRequest(BaseModel):
|
||||
handoff_to_calendar: bool | None = None
|
||||
|
||||
|
||||
class SchedulingAvailabilityAnswerInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slot_id: str
|
||||
value: SchedulingAvailabilityValue
|
||||
|
||||
|
||||
class SchedulingAvailabilityResponseRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
answers: list[SchedulingAvailabilityAnswerInput] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_slots(self) -> "SchedulingAvailabilityResponseRequest":
|
||||
slot_ids = [answer.slot_id for answer in self.answers]
|
||||
if len(slot_ids) != len(set(slot_ids)):
|
||||
raise ValueError("Each scheduling slot can be answered only once")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingPollOptionResultResponse(BaseModel):
|
||||
option_id: str
|
||||
option_key: str
|
||||
label: str
|
||||
count: int = 0
|
||||
score: int = 0
|
||||
values: dict[str, int] = Field(default_factory=dict)
|
||||
ranks: dict[int, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingPollSummaryResponse(BaseModel):
|
||||
poll_id: str
|
||||
kind: str
|
||||
status: str
|
||||
response_count: int
|
||||
option_results: list[SchedulingPollOptionResultResponse] = Field(default_factory=list)
|
||||
leading_option_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SchedulingSummaryResponse(BaseModel):
|
||||
request: SchedulingRequestResponse
|
||||
poll_summary: PollResultSummaryResponse
|
||||
poll_summary: SchedulingPollSummaryResponse
|
||||
|
||||
|
||||
class SchedulingCalendarActionResponse(BaseModel):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,10 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertNotIn("access", manifest.dependencies)
|
||||
self.assertIn("access", manifest.optional_dependencies)
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertFalse(manifest.required_capabilities)
|
||||
self.assertEqual(("poll.scheduling",), manifest.required_capabilities)
|
||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||
self.assertIn("poll.scheduling", manifest.required_capabilities)
|
||||
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
|
||||
self.assertIn("evaluation", manifest.optional_dependencies)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
@@ -27,6 +29,7 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@
|
||||
},
|
||||
"./styles/scheduling.css": "./src/styles/scheduling.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:view-model": "node --experimental-strip-types --test tests/scheduling-view-model.test.ts",
|
||||
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
34
webui/scripts/test-scheduling-page-structure.mjs
Normal file
34
webui/scripts/test-scheduling-page-structure.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
|
||||
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
|
||||
const page = readFileSync(pagePath, "utf8");
|
||||
const api = readFileSync(apiPath, "utf8");
|
||||
|
||||
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
|
||||
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
|
||||
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
|
||||
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
|
||||
assert.match(page, /className="scheduling-full-editor"/);
|
||||
assert.match(page, /className="scheduling-page-actions"/);
|
||||
|
||||
const editorActions = page.slice(
|
||||
page.indexOf('<div className="scheduling-page-actions">'),
|
||||
page.indexOf('</div>', page.indexOf('<div className="scheduling-page-actions">'))
|
||||
);
|
||||
assert.ok(editorActions.indexOf("I18N.discard") < editorActions.indexOf("I18N.save"));
|
||||
assert.match(page, /<Plus aria-hidden="true" size=\{16\} \/> \{I18N\.addRequest\}/);
|
||||
assert.match(page, /title=\{I18N\.myRequests\}/);
|
||||
assert.match(page, /title=\{I18N\.invitedRequests\}/);
|
||||
assert.match(page, /className="scheduling-table-actions"/);
|
||||
assert.match(page, /aria-label=\{i18nMessage\("i18n:govoplan-scheduling\.decide_on_value/);
|
||||
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
|
||||
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
|
||||
assert.match(page, /className="scheduling-selected-calendar"/);
|
||||
assert.match(page, /showPlanningCalendarActions/);
|
||||
assert.match(page, /title=\{!canReadAvailability \? I18N\.requiresAvailabilityRead/);
|
||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
|
||||
|
||||
console.log("Scheduling page structure satisfies the focused list/create/respond contract.");
|
||||
@@ -115,6 +115,15 @@ export type SchedulingDecisionPayload = {
|
||||
handoff_to_calendar?: boolean | null;
|
||||
};
|
||||
|
||||
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
|
||||
|
||||
export type SchedulingAvailabilityPayload = {
|
||||
answers: Array<{
|
||||
slot_id: string;
|
||||
value: SchedulingAvailabilityValue;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SchedulingCalendarActionResponse = {
|
||||
request: SchedulingRequest;
|
||||
created_event_ids: string[];
|
||||
@@ -197,6 +206,18 @@ export function createSchedulingRequest(settings: ApiSettings, payload: Scheduli
|
||||
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
|
||||
}
|
||||
|
||||
export function submitSchedulingAvailability(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
payload: SchedulingAvailabilityPayload
|
||||
): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/responses`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
146
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
146
webui/src/features/scheduling/schedulingViewModel.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { SchedulingParticipant, SchedulingRequest } from "../../api/scheduling";
|
||||
|
||||
export type SchedulingActor = {
|
||||
accountId?: string | null;
|
||||
userId?: string | null;
|
||||
membershipId?: string | null;
|
||||
identityId?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingRequestGroups = {
|
||||
owned: SchedulingRequest[];
|
||||
invited: SchedulingRequest[];
|
||||
other: SchedulingRequest[];
|
||||
};
|
||||
|
||||
export type SchedulingSortPhase =
|
||||
| "unanswered"
|
||||
| "answered"
|
||||
| "closed"
|
||||
| "determined"
|
||||
| "past";
|
||||
|
||||
export function schedulingActorIds(actor: SchedulingActor): string[] {
|
||||
return Array.from(new Set([
|
||||
actor.accountId,
|
||||
actor.userId,
|
||||
actor.membershipId,
|
||||
actor.identityId,
|
||||
actor.email
|
||||
].filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
export function schedulingParticipantForActor(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor
|
||||
): SchedulingParticipant | null {
|
||||
const ids = new Set(schedulingActorIds(actor));
|
||||
return request.participants.find((participant) =>
|
||||
Boolean(
|
||||
(participant.respondent_id && ids.has(participant.respondent_id)) ||
|
||||
(participant.email && ids.has(participant.email))
|
||||
)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function schedulingRequestIsOwned(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor
|
||||
): boolean {
|
||||
return Boolean(
|
||||
request.organizer_user_id &&
|
||||
schedulingActorIds(actor).includes(request.organizer_user_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function schedulingSortPhase(
|
||||
request: SchedulingRequest,
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): SchedulingSortPhase {
|
||||
if (schedulingRequestIsPast(request, now)) return "past";
|
||||
if (["decided", "handed_off"].includes(request.status)) return "determined";
|
||||
if (["closed", "cancelled", "archived"].includes(request.status)) return "closed";
|
||||
if (schedulingRequestIsOwned(request, actor)) return "unanswered";
|
||||
const participant = schedulingParticipantForActor(request, actor);
|
||||
return participant && ["responded", "declined"].includes(participant.status)
|
||||
? "answered"
|
||||
: "unanswered";
|
||||
}
|
||||
|
||||
export function compareSchedulingRequests(
|
||||
left: SchedulingRequest,
|
||||
right: SchedulingRequest,
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): number {
|
||||
const leftPhase = schedulingSortPhase(left, actor, now);
|
||||
const rightPhase = schedulingSortPhase(right, actor, now);
|
||||
const phaseDifference = SORT_PHASE_ORDER[leftPhase] - SORT_PHASE_ORDER[rightPhase];
|
||||
if (phaseDifference !== 0) return phaseDifference;
|
||||
const leftDate = schedulingRelevantTimestamp(left, now);
|
||||
const rightDate = schedulingRelevantTimestamp(right, now);
|
||||
const dateDifference = leftPhase === "past"
|
||||
? rightDate - leftDate
|
||||
: leftDate - rightDate;
|
||||
if (dateDifference !== 0) return dateDifference;
|
||||
const titleDifference = left.title.localeCompare(right.title);
|
||||
return titleDifference || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function groupSchedulingRequests(
|
||||
requests: SchedulingRequest[],
|
||||
actor: SchedulingActor,
|
||||
now = new Date()
|
||||
): SchedulingRequestGroups {
|
||||
const groups: SchedulingRequestGroups = { owned: [], invited: [], other: [] };
|
||||
for (const request of requests) {
|
||||
if (schedulingRequestIsOwned(request, actor)) {
|
||||
groups.owned.push(request);
|
||||
} else if (schedulingParticipantForActor(request, actor)) {
|
||||
groups.invited.push(request);
|
||||
} else {
|
||||
groups.other.push(request);
|
||||
}
|
||||
}
|
||||
for (const group of Object.values(groups)) {
|
||||
group.sort((left, right) => compareSchedulingRequests(left, right, actor, now));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function schedulingRelevantTimestamp(
|
||||
request: SchedulingRequest,
|
||||
now = new Date()
|
||||
): number {
|
||||
const nowValue = now.getTime();
|
||||
const futureStarts = request.slots
|
||||
.map((slot) => Date.parse(slot.start_at))
|
||||
.filter((value) => Number.isFinite(value) && value >= nowValue)
|
||||
.sort((left, right) => left - right);
|
||||
if (futureStarts[0] !== undefined) return futureStarts[0];
|
||||
const slotEnds = request.slots
|
||||
.map((slot) => Date.parse(slot.end_at))
|
||||
.filter(Number.isFinite);
|
||||
if (slotEnds.length) return Math.max(...slotEnds);
|
||||
const fallback = Date.parse(request.deadline_at || request.updated_at || request.created_at);
|
||||
return Number.isFinite(fallback) ? fallback : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
export function schedulingRequestIsPast(
|
||||
request: SchedulingRequest,
|
||||
now = new Date()
|
||||
): boolean {
|
||||
if (!request.slots.length) return false;
|
||||
const slotEnds = request.slots.map((slot) => Date.parse(slot.end_at));
|
||||
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
|
||||
}
|
||||
|
||||
const SORT_PHASE_ORDER: Record<SchedulingSortPhase, number> = {
|
||||
unanswered: 0,
|
||||
answered: 1,
|
||||
closed: 2,
|
||||
determined: 3,
|
||||
past: 4
|
||||
};
|
||||
@@ -1,4 +1,210 @@
|
||||
export const generatedTranslations = {
|
||||
en: {},
|
||||
de: {}
|
||||
en: {
|
||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Add participant",
|
||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Add scheduling request",
|
||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Add slot",
|
||||
"i18n:govoplan-scheduling.answered.e0aafffa": "Answered",
|
||||
"i18n:govoplan-scheduling.available.7c62a142": "Available",
|
||||
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Awaiting response",
|
||||
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Basic information",
|
||||
"i18n:govoplan-scheduling.busy.592a9d16": "Busy",
|
||||
"i18n:govoplan-scheduling.calendar.adab5090": "Calendar",
|
||||
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Calendar coordination",
|
||||
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Configured calendar",
|
||||
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Calendar integration",
|
||||
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Calendar integration requires the Calendar module plus calendar-read, availability-read, and event-write access.",
|
||||
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Candidate availability",
|
||||
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Candidate slots",
|
||||
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Check free/busy",
|
||||
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Choose availability",
|
||||
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Choose availability for at least one candidate slot.",
|
||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Close poll",
|
||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Close stops accepting new availability responses.",
|
||||
"i18n:govoplan-scheduling.closed.88d86b77": "Closed",
|
||||
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Create and open scheduling request",
|
||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Create calendar event",
|
||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Create tentative holds",
|
||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Cancellation",
|
||||
"i18n:govoplan-scheduling.decision.7f59a1f1": "Decision",
|
||||
"i18n:govoplan-scheduling.declined.ff59b80f": "Declined",
|
||||
"i18n:govoplan-scheduling.decide_on_value.196409cd": "Decide on {value0}",
|
||||
"i18n:govoplan-scheduling.description.55f8ebc8": "Description",
|
||||
"i18n:govoplan-scheduling.determined.9f23293d": "Determined",
|
||||
"i18n:govoplan-scheduling.discard.36fff63c": "Discard",
|
||||
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Discard this unsaved scheduling request?",
|
||||
"i18n:govoplan-scheduling.draft.23d33e22": "Draft",
|
||||
"i18n:govoplan-scheduling.end.a2bb9d34": "End",
|
||||
"i18n:govoplan-scheduling.error.7f2f6a15": "Error",
|
||||
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Every candidate slot must end after it starts.",
|
||||
"i18n:govoplan-scheduling.failed.09fef5d8": "Failed",
|
||||
"i18n:govoplan-scheduling.free.75f52718": "Free",
|
||||
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Free/busy checks each candidate slot against the selected calendar.",
|
||||
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Loading scheduling requests…",
|
||||
"i18n:govoplan-scheduling.invitation.6306ef74": "Invitation",
|
||||
"i18n:govoplan-scheduling.invited.53469df1": "Invited",
|
||||
"i18n:govoplan-scheduling.location.d219c681": "Location",
|
||||
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Managed scheduling requests",
|
||||
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Maybe",
|
||||
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "My scheduling requests",
|
||||
"i18n:govoplan-scheduling.name.709a2322": "Name",
|
||||
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "New scheduling request",
|
||||
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "No notifications have been created.",
|
||||
"i18n:govoplan-scheduling.no_participants.73a89101": "No participants",
|
||||
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "No requests in this group",
|
||||
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "No scheduling request selected",
|
||||
"i18n:govoplan-scheduling.notifications.753a22b2": "Notifications",
|
||||
"i18n:govoplan-scheduling.open.cf9b7706": "Open",
|
||||
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Open poll",
|
||||
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Open starts collecting availability responses.",
|
||||
"i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}",
|
||||
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
|
||||
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
|
||||
"i18n:govoplan-scheduling.participants.cd56e083": "Participants",
|
||||
"i18n:govoplan-scheduling.past.405c12fb": "Past",
|
||||
"i18n:govoplan-scheduling.pending.96f608c1": "Pending",
|
||||
"i18n:govoplan-scheduling.queued.6a599877": "Queued",
|
||||
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Refresh requests",
|
||||
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Reminder creates a notification job for every active participant.",
|
||||
"i18n:govoplan-scheduling.remove.e963907d": "Remove",
|
||||
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Remove participant {value0}",
|
||||
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Remove slot {value0}",
|
||||
"i18n:govoplan-scheduling.removed.b5e77c5c": "Removed",
|
||||
"i18n:govoplan-scheduling.reminder.b87a1929": "Reminder",
|
||||
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Request failed",
|
||||
"i18n:govoplan-scheduling.required.eed6bfb4": "Required",
|
||||
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Requires Calendar availability-read access.",
|
||||
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Requires Calendar event-write access.",
|
||||
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Responses may be updated while this request remains open.",
|
||||
"i18n:govoplan-scheduling.responded.4f218211": "Responded",
|
||||
"i18n:govoplan-scheduling.save.efc007a3": "Save",
|
||||
"i18n:govoplan-scheduling.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Scheduling requests",
|
||||
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Scheduling request title is required.",
|
||||
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Scheduling requests for me",
|
||||
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Select a calendar",
|
||||
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Select a calendar or turn off Calendar integration.",
|
||||
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Send reminder",
|
||||
"i18n:govoplan-scheduling.sending.ceafde86": "Sending",
|
||||
"i18n:govoplan-scheduling.sent.35f49dcf": "Sent",
|
||||
"i18n:govoplan-scheduling.skipped.5a000ad7": "Skipped",
|
||||
"i18n:govoplan-scheduling.start.952f3754": "Start",
|
||||
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Submit response",
|
||||
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Tentative holds create one provisional calendar event per candidate slot.",
|
||||
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "The final event is created only for the selected slot.",
|
||||
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "The response replaces your previous availability choices.",
|
||||
"i18n:govoplan-scheduling.title.768e0c1c": "Title",
|
||||
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Unavailable",
|
||||
"i18n:govoplan-scheduling.unchecked.1b927dec": "Unchecked",
|
||||
"i18n:govoplan-scheduling.update_response.346233cf": "Update response",
|
||||
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Use a calendar for availability checks, tentative holds, and the final event.",
|
||||
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} participants",
|
||||
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} responses",
|
||||
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "What do these actions do?",
|
||||
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "You can respond here or use the invitation link you received.",
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-scheduling.add_participant.6cff957d": "Teilnehmende Person hinzufügen",
|
||||
"i18n:govoplan-scheduling.add_scheduling_request.3c71be15": "Terminanfrage hinzufügen",
|
||||
"i18n:govoplan-scheduling.add_slot.9fcff3ed": "Terminvorschlag hinzufügen",
|
||||
"i18n:govoplan-scheduling.answered.e0aafffa": "Beantwortet",
|
||||
"i18n:govoplan-scheduling.available.7c62a142": "Verfügbar",
|
||||
"i18n:govoplan-scheduling.awaiting_response.ee646ea9": "Antwort ausstehend",
|
||||
"i18n:govoplan-scheduling.basic_information.d8bc7383": "Grunddaten",
|
||||
"i18n:govoplan-scheduling.busy.592a9d16": "Belegt",
|
||||
"i18n:govoplan-scheduling.calendar.adab5090": "Kalender",
|
||||
"i18n:govoplan-scheduling.calendar_coordination.291ab00a": "Kalenderabgleich",
|
||||
"i18n:govoplan-scheduling.configured_calendar.e2e8ebd5": "Ausgewählter Kalender",
|
||||
"i18n:govoplan-scheduling.calendar_integration.181ad18b": "Kalenderintegration",
|
||||
"i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e": "Die Kalenderintegration benötigt das Kalendermodul sowie Leserechte für Kalender und Verfügbarkeiten und Schreibrechte für Termine.",
|
||||
"i18n:govoplan-scheduling.candidate_availability.9541c4b5": "Verfügbarkeit zu den Vorschlägen",
|
||||
"i18n:govoplan-scheduling.candidate_slots.c414946b": "Terminvorschläge",
|
||||
"i18n:govoplan-scheduling.check_free_busy.e9700e00": "Frei/Belegt prüfen",
|
||||
"i18n:govoplan-scheduling.choose_availability.ac95b8f6": "Verfügbarkeit auswählen",
|
||||
"i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f": "Wählen Sie für mindestens einen Terminvorschlag eine Verfügbarkeit aus.",
|
||||
"i18n:govoplan-scheduling.close_poll.a6a18916": "Abstimmung schließen",
|
||||
"i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519": "Schließen beendet die Annahme neuer Verfügbarkeitsantworten.",
|
||||
"i18n:govoplan-scheduling.closed.88d86b77": "Geschlossen",
|
||||
"i18n:govoplan-scheduling.create_and_open_scheduling_request.1dbc9345": "Terminanfrage erstellen und öffnen",
|
||||
"i18n:govoplan-scheduling.create_calendar_event.0b87cfcf": "Kalendertermin erstellen",
|
||||
"i18n:govoplan-scheduling.create_tentative_holds.51c4744e": "Vorläufige Reservierungen erstellen",
|
||||
"i18n:govoplan-scheduling.cancellation.319aaae4": "Stornierung",
|
||||
"i18n:govoplan-scheduling.decision.7f59a1f1": "Entscheidung",
|
||||
"i18n:govoplan-scheduling.declined.ff59b80f": "Abgelehnt",
|
||||
"i18n:govoplan-scheduling.decide_on_value.196409cd": "{value0} als Termin festlegen",
|
||||
"i18n:govoplan-scheduling.description.55f8ebc8": "Beschreibung",
|
||||
"i18n:govoplan-scheduling.determined.9f23293d": "Festgelegt",
|
||||
"i18n:govoplan-scheduling.discard.36fff63c": "Verwerfen",
|
||||
"i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2": "Diese ungespeicherte Terminanfrage verwerfen?",
|
||||
"i18n:govoplan-scheduling.draft.23d33e22": "Entwurf",
|
||||
"i18n:govoplan-scheduling.end.a2bb9d34": "Ende",
|
||||
"i18n:govoplan-scheduling.error.7f2f6a15": "Fehler",
|
||||
"i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010": "Jeder Terminvorschlag muss nach seinem Beginn enden.",
|
||||
"i18n:govoplan-scheduling.failed.09fef5d8": "Fehlgeschlagen",
|
||||
"i18n:govoplan-scheduling.free.75f52718": "Frei",
|
||||
"i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a": "Frei/Belegt prüft jeden Terminvorschlag gegen den ausgewählten Kalender.",
|
||||
"i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d": "Terminanfragen werden geladen …",
|
||||
"i18n:govoplan-scheduling.invitation.6306ef74": "Einladung",
|
||||
"i18n:govoplan-scheduling.invited.53469df1": "Eingeladen",
|
||||
"i18n:govoplan-scheduling.location.d219c681": "Ort",
|
||||
"i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f": "Verwaltete Terminanfragen",
|
||||
"i18n:govoplan-scheduling.maybe.56dd8d0b": "Vielleicht",
|
||||
"i18n:govoplan-scheduling.my_scheduling_requests.d28ef235": "Meine Terminanfragen",
|
||||
"i18n:govoplan-scheduling.name.709a2322": "Name",
|
||||
"i18n:govoplan-scheduling.new_scheduling_request.2080f675": "Neue Terminanfrage",
|
||||
"i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3": "Es wurden noch keine Benachrichtigungen erstellt.",
|
||||
"i18n:govoplan-scheduling.no_participants.73a89101": "Keine Teilnehmenden",
|
||||
"i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40": "Keine Anfragen in dieser Gruppe",
|
||||
"i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664": "Keine Terminanfrage ausgewählt",
|
||||
"i18n:govoplan-scheduling.notifications.753a22b2": "Benachrichtigungen",
|
||||
"i18n:govoplan-scheduling.open.cf9b7706": "Offen",
|
||||
"i18n:govoplan-scheduling.open_poll.2beac9a7": "Abstimmung öffnen",
|
||||
"i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5": "Öffnen startet die Erfassung von Verfügbarkeitsantworten.",
|
||||
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
|
||||
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
|
||||
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
|
||||
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
|
||||
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
|
||||
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",
|
||||
"i18n:govoplan-scheduling.queued.6a599877": "Eingereiht",
|
||||
"i18n:govoplan-scheduling.refresh_requests.0a3ed7a1": "Anfragen aktualisieren",
|
||||
"i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797": "Erinnern erstellt für jede aktive teilnehmende Person einen Benachrichtigungsauftrag.",
|
||||
"i18n:govoplan-scheduling.remove.e963907d": "Entfernen",
|
||||
"i18n:govoplan-scheduling.remove_participant_value.e55f2b70": "Teilnehmende Person {value0} entfernen",
|
||||
"i18n:govoplan-scheduling.remove_slot_value.3adc7576": "Terminvorschlag {value0} entfernen",
|
||||
"i18n:govoplan-scheduling.removed.b5e77c5c": "Entfernt",
|
||||
"i18n:govoplan-scheduling.reminder.b87a1929": "Erinnerung",
|
||||
"i18n:govoplan-scheduling.request_failed.9fcda32c": "Anfrage fehlgeschlagen",
|
||||
"i18n:govoplan-scheduling.required.eed6bfb4": "Erforderlich",
|
||||
"i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b": "Erfordert Leserechte für Kalenderverfügbarkeiten.",
|
||||
"i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763": "Erfordert Schreibrechte für Kalendertermine.",
|
||||
"i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe": "Antworten können aktualisiert werden, solange diese Anfrage offen ist.",
|
||||
"i18n:govoplan-scheduling.responded.4f218211": "Geantwortet",
|
||||
"i18n:govoplan-scheduling.save.efc007a3": "Speichern",
|
||||
"i18n:govoplan-scheduling.saving.56a2285c": "Wird gespeichert …",
|
||||
"i18n:govoplan-scheduling.scheduling_requests.b3c12f4d": "Terminanfragen",
|
||||
"i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be": "Für die Terminanfrage ist ein Titel erforderlich.",
|
||||
"i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba": "Terminanfragen an mich",
|
||||
"i18n:govoplan-scheduling.select_a_calendar.f6af95bb": "Kalender auswählen",
|
||||
"i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9": "Wählen Sie einen Kalender aus oder schalten Sie die Kalenderintegration aus.",
|
||||
"i18n:govoplan-scheduling.send_reminder.cf5eb3bf": "Erinnerung senden",
|
||||
"i18n:govoplan-scheduling.sending.ceafde86": "Wird gesendet",
|
||||
"i18n:govoplan-scheduling.sent.35f49dcf": "Gesendet",
|
||||
"i18n:govoplan-scheduling.skipped.5a000ad7": "Übersprungen",
|
||||
"i18n:govoplan-scheduling.start.952f3754": "Beginn",
|
||||
"i18n:govoplan-scheduling.submit_response.a5f0c053": "Antwort senden",
|
||||
"i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884": "Vorläufige Reservierungen erstellen je Terminvorschlag einen provisorischen Kalendereintrag.",
|
||||
"i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252": "Der endgültige Kalendereintrag wird nur für den ausgewählten Termin erstellt.",
|
||||
"i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53": "Die Antwort ersetzt Ihre vorherige Verfügbarkeitsauswahl.",
|
||||
"i18n:govoplan-scheduling.title.768e0c1c": "Titel",
|
||||
"i18n:govoplan-scheduling.unavailable.2c9c1f79": "Nicht verfügbar",
|
||||
"i18n:govoplan-scheduling.unchecked.1b927dec": "Nicht geprüft",
|
||||
"i18n:govoplan-scheduling.update_response.346233cf": "Antwort aktualisieren",
|
||||
"i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa": "Einen Kalender für Verfügbarkeitsprüfungen, vorläufige Reservierungen und den endgültigen Termin verwenden.",
|
||||
"i18n:govoplan-scheduling.value_participants.e776b092": "{value0} Teilnehmende",
|
||||
"i18n:govoplan-scheduling.value_responses.ba17af9a": "{value0} Antworten",
|
||||
"i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e": "Was bewirken diese Aktionen?",
|
||||
"i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53": "Sie können hier oder über den erhaltenen Einladungslink antworten.",
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Ihre Antwort wurde gespeichert."
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
@@ -15,48 +14,87 @@
|
||||
}
|
||||
|
||||
.scheduling-shell {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 310px) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-sidebar {
|
||||
.scheduling-sidebar,
|
||||
.scheduling-workspace,
|
||||
.scheduling-full-editor {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scheduling-sidebar {
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar,
|
||||
.scheduling-topbar,
|
||||
.scheduling-panel-heading,
|
||||
.scheduling-mini-heading,
|
||||
.scheduling-status-row,
|
||||
.scheduling-page-header {
|
||||
min-height: 58px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar,
|
||||
.scheduling-topbar,
|
||||
.scheduling-page-header,
|
||||
.scheduling-section-heading,
|
||||
.scheduling-sidebar-actions,
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions,
|
||||
.scheduling-title-line {
|
||||
.scheduling-title-line,
|
||||
.scheduling-page-title,
|
||||
.scheduling-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar {
|
||||
.scheduling-sidebar-bar,
|
||||
.scheduling-topbar,
|
||||
.scheduling-page-header,
|
||||
.scheduling-section-heading {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-actions,
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-actions .btn,
|
||||
.scheduling-page-actions .btn,
|
||||
.scheduling-card-actions .btn,
|
||||
.scheduling-actions .btn,
|
||||
.scheduling-section-heading .btn,
|
||||
.scheduling-response-card .btn {
|
||||
min-height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-icon-button.btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -67,30 +105,61 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.scheduling-list-group + .scheduling-list-group {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-list-group h2 {
|
||||
margin: 0 8px 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .025em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.scheduling-list-item {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scheduling-list-item:hover,
|
||||
.scheduling-list-item.is-selected {
|
||||
.scheduling-list-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.scheduling-list-item span,
|
||||
.scheduling-slot-row strong,
|
||||
.scheduling-list-item.is-selected {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.scheduling-list-item > span,
|
||||
.scheduling-title-line > div,
|
||||
.scheduling-page-title > div,
|
||||
.scheduling-section-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduling-list-item > span {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scheduling-list-item strong,
|
||||
.scheduling-list-item small,
|
||||
.scheduling-compact-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -99,121 +168,110 @@
|
||||
}
|
||||
|
||||
.scheduling-list-item small,
|
||||
.scheduling-slot-row small,
|
||||
.scheduling-compact-row small,
|
||||
.scheduling-note {
|
||||
.scheduling-title-line small,
|
||||
.scheduling-page-title p,
|
||||
.scheduling-section-heading p,
|
||||
.scheduling-note,
|
||||
.scheduling-list-empty,
|
||||
.scheduling-compact-row small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-list-status {
|
||||
max-width: 110px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-list-empty {
|
||||
margin: 3px 8px 8px;
|
||||
}
|
||||
|
||||
.scheduling-workspace {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-topbar {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-title-line {
|
||||
min-width: 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scheduling-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
.scheduling-title-line > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.scheduling-actions .btn,
|
||||
.scheduling-create-panel .btn,
|
||||
.scheduling-slot-row .btn {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.scheduling-alert,
|
||||
.scheduling-success {
|
||||
margin: 10px 14px 0;
|
||||
padding: 9px 11px;
|
||||
border: var(--border-line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.scheduling-alert {
|
||||
margin: 8px 12px 0;
|
||||
padding: 8px 10px;
|
||||
border: var(--border-line);
|
||||
border-color: var(--danger);
|
||||
border-radius: 6px;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.scheduling-content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
.scheduling-success {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
}
|
||||
|
||||
.scheduling-create-panel,
|
||||
.scheduling-detail {
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel {
|
||||
.scheduling-detail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel label,
|
||||
.scheduling-form-section {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel label span,
|
||||
.scheduling-mini-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-create-panel input {
|
||||
.scheduling-card {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.scheduling-checkbox {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
.scheduling-card h2,
|
||||
.scheduling-page-header h1,
|
||||
.scheduling-section-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.scheduling-slot-draft,
|
||||
.scheduling-participant-draft {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
.scheduling-card h2,
|
||||
.scheduling-section-heading h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.scheduling-slot-draft {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.scheduling-page-header h1 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.scheduling-page-title p,
|
||||
.scheduling-section-heading p {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
|
||||
.scheduling-summary-card > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-status-row {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scheduling-status {
|
||||
@@ -222,7 +280,7 @@
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scheduling-status-collecting,
|
||||
@@ -230,33 +288,76 @@
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.scheduling-slot-table {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
.scheduling-table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
padding: 8px;
|
||||
.scheduling-table-card > .scheduling-section-heading {
|
||||
min-height: 52px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-slot-row:hover {
|
||||
background: var(--hover-bg);
|
||||
.scheduling-table-scroll {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scheduling-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.scheduling-table th,
|
||||
.scheduling-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: var(--border-line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.scheduling-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-table th {
|
||||
color: var(--muted);
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scheduling-table td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scheduling-table td:first-child {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.scheduling-table td > strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scheduling-table-actions {
|
||||
width: 1%;
|
||||
text-align: right !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scheduling-freebusy {
|
||||
min-width: 74px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: inline-block;
|
||||
margin-top: 3px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-soft);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-freebusy.free {
|
||||
@@ -272,37 +373,218 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.scheduling-compact-row {
|
||||
min-height: 36px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-empty {
|
||||
padding: 20px;
|
||||
.scheduling-compact-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-response-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-response-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 240px);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-response-grid label:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label > span {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.scheduling-response-grid small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.scheduling-shell,
|
||||
.scheduling-content,
|
||||
.scheduling-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.scheduling-response-grid select,
|
||||
.scheduling-field input,
|
||||
.scheduling-field textarea,
|
||||
.scheduling-edit-table input:not([type="checkbox"]) {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 7px 9px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.scheduling-sidebar,
|
||||
.scheduling-create-panel {
|
||||
.scheduling-action-help summary {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-action-help ul {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.scheduling-empty {
|
||||
padding: 28px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scheduling-editor-page {
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.scheduling-full-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: var(--border-line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.scheduling-page-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.scheduling-page-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scheduling-editor-scroll {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scheduling-form-grid h2,
|
||||
.scheduling-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.scheduling-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-field > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.scheduling-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-capability-note {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-edit-table input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.scheduling-shell {
|
||||
grid-template-columns: minmax(260px, 310px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-actions {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.scheduling-page {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 115px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scheduling-shell {
|
||||
height: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scheduling-sidebar {
|
||||
max-height: 46vh;
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
.scheduling-workspace,
|
||||
.scheduling-full-editor {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scheduling-detail,
|
||||
.scheduling-editor-scroll {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scheduling-columns,
|
||||
.scheduling-form-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-page-header,
|
||||
.scheduling-topbar,
|
||||
.scheduling-section-heading {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scheduling-page-actions,
|
||||
.scheduling-card-actions,
|
||||
.scheduling-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scheduling-page-actions .btn:last-child,
|
||||
.scheduling-card-actions .btn:last-child,
|
||||
.scheduling-actions .btn:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.scheduling-response-grid label {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
111
webui/tests/scheduling-view-model.test.ts
Normal file
111
webui/tests/scheduling-view-model.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { SchedulingRequest } from "../src/api/scheduling.ts";
|
||||
import {
|
||||
groupSchedulingRequests,
|
||||
schedulingSortPhase,
|
||||
type SchedulingActor
|
||||
} from "../src/features/scheduling/schedulingViewModel.ts";
|
||||
|
||||
const now = new Date("2026-07-20T10:00:00Z");
|
||||
const actor: SchedulingActor = {
|
||||
accountId: "account-1",
|
||||
membershipId: "membership-1",
|
||||
email: "person@example.test"
|
||||
};
|
||||
|
||||
function request(
|
||||
id: string,
|
||||
options: {
|
||||
organizer?: string;
|
||||
participantStatus?: string;
|
||||
status?: SchedulingRequest["status"];
|
||||
start?: string;
|
||||
end?: string;
|
||||
} = {}
|
||||
): SchedulingRequest {
|
||||
return {
|
||||
id,
|
||||
tenant_id: "tenant-1",
|
||||
title: id,
|
||||
timezone: "UTC",
|
||||
status: options.status ?? "collecting",
|
||||
organizer_user_id: options.organizer ?? "organizer-elsewhere",
|
||||
allow_external_participants: true,
|
||||
allow_participant_updates: true,
|
||||
result_visibility: "after_close",
|
||||
calendar_integration_enabled: false,
|
||||
calendar_freebusy_enabled: false,
|
||||
calendar_hold_enabled: false,
|
||||
create_calendar_event_on_decision: false,
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
updated_at: "2026-07-01T00:00:00Z",
|
||||
metadata: {},
|
||||
slots: [{
|
||||
id: `slot-${id}`,
|
||||
label: id,
|
||||
start_at: options.start ?? "2026-07-21T09:00:00Z",
|
||||
end_at: options.end ?? "2026-07-21T10:00:00Z",
|
||||
timezone: "UTC",
|
||||
position: 0,
|
||||
freebusy_conflicts: [],
|
||||
metadata: {}
|
||||
}],
|
||||
participants: options.participantStatus ? [{
|
||||
id: `participant-${id}`,
|
||||
respondent_id: "membership-1",
|
||||
email: "person@example.test",
|
||||
participant_type: "internal",
|
||||
required: true,
|
||||
status: options.participantStatus,
|
||||
metadata: {}
|
||||
}] : []
|
||||
};
|
||||
}
|
||||
|
||||
test("groups owned, invited, and administrator-only requests without hiding any", () => {
|
||||
const groups = groupSchedulingRequests([
|
||||
request("managed"),
|
||||
request("mine", { organizer: "account-1" }),
|
||||
request("invited", { participantStatus: "invited" })
|
||||
], actor, now);
|
||||
|
||||
assert.deepEqual(groups.owned.map((item) => item.id), ["mine"]);
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), ["invited"]);
|
||||
assert.deepEqual(groups.other.map((item) => item.id), ["managed"]);
|
||||
});
|
||||
|
||||
test("keeps an organizer's active request in the open phase even if they also responded", () => {
|
||||
const owned = request("mine-and-invited", {
|
||||
organizer: "account-1",
|
||||
participantStatus: "responded"
|
||||
});
|
||||
|
||||
assert.equal(schedulingSortPhase(owned, actor, now), "unanswered");
|
||||
});
|
||||
|
||||
test("orders unanswered by nearest slot before answered, closed, determined, and past", () => {
|
||||
const groups = groupSchedulingRequests([
|
||||
request("past", {
|
||||
participantStatus: "invited",
|
||||
start: "2026-07-18T09:00:00Z",
|
||||
end: "2026-07-18T10:00:00Z"
|
||||
}),
|
||||
request("determined", { participantStatus: "invited", status: "decided" }),
|
||||
request("closed", { participantStatus: "invited", status: "closed" }),
|
||||
request("answered", { participantStatus: "responded" }),
|
||||
request("later", { participantStatus: "invited", start: "2026-07-23T09:00:00Z" }),
|
||||
request("nearer", { participantStatus: "invited", start: "2026-07-21T09:00:00Z" })
|
||||
], actor, now);
|
||||
|
||||
assert.deepEqual(groups.invited.map((item) => item.id), [
|
||||
"nearer",
|
||||
"later",
|
||||
"answered",
|
||||
"closed",
|
||||
"determined",
|
||||
"past"
|
||||
]);
|
||||
assert.equal(schedulingSortPhase(groups.invited[0], actor, now), "unanswered");
|
||||
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
|
||||
});
|
||||
Reference in New Issue
Block a user