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 pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
@@ -17,6 +18,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
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
|
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
|
||||||
|
|
||||||
MODULE_ID = "scheduling"
|
MODULE_ID = "scheduling"
|
||||||
@@ -128,7 +130,12 @@ manifest = ModuleManifest(
|
|||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("poll",),
|
dependencies=("poll",),
|
||||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
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=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||||
ModuleInterfaceProvider(name="scheduling.decision_handoff", 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="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="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="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,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
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_core.db.session import get_session
|
||||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
|
||||||
from govoplan_scheduling.backend.schemas import (
|
from govoplan_scheduling.backend.schemas import (
|
||||||
|
SchedulingAvailabilityResponseRequest,
|
||||||
SchedulingAddressLookupCandidate,
|
SchedulingAddressLookupCandidate,
|
||||||
SchedulingAddressLookupResponse,
|
SchedulingAddressLookupResponse,
|
||||||
SchedulingCalendarActionResponse,
|
SchedulingCalendarActionResponse,
|
||||||
@@ -22,12 +23,14 @@ from govoplan_scheduling.backend.schemas import (
|
|||||||
SchedulingRequestListResponse,
|
SchedulingRequestListResponse,
|
||||||
SchedulingRequestResponse,
|
SchedulingRequestResponse,
|
||||||
SchedulingRequestUpdateRequest,
|
SchedulingRequestUpdateRequest,
|
||||||
|
SchedulingPollSummaryResponse,
|
||||||
SchedulingStatusResponse,
|
SchedulingStatusResponse,
|
||||||
SchedulingSummaryResponse,
|
SchedulingSummaryResponse,
|
||||||
)
|
)
|
||||||
from govoplan_scheduling.backend.runtime import get_registry
|
from govoplan_scheduling.backend.runtime import get_registry
|
||||||
from govoplan_scheduling.backend.service import (
|
from govoplan_scheduling.backend.service import (
|
||||||
SchedulingError,
|
SchedulingError,
|
||||||
|
SchedulingPermissionError,
|
||||||
cancel_scheduling_request,
|
cancel_scheduling_request,
|
||||||
close_scheduling_request,
|
close_scheduling_request,
|
||||||
create_final_calendar_event,
|
create_final_calendar_event,
|
||||||
@@ -36,13 +39,16 @@ from govoplan_scheduling.backend.service import (
|
|||||||
create_tentative_calendar_holds,
|
create_tentative_calendar_holds,
|
||||||
decide_scheduling_request,
|
decide_scheduling_request,
|
||||||
evaluate_calendar_freebusy,
|
evaluate_calendar_freebusy,
|
||||||
get_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
list_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
open_scheduling_request,
|
open_scheduling_request,
|
||||||
|
refresh_participant_response_state,
|
||||||
|
require_visible_scheduling_results,
|
||||||
scheduling_notification_response,
|
scheduling_notification_response,
|
||||||
scheduling_request_response,
|
scheduling_request_response,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
|
submit_scheduling_availability,
|
||||||
update_scheduling_request,
|
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}")
|
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:
|
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":
|
if str(exc) == "Scheduling request not found":
|
||||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
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))
|
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(
|
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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingRequestListResponse:
|
) -> SchedulingRequestListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
requests = list_scheduling_requests(session, tenant_id=principal.tenant_id, status=status_filter)
|
requests = list_visible_scheduling_requests(
|
||||||
return SchedulingRequestListResponse(requests=[_request_response(request) for request in 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)
|
@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:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||||
@@ -156,9 +240,23 @@ def api_get_scheduling_request(
|
|||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
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:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||||
@@ -170,11 +268,17 @@ def api_update_scheduling_request(
|
|||||||
) -> SchedulingRequestResponse:
|
) -> SchedulingRequestResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
return _request_response(
|
request = update_scheduling_request(
|
||||||
update_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id, payload=payload)
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
request_id=request_id,
|
||||||
|
payload=payload,
|
||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@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)
|
request = open_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@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)
|
request = close_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.post("/requests/{request_id}/decide", response_model=SchedulingStatusResponse)
|
||||||
@@ -220,10 +328,16 @@ def api_decide_scheduling_request(
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
user_id=principal.account_id,
|
user_id=principal.account_id,
|
||||||
|
allow_calendar_handoff=has_scope(
|
||||||
|
principal,
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
request, warnings = evaluate_calendar_freebusy(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
updated_slot_ids=[slot.id for slot in request.slots if slot.freebusy_checked_at is not None],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/calendar/holds", response_model=SchedulingCalendarActionResponse)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
request, created_event_ids, warnings = create_tentative_calendar_holds(
|
||||||
session,
|
session,
|
||||||
@@ -260,12 +378,14 @@ def api_create_tentative_calendar_holds(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
created_event_ids=created_event_ids,
|
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],
|
updated_slot_ids=[slot.id for slot in request.slots if slot.tentative_hold_event_id in created_event_ids],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/calendar/event", response_model=SchedulingCalendarActionResponse)
|
@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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingCalendarActionResponse:
|
) -> SchedulingCalendarActionResponse:
|
||||||
_require_scope(principal, WRITE_SCOPE)
|
_require_scope(principal, WRITE_SCOPE)
|
||||||
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
request, event_id, warnings = create_final_calendar_event(
|
request, event_id, warnings = create_final_calendar_event(
|
||||||
session,
|
session,
|
||||||
@@ -284,11 +405,13 @@ def api_create_final_calendar_event(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingCalendarActionResponse(
|
response = SchedulingCalendarActionResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
created_event_ids=[event_id] if event_id else [],
|
created_event_ids=[event_id] if event_id else [],
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/requests/{request_id}/cancel", response_model=SchedulingStatusResponse)
|
@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)
|
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from 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)
|
@router.get("/requests/{request_id}/summary", response_model=SchedulingSummaryResponse)
|
||||||
@@ -313,14 +438,30 @@ def api_scheduling_summary(
|
|||||||
) -> SchedulingSummaryResponse:
|
) -> SchedulingSummaryResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
try:
|
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)
|
summary = scheduling_request_summary(session, tenant_id=principal.tenant_id, request_id=request_id)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingSummaryResponse(
|
response = SchedulingSummaryResponse(
|
||||||
request=_request_response(request),
|
request=_request_response(request, principal=principal),
|
||||||
poll_summary=PollResultSummaryResponse.model_validate(summary),
|
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)
|
@router.get("/notifications", response_model=SchedulingNotificationListResponse)
|
||||||
@@ -331,9 +472,11 @@ def api_list_scheduling_notifications(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
) -> SchedulingNotificationListResponse:
|
) -> SchedulingNotificationListResponse:
|
||||||
_require_scope(principal, READ_SCOPE)
|
_require_scope(principal, READ_SCOPE)
|
||||||
notifications = list_scheduling_notifications(
|
notifications = list_visible_scheduling_notifications(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
|
actor_ids=_principal_actor_ids(principal),
|
||||||
|
can_manage=_can_manage_scheduling(principal),
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
status=status_filter,
|
status=status_filter,
|
||||||
)
|
)
|
||||||
@@ -364,9 +507,11 @@ def api_create_scheduling_notifications(
|
|||||||
)
|
)
|
||||||
except SchedulingError as exc:
|
except SchedulingError as exc:
|
||||||
raise _scheduling_http_error(exc) from exc
|
raise _scheduling_http_error(exc) from exc
|
||||||
return SchedulingNotificationListResponse(
|
response = SchedulingNotificationListResponse(
|
||||||
notifications=[
|
notifications=[
|
||||||
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
SchedulingNotificationResponse.model_validate(scheduling_notification_response(notification))
|
||||||
for notification in notifications
|
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 pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
|
||||||
|
|
||||||
|
|
||||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||||
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
SchedulingParticipantType = Literal["internal", "external", "resource"]
|
||||||
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
SchedulingParticipantStatus = Literal["draft", "invited", "responded", "declined", "removed"]
|
||||||
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
SchedulingResultVisibility = Literal["organizer", "after_response", "after_close", "public"]
|
||||||
|
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
class SchedulingCalendarPreferences(BaseModel):
|
class SchedulingCalendarPreferences(BaseModel):
|
||||||
@@ -164,9 +163,48 @@ class SchedulingDecisionRequest(BaseModel):
|
|||||||
handoff_to_calendar: bool | None = None
|
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):
|
class SchedulingSummaryResponse(BaseModel):
|
||||||
request: SchedulingRequestResponse
|
request: SchedulingRequestResponse
|
||||||
poll_summary: PollResultSummaryResponse
|
poll_summary: SchedulingPollSummaryResponse
|
||||||
|
|
||||||
|
|
||||||
class SchedulingCalendarActionResponse(BaseModel):
|
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.assertNotIn("access", manifest.dependencies)
|
||||||
self.assertIn("access", manifest.optional_dependencies)
|
self.assertIn("access", manifest.optional_dependencies)
|
||||||
self.assertIn("addresses", 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("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.assertIn("evaluation", manifest.optional_dependencies)
|
||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
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("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("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||||
self.assertIn("addresses.lookup", {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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@
|
|||||||
},
|
},
|
||||||
"./styles/scheduling.css": "./src/styles/scheduling.css"
|
"./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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"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;
|
handoff_to_calendar?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SchedulingAvailabilityValue = "available" | "maybe" | "unavailable";
|
||||||
|
|
||||||
|
export type SchedulingAvailabilityPayload = {
|
||||||
|
answers: Array<{
|
||||||
|
slot_id: string;
|
||||||
|
value: SchedulingAvailabilityValue;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SchedulingCalendarActionResponse = {
|
export type SchedulingCalendarActionResponse = {
|
||||||
request: SchedulingRequest;
|
request: SchedulingRequest;
|
||||||
created_event_ids: string[];
|
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));
|
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> {
|
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
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 = {
|
export const generatedTranslations = {
|
||||||
en: {},
|
en: {
|
||||||
de: {}
|
"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;
|
box-sizing: border-box;
|
||||||
height: calc(100vh - 115px);
|
height: calc(100vh - 115px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -15,48 +14,87 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-shell {
|
.scheduling-shell {
|
||||||
min-height: 0;
|
|
||||||
height: 100%;
|
|
||||||
display: grid;
|
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);
|
border: var(--border-line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-sidebar {
|
.scheduling-sidebar,
|
||||||
|
.scheduling-workspace,
|
||||||
|
.scheduling-full-editor {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduling-sidebar {
|
||||||
border-right: var(--border-line);
|
border-right: var(--border-line);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-sidebar-bar,
|
.scheduling-sidebar-bar,
|
||||||
.scheduling-topbar,
|
.scheduling-topbar,
|
||||||
.scheduling-panel-heading,
|
.scheduling-page-header {
|
||||||
.scheduling-mini-heading,
|
min-height: 58px;
|
||||||
.scheduling-status-row,
|
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-actions,
|
||||||
.scheduling-title-line {
|
.scheduling-title-line,
|
||||||
|
.scheduling-page-title,
|
||||||
|
.scheduling-status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-sidebar-bar {
|
.scheduling-sidebar-bar,
|
||||||
|
.scheduling-topbar,
|
||||||
|
.scheduling-page-header,
|
||||||
|
.scheduling-section-heading {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
min-height: 54px;
|
}
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: var(--border-line);
|
.scheduling-sidebar-actions,
|
||||||
background: var(--panel-header);
|
.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 {
|
.scheduling-icon-button.btn {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
min-height: 34px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -67,30 +105,61 @@
|
|||||||
padding: 8px;
|
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 {
|
.scheduling-list-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 50px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 40px;
|
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
padding: 8px 9px;
|
padding: 8px 10px;
|
||||||
border: 0;
|
border: 1px solid transparent;
|
||||||
border-radius: 6px;
|
border-radius: 7px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-item:hover,
|
.scheduling-list-item:hover {
|
||||||
.scheduling-list-item.is-selected {
|
|
||||||
background: var(--hover-bg);
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-item span,
|
.scheduling-list-item.is-selected {
|
||||||
.scheduling-slot-row strong,
|
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 {
|
.scheduling-compact-row span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -99,121 +168,110 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-list-item small,
|
.scheduling-list-item small,
|
||||||
.scheduling-slot-row small,
|
.scheduling-title-line small,
|
||||||
.scheduling-compact-row small,
|
.scheduling-page-title p,
|
||||||
.scheduling-note {
|
.scheduling-section-heading p,
|
||||||
|
.scheduling-note,
|
||||||
|
.scheduling-list-empty,
|
||||||
|
.scheduling-compact-row small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
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 {
|
.scheduling-workspace {
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
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 {
|
.scheduling-title-line {
|
||||||
min-width: 180px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-actions {
|
.scheduling-title-line > div {
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
justify-content: flex-end;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-actions .btn,
|
.scheduling-alert,
|
||||||
.scheduling-create-panel .btn,
|
.scheduling-success {
|
||||||
.scheduling-slot-row .btn {
|
margin: 10px 14px 0;
|
||||||
min-height: 32px;
|
padding: 9px 11px;
|
||||||
display: inline-flex;
|
border: var(--border-line);
|
||||||
align-items: center;
|
border-radius: 7px;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-alert {
|
.scheduling-alert {
|
||||||
margin: 8px 12px 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-content {
|
.scheduling-success {
|
||||||
min-height: 0;
|
border-color: var(--success);
|
||||||
display: grid;
|
color: var(--success);
|
||||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||||
gap: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-create-panel,
|
.scheduling-detail,
|
||||||
.scheduling-detail {
|
.scheduling-editor-scroll {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-create-panel {
|
.scheduling-detail {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
border-right: var(--border-line);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-create-panel label,
|
.scheduling-card {
|
||||||
.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 {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 34px;
|
min-width: 0;
|
||||||
padding: 6px 8px;
|
padding: 14px;
|
||||||
border: var(--border-line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
color: var(--text);
|
background: var(--panel);
|
||||||
background: var(--input-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-checkbox {
|
.scheduling-card h2,
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
.scheduling-page-header h1,
|
||||||
align-items: center;
|
.scheduling-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-draft,
|
.scheduling-card h2,
|
||||||
.scheduling-participant-draft {
|
.scheduling-section-heading h2 {
|
||||||
display: grid;
|
font-size: 15px;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-draft {
|
.scheduling-page-header h1 {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
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 {
|
.scheduling-status-row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-status {
|
.scheduling-status {
|
||||||
@@ -222,7 +280,7 @@
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-status-collecting,
|
.scheduling-status-collecting,
|
||||||
@@ -230,33 +288,76 @@
|
|||||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-table {
|
.scheduling-table-card {
|
||||||
display: grid;
|
padding: 0;
|
||||||
gap: 6px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-row {
|
.scheduling-table-card > .scheduling-section-heading {
|
||||||
display: grid;
|
min-height: 52px;
|
||||||
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
|
padding: 9px 14px;
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 46px;
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: var(--border-line);
|
border-bottom: var(--border-line);
|
||||||
|
background: var(--panel-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-row:hover {
|
.scheduling-table-scroll {
|
||||||
background: var(--hover-bg);
|
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 {
|
.scheduling-freebusy {
|
||||||
min-width: 74px;
|
display: inline-block;
|
||||||
padding: 3px 7px;
|
margin-top: 3px;
|
||||||
border-radius: 6px;
|
padding: 2px 6px;
|
||||||
text-align: center;
|
border-radius: 5px;
|
||||||
font-size: 12px;
|
color: var(--muted);
|
||||||
font-weight: 700;
|
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-freebusy.free {
|
.scheduling-freebusy.free {
|
||||||
@@ -272,37 +373,218 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-compact-row {
|
.scheduling-compact-row {
|
||||||
|
min-height: 36px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 34px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: var(--border-line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-empty {
|
.scheduling-compact-row:last-child {
|
||||||
padding: 20px;
|
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);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
.scheduling-response-grid select,
|
||||||
.scheduling-shell,
|
.scheduling-field input,
|
||||||
.scheduling-content,
|
.scheduling-field textarea,
|
||||||
.scheduling-columns {
|
.scheduling-edit-table input:not([type="checkbox"]) {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
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-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,
|
.scheduling-sidebar-bar {
|
||||||
.scheduling-create-panel {
|
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-right: 0;
|
||||||
border-bottom: var(--border-line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scheduling-slot-row {
|
.scheduling-workspace,
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
.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