11 Commits
Author SHA1 Message Date
zemion 96c48a7f2d refactor(committee): reuse exact Core actor selection contract
Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.22. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:37 +02:00
zemion 7c2ec4ec17 fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:08 +02:00
zemion e84fb631f0 Release govoplan-committee v0.1.21: unify interface contracts and documentation 2026-09-08 01:32:27 +02:00
zemion 46ba69fbfe fix(webui): review committee identifier risk
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:34 +02:00
zemion 432f8bbd02 docs(committee): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:22 +02:00
zemion 049ea80385 feat(committee): add governed DSAR coverage 2026-08-21 12:16:00 +02:00
zemion 3259741765 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion be411e4ca8 feat: align committee with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 75c9488f66 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 0e03f6a70c Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion 1351338590 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
12 changed files with 973 additions and 116 deletions
+18
View File
@@ -100,3 +100,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan-core
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply /mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-committee --apply
``` ```
## Git-source WebUI package
The repository root exposes `@govoplan/committee-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/committee-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+29 -3
View File
@@ -1,8 +1,34 @@
{ {
"name": "@govoplan/committee", "name": "@govoplan/committee-webui",
"version": "0.1.17", "version": "0.1.22",
"private": true, "private": true,
"description": "GovOPlaN Committee platform module seed.", "description": "GovOPlaN Committee platform module seed.",
"type": "module", "type": "module",
"peerDependencies": {} "peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/committee.css": "./webui/src/styles/committee.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
} }
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-committee" name = "govoplan-committee"
version = "0.1.17" version = "0.1.22"
description = "GovOPlaN committee governance and formal-decision integration module." description = "GovOPlaN committee governance and formal-decision integration module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.17", "govoplan-core>=0.1.46",
"govoplan-access>=0.1.17", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,396 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
COMMITTEE_DSAR_CAPABILITY = dsar_capability_name("committee")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
object_kind: str | None
object_id: str | None
decision_id: str | None
class CommitteeDsarProvider:
provider_id = "committee"
module_id = "committee"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
revisions = db.query(CommitteeWorkspaceRevision).filter(
CommitteeWorkspaceRevision.tenant_id == tenant_id,
CommitteeWorkspaceRevision.changed_by.in_(selectors.actor_ids),
)
events = db.query(CommitteeWorkspaceEvent).filter(
CommitteeWorkspaceEvent.tenant_id == tenant_id,
CommitteeWorkspaceEvent.actor_id.in_(selectors.actor_ids),
)
decisions = db.query(CommitteeDecisionProjection).filter(
CommitteeDecisionProjection.tenant_id == tenant_id,
CommitteeDecisionProjection.changed_by.in_(selectors.actor_ids),
)
if selectors.object_kind:
revisions = revisions.filter(
CommitteeWorkspaceRevision.object_kind == selectors.object_kind
)
events = events.filter(
CommitteeWorkspaceEvent.object_kind == selectors.object_kind
)
if selectors.object_id:
revisions = revisions.filter(
CommitteeWorkspaceRevision.object_id == selectors.object_id
)
events = events.filter(
CommitteeWorkspaceEvent.object_id == selectors.object_id
)
if selectors.object_kind == "meeting":
decisions = decisions.filter(
CommitteeDecisionProjection.meeting_id == selectors.object_id
)
elif selectors.object_kind in {"agenda", "agenda_item"}:
decisions = decisions.filter(
CommitteeDecisionProjection.agenda_item_id == selectors.object_id
)
elif selectors.object_kind == "decision":
decisions = decisions.filter(
CommitteeDecisionProjection.decision_id == selectors.object_id
)
if selectors.decision_id:
decisions = decisions.filter(
CommitteeDecisionProjection.decision_id == selectors.decision_id
)
records.extend(
_revision_attribution(row)
for row in _limited(
revisions,
CommitteeWorkspaceRevision.recorded_at,
CommitteeWorkspaceRevision.id,
label="workspace revision attribution",
)
)
records.extend(
_event_attribution(row)
for row in _limited(
events,
CommitteeWorkspaceEvent.occurred_at,
CommitteeWorkspaceEvent.id,
label="workspace event attribution",
)
)
records.extend(
_decision_attribution(row)
for row in _limited(
decisions,
CommitteeDecisionProjection.recorded_at,
CommitteeDecisionProjection.id,
label="Decision projection attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Committee DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Committee DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=(
f"committee:retain:{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=record.retention_reason
or "Committee attribution is immutable evidence.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Committee DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Committee DSAR publishes retain-only actions.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Committee workspace, event, and Decision attribution remains "
"immutable institutional evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("committee.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("committee.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("committee.identity"),
references.get("identity.id"),
),
"actor_id": _coalesce(
references.get("committee.actor"),
references.get("committee.changed_by"),
),
"object_kind": _coalesce(
references.get("committee.object_kind"),
references.get("committee.kind"),
),
"object_id": _coalesce(
references.get("committee.object"),
references.get("committee.object_id"),
),
"decision_id": _coalesce(
references.get("committee.decision"),
references.get("committee.decision_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_actor = _optional_string(values["actor_id"])
if direct_actor:
if actor_ids and direct_actor not in actor_ids:
return None
if not actor_ids:
actor_ids = (direct_actor,)
if not actor_ids:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
object_kind=_optional_string(values["object_kind"]),
object_id=_optional_string(values["object_id"]),
decision_id=_optional_string(values["decision_id"]),
)
def _revision_attribution(row: CommitteeWorkspaceRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_workspace_actor_attribution",
resource_id=row.id,
category="committee_workspace_accountability",
title="Committee workspace revision attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"revision": row.revision,
"parent_kind": row.parent_kind,
"parent_id": row.parent_id,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_workspace_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Committee workspace attribution is immutable evidence.",
)
def _event_attribution(row: CommitteeWorkspaceEvent) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_event_actor_attribution",
resource_id=row.id,
category="committee_workspace_accountability",
title="Committee lifecycle event attribution",
data={
"object_kind": row.object_kind,
"object_id": row.object_id,
"object_revision": row.object_revision,
"event_id": row.event_id,
"event_type": row.event_type,
"occurred_at": _iso(row.occurred_at),
},
observed_at=_aware(row.occurred_at),
immutable_evidence=True,
retention_reason="Committee event attribution is immutable evidence.",
)
def _decision_attribution(row: CommitteeDecisionProjection) -> DsarRecordRef:
return DsarRecordRef(
provider_id="committee",
module_id="committee",
resource_type="committee_decision_actor_attribution",
resource_id=row.id,
category="committee_decision_accountability",
title="Committee fallback Decision attribution",
data={
"decision_id": row.decision_id,
"revision": row.revision,
"meeting_id": row.meeting_id,
"agenda_item_id": row.agenda_item_id,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_decision_projection",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason=(
"Committee fallback Decision attribution is immutable evidence."
),
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Committee DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Committee DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "committee" or record.module_id != "committee":
raise ValueError("Committee DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Committee DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "committee" or action.module_id != "committee":
raise ValueError("Committee DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("committee:retain:"):
raise ValueError("Committee DSAR action identity is invalid.")
__all__ = ["COMMITTEE_DSAR_CAPABILITY", "CommitteeDsarProvider"]
+178 -2
View File
@@ -12,6 +12,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -23,6 +24,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
@@ -45,6 +47,10 @@ from govoplan_committee.backend.ballots import (
CommitteeBallotFinalizer, CommitteeBallotFinalizer,
) )
from govoplan_committee.backend.db import models as committee_models from govoplan_committee.backend.db import models as committee_models
from govoplan_committee.backend.dsar_provider import (
COMMITTEE_DSAR_CAPABILITY,
CommitteeDsarProvider,
)
from govoplan_committee.backend.workspace import ( from govoplan_committee.backend.workspace import (
CAPABILITY_COMMITTEE_WORKSPACE, CAPABILITY_COMMITTEE_WORKSPACE,
SqlCommitteeWorkspace, SqlCommitteeWorkspace,
@@ -53,7 +59,7 @@ from govoplan_core.db.base import Base
MODULE_ID = "committee" MODULE_ID = "committee"
MODULE_NAME = "Committee" MODULE_NAME = "Committee"
MODULE_VERSION = "0.1.17" MODULE_VERSION = "0.1.22"
READ_SCOPE = "committee:workspace:read" READ_SCOPE = "committee:workspace:read"
WRITE_SCOPE = "committee:workspace:write" WRITE_SCOPE = "committee:workspace:write"
BALLOT_SCOPE = "committee:ballot:finalize" BALLOT_SCOPE = "committee:ballot:finalize"
@@ -123,6 +129,10 @@ def _ballot_finalizer(context: ModuleContext) -> CommitteeBallotFinalizer:
return CommitteeBallotFinalizer(context.registry) return CommitteeBallotFinalizer(context.registry)
def _dsar_provider(_context: ModuleContext) -> CommitteeDsarProvider:
return CommitteeDsarProvider()
def _router(context: ModuleContext): def _router(context: ModuleContext):
from govoplan_committee.backend.router import configure_registry, router from govoplan_committee.backend.router import configure_registry, router
@@ -226,6 +236,7 @@ DOCUMENTATION = (
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"), audience=("user", "operator", "module_admin", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
order=100, order=100,
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
@@ -236,6 +247,7 @@ DOCUMENTATION = (
), ),
), ),
metadata={ metadata={
"kind": "workflow",
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
"committee.navigation", "committee.navigation",
@@ -262,6 +274,35 @@ DOCUMENTATION = (
"generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle" "generic formal decision authority, reasoning, effect, review, correction, or revocation lifecycle"
], ],
}, },
translations={
"de": {
"title": "Abgrenzung des Committee-Moduls",
"summary": (
"Arbeitsabläufe für Ausschüsse, Vorstände, Räte und Senate mit Sitzungen, Tagesordnungen, Protokollen, Beratung, Abstimmung, formellen Entscheidungsverweisen und Folgeaufgaben."
),
"body": (
"Der dauerhafte Arbeitsbereich bewahrt unveränderliche Gremien, Sitzungen, Tagesordnungspunkte, gesteuerte "
"Abstimmungsergebnisse, Protokolle und Lebenszyklusereignisse. Der Entscheidungspfad konstruiert daraus, aus "
"einer wirksamen Mandatsauflösung, Genehmigung, versionierten Rechtsgrundlagen, Nachweisen, Begründung und "
"beantragten oder beobachteten Wirkungen eine rekonstruierbare formelle Entscheidung. Committee führt keine "
"generische Mandats- oder Entscheidungspersistenz; optionale Anbieter lösen diese Objekte auf und speichern "
"sie, während eine geschützte lokale Projektion den begrenzten Rückfall bewahrt. An einen Anbieter gebundene "
"Stimmabgaben werden über Adapterfähigkeiten abgeschlossen, ohne einzelne Stimmen aufzubewahren. Von Voting "
"gelieferte Ergebnisse bewahren bereinigte Anbieterzusicherungen und stufen den nicht zertifizierten lokalen "
"vertraulichen Referenzanbieter niemals zu einem zertifizierten Profil hoch."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"privacy_notes": [
"Committee-Listen zeigen nur Datensätze, die im aktiven Mandanten- und Berechtigungskontext autorisiert sind.",
"Anbietergestützte Abstimmungen speichern aggregierte Ergebnisnachweise, keine einzelnen vertraulichen Stimmen.",
"Geschützte Entscheidungsbegründungen erfordern die besondere Berechtigung zum geschützten Lesen.",
]
}
},
), ),
DocumentationTopic( DocumentationTopic(
id=f"{MODULE_ID}.reference.fields-and-consequences", id=f"{MODULE_ID}.reference.fields-and-consequences",
@@ -289,6 +330,7 @@ DOCUMENTATION = (
), ),
), ),
metadata={ metadata={
"kind": "reference",
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
"committee.field.state", "committee.field.state",
@@ -309,6 +351,36 @@ DOCUMENTATION = (
"cancel_or_retire": "Stops future use while retaining institutional and audit evidence.", "cancel_or_retire": "Stops future use while retaining institutional and audit evidence.",
}, },
}, },
translations={
"de": {
"title": "Committee-Felder und Folgen",
"summary": (
"Feldherkunft, Lebenszyklusbeschränkungen, optionale Anbieterverweise und Nachweisfolgen für Committee-Datensätze."
),
"body": (
"Organisationseinheitsverweise eines Gremiums bestimmen den institutionellen Kontext, gewähren aber selbst "
"keinen Zugriff. Sitzungszeiten begründen den Tagesordnungskontext. Gegenstands-, Decision-, Approval-, "
"Records- und Abstimmungskennungen sind stabile modulübergreifende Verweise und bleiben nur optional, soweit "
"der aktuelle Lebenszyklus dies erlaubt. Zustandsübergänge erzeugen neue unveränderliche Revisionen; endgültige "
"Datensätze können nicht an Ort und Stelle bearbeitet werden. Der Abschluss einer Abstimmung oder die Annahme "
"eines Protokolls erfordert den zugehörigen gesteuerten Nachweis. Die anbietergebundene Finalisierung importiert "
"nur ein aggregiertes Ergebnis und Zusicherungsnachweise. Änderungsgründe bleiben mit jeder Revision erhalten. "
"Abbruch, Rücknahme oder Außerbetriebnahme stoppt künftige Arbeit, löscht aber keine bestehenden Revisionen, "
"Entscheidungen, Protokolle oder Prüfnachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"save_revision": "Erzeugt eine neue unveränderliche Committee-Revision mit ihrem Änderungsgrund.",
"change_lifecycle": "Ändert die künftig möglichen Aktionen; endgültige Zustände sind unveränderlich.",
"finalize_ballot": "Importiert ein gesteuertes aggregiertes Ergebnis und Nachweise, ohne einzelne Stimmen aufzubewahren.",
"cancel_or_retire": "Stoppt die künftige Nutzung und bewahrt institutionelle sowie Prüfnachweise.",
}
}
},
), ),
) )
@@ -357,6 +429,17 @@ manifest = ModuleManifest(
order=38, order=38,
), ),
), ),
product_areas=(
ProductAreaContribution(
id="meetings-decisions",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.meetings_decisions",
icon="calendar",
description="i18n:govoplan-core.product_area.meetings_decisions_description",
surface_ids=("committee.nav.committee", "committee.route.committee"),
order=50,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="committee.navigation", id="committee.navigation",
@@ -387,6 +470,7 @@ manifest = ModuleManifest(
name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER, name=CAPABILITY_COMMITTEE_BALLOT_FINALIZER,
version="0.1.0", version="0.1.0",
), ),
ModuleInterfaceProvider(name=COMMITTEE_DSAR_CAPABILITY, version="0.1.0"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -418,6 +502,7 @@ manifest = ModuleManifest(
CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path, CAPABILITY_COMMITTEE_DECISION_PATH: _decision_path,
CAPABILITY_COMMITTEE_WORKSPACE: _workspace, CAPABILITY_COMMITTEE_WORKSPACE: _workspace,
CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer, CAPABILITY_COMMITTEE_BALLOT_FINALIZER: _ballot_finalizer,
COMMITTEE_DSAR_CAPABILITY: _dsar_provider,
}, },
capability_documentation={ capability_documentation={
CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation( CAPABILITY_COMMITTEE_DECISION_PATH: CapabilityDocumentation(
@@ -435,6 +520,14 @@ manifest = ModuleManifest(
summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.", summary="Imports aggregate result evidence from provider-neutral ballot adapters without persisting individual ballots.",
contract_version="0.1.0", contract_version="0.1.0",
), ),
COMMITTEE_DSAR_CAPABILITY: CapabilityDocumentation(
label="Committee data-subject request provider",
summary=(
"Exports minimized workspace, event, and fallback-Decision actor "
"attribution without deliberation or minute payloads."
),
contract_version="0.1.0",
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -460,7 +553,90 @@ manifest = ModuleManifest(
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION, documentation=(
DocumentationTopic(
id="committee.workspace-layout",
title="Committee workspace layout",
summary="Find workspace actions and read consistently arranged content.",
body="Reload and New body share the workspace-wide action bar at the upper right; Reload sits directly before New. Searching and selecting a body, meeting, or agenda item leaves those workspace actions in place. Item editing, ballots, and finalization remain scoped to the selected record. Disabled controls explain missing authority; administrators grant the existing management permissions rather than changing layouts.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Gremien: Aufbau des Arbeitsbereichs",
"summary": "Arbeitsbereichsaktionen finden und einheitlich angeordnete Inhalte lesen.",
"body": "Neu laden und Neues Gremium stehen oben rechts in der gemeinsamen Arbeitsbereichsleiste; Neu laden steht unmittelbar vor Neu. Suche und Auswahl eines Gremiums, einer Sitzung oder eines Tagesordnungspunkts verändern diese Positionen nicht. Bearbeitung, Abstimmungen und Abschluss bleiben dem ausgewählten Datensatz zugeordnet. Deaktivierte Aktionen erklären fehlende Berechtigungen; Administratoren vergeben die bestehenden Verwaltungsrechte, statt das Layout zu ändern.",
}},
),
DocumentationTopic(
id="committee.data-subject-requests",
title="Committee data-subject requests",
summary=(
"Export minimized actor attribution from immutable Committee records "
"without disclosing deliberation or ballot content."
),
body=(
"Committee correlates exact account, membership, identity, or explicit "
"actor identifiers inside the active tenant. Optional object-kind, "
"object, or Decision identifiers only narrow an already verified actor "
"search. Workspace results contain stable object, parent, revision, "
"state, and timing context. Event and fallback-Decision results contain "
"only lifecycle attribution and stable meeting or agenda references. "
"Search text, minutes, deliberation payloads, individual ballots, "
"request hashes, idempotency values, and referenced provider records "
"are excluded and never traversed. All returned attribution is retained "
"as immutable institutional evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "decisions", "voting", "records"),
metadata={
"kind": "reference",
"help_contexts": [
"committee.workspace",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_actor_attribution": (
"Returns stable lifecycle context without institutional payloads."
),
"retain_committee_history": (
"Preserves immutable meeting and Decision accountability evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Committee",
"summary": (
"Minimierte Akteurszuordnungen aus unveränderlichen Committee-Datensätzen ausgeben, ohne Beratungs- oder Abstimmungsinhalte offenzulegen."
),
"body": (
"Committee gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
"Akteurskennungen ab. Optionale Objektart-, Objekt- oder Decision-Kennungen schränken nur eine bereits "
"verifizierte Akteurssuche ein. Arbeitsbereichsergebnisse enthalten stabilen Objekt-, Eltern-, Revisions-, "
"Zustands- und Zeitkontext. Ereignis- und Rückfall-Decision-Ergebnisse enthalten ausschließlich "
"Lebenszykluszuordnungen sowie stabile Sitzungs- oder Tagesordnungsverweise. Suchtext, Protokolle, "
"Beratungsinhalte, einzelne Stimmen, Anfrageprüfsummen, Idempotenzwerte und referenzierte Anbieterdatensätze "
"werden ausgeschlossen und nie traversiert. Alle zurückgegebenen Zuordnungen bleiben unveränderliche "
"institutionelle Nachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_actor_attribution": "Gibt stabilen Lebenszykluskontext ohne institutionelle Inhalte zurück.",
"retain_committee_history": "Bewahrt unveränderliche Verantwortungsnachweise zu Sitzungen und Decisions.",
}
}
},
),
*DOCUMENTATION,
),
architecture=ARCHITECTURE, architecture=ARCHITECTURE,
) )
+1 -14
View File
@@ -11,6 +11,7 @@ import uuid
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.principal_helpers import principal_user_first_actor as _principal_actor
from govoplan_core.core.events import ( from govoplan_core.core.events import (
EventActorRef, EventActorRef,
EventObjectRef, EventObjectRef,
@@ -1001,20 +1002,6 @@ def _principal_tenant(principal: object) -> str:
return tenant_id return tenant_id
def _principal_actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
for value in (
getattr(user, "id", None),
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
candidate = str(value or "").strip()
if candidate:
return candidate
return None
def _session(value: object) -> Session: def _session(value: object) -> Session:
if not hasattr(value, "query"): if not hasattr(value, "query"):
raise InstitutionalContextError( raise InstitutionalContextError(
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_committee.backend.manifest import manifest
class CommitteeDocumentationTests(unittest.TestCase):
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
by_id = {topic.id: topic for topic in topics}
self.assertEqual(len(topics), len(by_id), "Documentation topic IDs must be unique")
self.assertLessEqual(
{
"committee.module-boundary",
"committee.reference.fields-and-consequences",
"committee.data-subject-requests",
"committee.workspace-layout",
},
set(by_id),
)
self.assertLessEqual(
{"user", "admin"},
set(by_id["committee.workspace-layout"].documentation_types),
)
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
workflow = next(topic for topic in topics if topic.metadata.get("kind") == "workflow")
self.assertTrue(workflow.conditions)
if __name__ == "__main__":
unittest.main()
+258
View File
@@ -0,0 +1,258 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_committee.backend.db.models import (
CommitteeDecisionProjection,
CommitteeWorkspaceEvent,
CommitteeWorkspaceRevision,
)
from govoplan_committee.backend.dsar_provider import (
COMMITTEE_DSAR_CAPABILITY,
CommitteeDsarProvider,
)
from govoplan_committee.backend.manifest import manifest
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
NOW = datetime(2026, 8, 21, 17, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: CommitteeDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (COMMITTEE_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != COMMITTEE_DSAR_CAPABILITY:
raise KeyError(name)
return "committee"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("committee",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != COMMITTEE_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "committee"})(),)
class CommitteeDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = CommitteeDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
CommitteeWorkspaceRevision(
id="workspace-revision-1",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-1",
revision=1,
parent_kind="body",
parent_id="body-1",
state="scheduled",
title="Private meeting title do not export",
search_text="private search text do not export",
recorded_at=NOW,
payload={"minutes": "private minutes do not export"},
changed_by="account-1",
),
CommitteeWorkspaceRevision(
id="workspace-revision-other",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-other",
revision=1,
state="scheduled",
title="Other meeting",
search_text="other search text",
recorded_at=NOW,
payload={"private": "other payload"},
changed_by="account-other",
),
)
)
self.session.add(
CommitteeWorkspaceEvent(
id="event-1",
tenant_id="tenant-1",
object_kind="meeting",
object_id="meeting-1",
object_revision=1,
event_id="event-external-1",
event_type="meeting.scheduled",
occurred_at=NOW,
actor_id="account-1",
idempotency_key="event-idempotency-do-not-export",
request_sha256="event-request-hash-do-not-export",
payload={"secret": "event-payload-do-not-export"},
)
)
self.session.add(
CommitteeDecisionProjection(
id="decision-projection-1",
tenant_id="tenant-1",
decision_id="decision-1",
revision="1",
meeting_id="meeting-1",
agenda_item_id="agenda-1",
state="effective",
recorded_at=NOW,
payload={
"reasoning": "decision-reasoning-do-not-export",
"ballot": "individual-ballot-do-not-export",
},
changed_by="account-1",
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_minimized_committee_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("meeting-1", exported)
self.assertIn("agenda-1", exported)
for excluded in (
"Private meeting title do not export",
"private search text do not export",
"private minutes do not export",
"event-idempotency-do-not-export",
"event-request-hash-do-not-export",
"event-payload-do-not-export",
"decision-reasoning-do-not-export",
"individual-ballot-do-not-export",
"meeting-other",
):
self.assertNotIn(excluded, exported)
def test_object_narrowing_and_actor_conflicts_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={
"committee.object_kind": "meeting",
"committee.object": "meeting-1",
},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"committee.actor": "account-other"},
),
)
object_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"committee.object": "meeting-1"}
),
)
self.assertEqual(
{
"committee_workspace_actor_attribution",
"committee_event_actor_attribution",
"committee_decision_actor_attribution",
},
{record.resource_type for record in narrowed},
)
self.assertEqual((), conflict)
self.assertEqual((), object_only)
def test_erasure_is_retain_only(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-committee-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertEqual(2, self.session.query(CommitteeWorkspaceRevision).count())
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(COMMITTEE_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-COMMITTEE-1",
request_kind="access",
subject=self._subject(),
purpose="Committee attribution access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(3, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/committee-webui", "name": "@govoplan/committee-webui",
"version": "0.1.17", "version": "0.1.22",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/committee.css": "./src/styles/committee.css" "./styles/committee.css": "./src/styles/committee.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.17", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+23 -20
View File
@@ -4,24 +4,26 @@ import {
ListPlus, ListPlus,
Pencil, Pencil,
Plus, Plus,
RefreshCw,
Search, Search,
ShieldCheck, ShieldCheck,
Vote Vote
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { import { ActionBlockerHint,
ActionBlockerHint,
Button, Button,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FilterBar,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatePanel,
StatusBadge, StatusBadge,
hasScope, hasScope,
i18nMessage, i18nMessage,
usePlatformLanguage, usePlatformLanguage,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -182,17 +184,18 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
return ( return (
<main className="committee-page"> <main className="committee-page">
<div className="committee-shell"> <WorkspaceFrame className="committee-shell" label="Committee workspace" interfaceId="committee.workspace" helpContextId="committee.page.workspace" helpModuleId="committee">
<div className="committee-toolbar"> <WorkspaceActionBar
<form onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search"> scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void refresh(), loading }}
className="committee-toolbar"
contextActions={<FilterBar as="form" surface="control" wrap="never" width="compact" onSubmit={(event) => { event.preventDefault(); void refresh(); }} className="committee-search">
<Search size={16} aria-hidden="true" /> <Search size={16} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search bodies" aria-label="Search committee bodies" />
</form> </FilterBar>}
<Button variant="ghost" onClick={() => void refresh()} disabled={loading} disabledReason={loading ? COMMITTEE_INTERFACE_I18N.loading : undefined}> createAction={<Button
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
<Button
variant="primary" variant="primary"
disabled={!canWrite || loading} disabled={!canWrite || loading}
disabledReason={committeeDisabledReason({ loading, permitted: canWrite })} disabledReason={committeeDisabledReason({ loading, permitted: canWrite })}
@@ -200,9 +203,9 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
> >
<Plus size={16} aria-hidden="true" /> <Plus size={16} aria-hidden="true" />
New body New body
</Button> </Button>}
<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} /> helpAction={<DocumentationHelpLink reference={COMMITTEE_DOCUMENTATION} />}
</div> />
{error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" resetKey={error} className="committee-alert">{error}</DismissibleAlert> : null}
{loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null} {loading && bodies.length === 0 ? <LoadingIndicator label="Loading committee workspace" /> : null}
@@ -262,7 +265,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
<section className="committee-detail" aria-label="Meeting workspace"> <section className="committee-detail" aria-label="Meeting workspace">
{!selectedMeeting ? ( {!selectedMeeting ? (
<div className="committee-empty">Select or create a meeting.</div> <StatePanel size="fill" title="Meetings" description="Select or create a meeting." />
) : ( ) : (
<> <>
<div className="committee-detail-heading"> <div className="committee-detail-heading">
@@ -310,7 +313,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
/> />
</div> </div>
))} ))}
{agendaItems.length === 0 ? <div className="committee-empty compact">No agenda items.</div> : null} {agendaItems.length === 0 ? <StatePanel size="inline" description="No agenda items." /> : null}
</div> </div>
</WorkspaceSection> </WorkspaceSection>
@@ -373,7 +376,7 @@ export default function CommitteePage({ settings, auth }: PlatformRouteContext)
)} )}
</section> </section>
</div> </div>
</div> </WorkspaceFrame>
{editor ? ( {editor ? (
<CommitteeRecordDialog <CommitteeRecordDialog
@@ -410,7 +413,7 @@ function RecordList({ records, selectedId, onSelect, secondary }: {
onSelect: (id: string) => void; onSelect: (id: string) => void;
secondary?: (record: CommitteeRecord) => string; secondary?: (record: CommitteeRecord) => string;
}) { }) {
if (records.length === 0) return <div className="committee-empty compact">No records.</div>; if (records.length === 0) return <StatePanel size="inline" description="No records." />;
return <div className="committee-record-list">{records.map((record) => ( return <div className="committee-record-list">{records.map((record) => (
<button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}> <button key={record.object_id} type="button" className={record.object_id === selectedId ? "is-selected" : ""} onClick={() => onSelect(record.object_id)}>
<strong>{record.title}</strong> <strong>{record.title}</strong>
@@ -430,7 +433,7 @@ function RecordRows({ records, editDisabledReason, onEdit, detail, secondaryActi
detail: (record: CommitteeRecord) => string; detail: (record: CommitteeRecord) => string;
secondaryAction?: (record: CommitteeRecord) => ReactNode; secondaryAction?: (record: CommitteeRecord) => ReactNode;
}) { }) {
if (records.length === 0) return <div className="committee-empty compact">No records.</div>; if (records.length === 0) return <StatePanel size="inline" description="No records." />;
return <div className="committee-record-rows">{records.map((record) => ( return <div className="committee-record-rows">{records.map((record) => (
<div key={record.object_id}> <div key={record.object_id}>
<span><strong>{record.title}</strong><small>{detail(record)}</small></span> <span><strong>{record.title}</strong><small>{detail(record)}</small></span>
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import { FormGrid,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
@@ -176,7 +176,7 @@ export default function CommitteeRecordDialog({
<div className="committee-record-form"> <div className="committee-record-form">
<div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div> <div className="committee-dialog-help"><DocumentationHelpLink reference={COMMITTEE_FIELD_DOCUMENTATION} /></div>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<div className="committee-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Title" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input <input
value={draft.title} value={draft.title}
@@ -194,7 +194,7 @@ export default function CommitteeRecordDialog({
{stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)} {stateOptions.map((state) => <option key={state} value={state}>{stateLabel(state)}</option>)}
</select> </select>
</FormField> </FormField>
</div> </FormGrid>
{kind === "body" ? ( {kind === "body" ? (
<FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Responsible organization unit ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
@@ -207,19 +207,19 @@ export default function CommitteeRecordDialog({
) : null} ) : null}
{kind === "meeting" ? ( {kind === "meeting" ? (
<div className="committee-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Starts"> <FormField label="Starts">
<input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} /> <input type="datetime-local" value={draft.startsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, startsAt: event.target.value })} />
</FormField> </FormField>
<FormField label="Ends"> <FormField label="Ends">
<input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} /> <input type="datetime-local" value={draft.endsAt} disabled={busy} onChange={(event) => setDraft({ ...draft, endsAt: event.target.value })} />
</FormField> </FormField>
</div> </FormGrid>
) : null} ) : null}
{kind === "agenda_item" ? ( {kind === "agenda_item" ? (
<> <>
<div className="committee-form-grid committee-form-grid-three"> <FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Position"> <FormField label="Position">
<input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} /> <input type="number" min="1" value={draft.position} disabled={busy} onChange={(event) => setDraft({ ...draft, position: event.target.value })} />
</FormField> </FormField>
@@ -234,10 +234,10 @@ export default function CommitteeRecordDialog({
<FormField label="Subject ID"> <FormField label="Subject ID">
<input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} /> <input value={draft.subjectId} disabled={busy} onChange={(event) => setDraft({ ...draft, subjectId: event.target.value })} />
</FormField> </FormField>
</div> </FormGrid>
{draft.state === "decided" ? ( {draft.state === "decided" ? (
<FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Formal Decision ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} /> <input data-help-risk-reviewed="standard" value={draft.decisionId} disabled={busy} onChange={(event) => setDraft({ ...draft, decisionId: event.target.value })} />
</FormField> </FormField>
) : null} ) : null}
</> </>
@@ -245,7 +245,7 @@ export default function CommitteeRecordDialog({
{kind === "vote" ? ( {kind === "vote" ? (
<> <>
<div className="committee-form-grid committee-form-grid-three"> <FormGrid columns={3} gap="compact" collapseAt="narrow">
<FormField label="Method"> <FormField label="Method">
<select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}> <select value={draft.method} disabled={busy} onChange={(event) => setDraft({ ...draft, method: event.target.value })}>
<option value="recorded">Recorded</option> <option value="recorded">Recorded</option>
@@ -259,7 +259,7 @@ export default function CommitteeRecordDialog({
<FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Ballot provider (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} /> <input value={draft.providerId} disabled={busy} onChange={(event) => setDraft({ ...draft, providerId: event.target.value })} />
</FormField> </FormField>
</div> </FormGrid>
<FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Voting ballot ID (optional)" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} /> <input value={draft.votingBallotId} disabled={busy} onChange={(event) => setDraft({ ...draft, votingBallotId: event.target.value })} />
</FormField> </FormField>
@@ -268,7 +268,7 @@ export default function CommitteeRecordDialog({
</FormField> </FormField>
{draft.state === "closed" ? ( {draft.state === "closed" ? (
<div className="committee-vote-result-fields"> <div className="committee-vote-result-fields">
<div className="committee-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Votes cast"> <FormField label="Votes cast">
<input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} /> <input type="number" min="0" value={draft.castCount} disabled={busy} onChange={(event) => setDraft({ ...draft, castCount: event.target.value })} />
</FormField> </FormField>
@@ -278,7 +278,7 @@ export default function CommitteeRecordDialog({
<option value="not-met">Not met</option> <option value="not-met">Not met</option>
</select> </select>
</FormField> </FormField>
</div> </FormGrid>
<div className="committee-count-grid"> <div className="committee-count-grid">
{choices.map((choice) => ( {choices.map((choice) => (
<FormField key={choice} label={`${choice} votes`}> <FormField key={choice} label={`${choice} votes`}>
@@ -344,14 +344,14 @@ export default function CommitteeRecordDialog({
function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) { function EvidenceFields({ draft, busy, setDraft }: { draft: Draft; busy: boolean; setDraft: (draft: Draft) => void }) {
return ( return (
<div className="committee-form-grid"> <FormGrid columns={2} gap="compact" collapseAt="narrow">
<FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Approval ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} /> <input value={draft.approvalId} disabled={busy} onChange={(event) => setDraft({ ...draft, approvalId: event.target.value })} />
</FormField> </FormField>
<FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}> <FormField label="Evidence record ID" documentation={COMMITTEE_FIELD_DOCUMENTATION}>
<input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} /> <input value={draft.evidenceId} disabled={busy} onChange={(event) => setDraft({ ...draft, evidenceId: event.target.value })} />
</FormField> </FormField>
</div> </FormGrid>
); );
} }
+3 -58
View File
@@ -1,39 +1,14 @@
.committee-page, .committee-page {
.committee-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.committee-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.committee-toolbar {
display: flex;
align-items: center;
gap: 9px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.committee-search { .committee-search {
display: flex; flex: 1 1 460px;
align-items: center;
gap: 8px;
width: min(460px, 100%);
margin-right: auto; margin-right: auto;
} }
.committee-search input {
min-width: 140px;
flex: 1;
}
.committee-alert { .committee-alert {
margin: 10px 16px 0; margin: 10px 16px 0;
} }
@@ -231,17 +206,6 @@
gap: 4px !important; gap: 4px !important;
} }
.committee-empty {
display: grid;
min-height: 180px;
place-items: center;
color: var(--text-soft);
}
.committee-empty.compact {
min-height: 72px;
}
.committee-record-dialog { .committee-record-dialog {
width: min(820px, calc(100vw - 32px)); width: min(820px, calc(100vw - 32px));
max-height: min(820px, calc(100vh - 32px)); max-height: min(820px, calc(100vh - 32px));
@@ -268,17 +232,11 @@
justify-content: flex-end; justify-content: flex-end;
} }
.committee-form-grid,
.committee-count-grid { .committee-count-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px; gap: 10px;
} }
.committee-form-grid-three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.committee-count-grid { .committee-count-grid {
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
} }
@@ -291,22 +249,13 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@media (max-width: 1050px) { @media (max-width: 1100px) {
.committee-workspace { .committee-workspace {
grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr); grid-template-columns: minmax(190px, 0.7fr) minmax(220px, 0.9fr) minmax(360px, 1.6fr);
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.committee-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.committee-search {
width: 100%;
}
.committee-workspace { .committee-workspace {
grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr); grid-template-columns: minmax(150px, 0.8fr) minmax(0, 1.8fr);
grid-template-rows: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr));
@@ -328,8 +277,4 @@
grid-row: 1 / span 2; grid-row: 1 / span 2;
} }
.committee-form-grid,
.committee-form-grid-three {
grid-template-columns: 1fr;
}
} }