chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent 57f6066bf7
commit 52e6ed49c5
66 changed files with 8868 additions and 4072 deletions

1
.gitignore vendored
View File

@@ -331,3 +331,4 @@ cython_debug/
# GovOPlaN WebUI test output # GovOPlaN WebUI test output
webui/.policy-test-build/ webui/.policy-test-build/
webui/.template-preview-test-build/ webui/.template-preview-test-build/
webui/.import-test-build/

View File

@@ -77,6 +77,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
## Operations ## Operations
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist. - [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
- [Example campaigns and release checklist](docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md) defines the maintained example scenarios and release gates.
- [Campaign examples](examples/README.md) is the credential-free scenario catalogue that release fixtures must follow.
- [SMTP/IMAP test bed](dev/mail-testbed/README.md) provides the GreenMail Docker Compose setup and transport smoke for dedicated non-production delivery tests.
## Release packaging ## Release packaging

View File

@@ -0,0 +1,11 @@
GOVOPLAN_MAIL_TEST_LOCAL_PART=campaign-test
GOVOPLAN_MAIL_TEST_DOMAIN=govoplan.test
GOVOPLAN_MAIL_TEST_USER=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_PASSWORD=campaign-test-password
GOVOPLAN_MAIL_TEST_RECIPIENT=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_FROM=campaign-test@govoplan.test
GOVOPLAN_MAIL_TEST_SMTP_PORT=3025
GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45

View File

@@ -0,0 +1,69 @@
# Campaign SMTP/IMAP Test Bed
This test bed provides dedicated non-production SMTP/IMAP infrastructure for
campaign delivery checks. It uses GreenMail and must never be configured with
production recipients or credentials.
## Start
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
cp .env.example .env
docker compose --env-file .env up -d
```
Default endpoints:
- SMTP: `127.0.0.1:3025`, plain transport
- IMAP: `127.0.0.1:3143`, plain transport
- GreenMail API/UI endpoint: `127.0.0.1:38080`
## Smoke Test
Run the transport smoke from the core virtual environment after installing the
campaign and mail modules:
```bash
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
set -a
. ./.env
set +a
/mnt/DATA/git/govoplan-core/.venv/bin/python run_transport_smoke.py
```
The smoke sends and appends three messages:
- no attachment
- one normal attachment
- one password-protected AES ZIP attachment
It verifies SMTP authentication, IMAP authentication, folder listing, delivery
to `INBOX`, and append-to-Sent behavior.
If the smoke is started immediately after `docker compose up -d`, GreenMail may
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
## Use With A Campaign
Use the same settings in a campaign mail profile:
- SMTP host `127.0.0.1`, port `3025`, security `plain`
- IMAP host `127.0.0.1`, port `3143`, security `plain`
- username and password from `.env`
- append folder from `GOVOPLAN_MAIL_TEST_SENT_FOLDER`
When changing the mailbox, keep `GOVOPLAN_MAIL_TEST_USER` equal to
`${GOVOPLAN_MAIL_TEST_LOCAL_PART}@${GOVOPLAN_MAIL_TEST_DOMAIN}` because the
compose file creates the GreenMail account from local part, password, and
domain while login uses the full email address.
Then run the controlled sending checklist from
`docs/CAMPAIGN_DELIVERY_RUNBOOK.md` for no attachment, one attachment, and
password-protected ZIP campaign variants.
## Stop
```bash
docker compose --env-file .env down -v
```

View File

@@ -0,0 +1,17 @@
services:
greenmail:
image: greenmail/standalone:2.1.9
container_name: govoplan-campaign-mail-testbed
restart: unless-stopped
environment:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.hostname=0.0.0.0
-Dgreenmail.auth.disabled=false
-Dgreenmail.users=${GOVOPLAN_MAIL_TEST_LOCAL_PART:-campaign-test}:${GOVOPLAN_MAIL_TEST_PASSWORD:-campaign-test-password}@${GOVOPLAN_MAIL_TEST_DOMAIN:-govoplan.test}
-Dgreenmail.users.login=email
-Dgreenmail.verbose
ports:
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
- "127.0.0.1:38080:8080"

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
from __future__ import annotations
import imaplib
import mimetypes
import os
import tempfile
import time
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from pathlib import Path
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
from govoplan_mail.backend.sending.imap import append_message_to_sent, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import send_email_message, test_smtp_login
from govoplan_campaign.backend.services.zip_service import create_zip_archive
def _env(name: str, default: str) -> str:
return os.environ.get(name, default).strip() or default
SMTP_HOST = _env("GOVOPLAN_MAIL_TEST_SMTP_HOST", "127.0.0.1")
IMAP_HOST = _env("GOVOPLAN_MAIL_TEST_IMAP_HOST", "127.0.0.1")
SMTP_PORT = int(_env("GOVOPLAN_MAIL_TEST_SMTP_PORT", "3025"))
IMAP_PORT = int(_env("GOVOPLAN_MAIL_TEST_IMAP_PORT", "3143"))
USERNAME = _env("GOVOPLAN_MAIL_TEST_USER", "campaign-test@govoplan.test")
PASSWORD = _env("GOVOPLAN_MAIL_TEST_PASSWORD", "campaign-test-password")
SENDER = _env("GOVOPLAN_MAIL_TEST_FROM", USERNAME)
RECIPIENT = _env("GOVOPLAN_MAIL_TEST_RECIPIENT", USERNAME)
SENT_FOLDER = _env("GOVOPLAN_MAIL_TEST_SENT_FOLDER", "Sent")
ZIP_PASSWORD = _env("GOVOPLAN_MAIL_TEST_ZIP_PASSWORD", "zip-test-password")
SERVICE_READY_TIMEOUT_SECONDS = int(_env("GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS", "45"))
def _smtp_config() -> SmtpConfig:
return SmtpConfig(
host=SMTP_HOST,
port=SMTP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
timeout_seconds=10,
)
def _imap_config() -> ImapConfig:
return ImapConfig(
host=IMAP_HOST,
port=IMAP_PORT,
security=TransportSecurity.PLAIN,
username=USERNAME,
password=PASSWORD,
sent_folder=SENT_FOLDER,
timeout_seconds=10,
)
def _message(subject: str, body: str, attachments: list[Path] | None = None) -> EmailMessage:
msg = EmailMessage()
msg["From"] = SENDER
msg["To"] = RECIPIENT
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=False)
msg["Message-ID"] = make_msgid(domain="govoplan.test")
msg.set_content(body)
for attachment in attachments or []:
content_type = mimetypes.guess_type(attachment.name)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1)
msg.add_attachment(attachment.read_bytes(), maintype=maintype, subtype=subtype, filename=attachment.name)
return msg
def _ensure_folder(config: ImapConfig, folder: str) -> None:
client = imaplib.IMAP4(host=config.host or "", port=config.port or 143, timeout=config.timeout_seconds)
try:
if config.username and config.password:
client.login(config.username, config.password)
typ, data = client.create(folder)
if typ not in {"OK", "NO"}:
raise RuntimeError(f"Could not create IMAP folder {folder!r}: {data!r}")
finally:
try:
client.logout()
except Exception:
pass
def _wait_for_delivery(config: ImapConfig, *, before_count: int, expected_increment: int) -> None:
deadline = time.monotonic() + 15
last_count = before_count
while time.monotonic() < deadline:
result = list_imap_messages(imap_config=config, folder="INBOX", limit=10)
last_count = result.total_count
if last_count >= before_count + expected_increment:
return
time.sleep(0.5)
raise RuntimeError(
f"Expected at least {expected_increment} new INBOX message(s), "
f"but count moved from {before_count} to {last_count}."
)
def _wait_for_service(label: str, check):
deadline = time.monotonic() + SERVICE_READY_TIMEOUT_SECONDS
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
return check()
except Exception as exc: # GreenMail can bind before SMTP/IMAP is ready.
last_error = exc
time.sleep(1)
raise RuntimeError(f"{label} did not become ready within {SERVICE_READY_TIMEOUT_SECONDS}s: {last_error}") from last_error
def main() -> int:
smtp = _smtp_config()
imap = _imap_config()
smtp_login = _wait_for_service("SMTP", lambda: test_smtp_login(smtp_config=smtp))
imap_login = _wait_for_service("IMAP", lambda: test_imap_login(imap_config=imap))
print(f"SMTP login OK: {smtp_login.host}:{smtp_login.port} authenticated={smtp_login.authenticated}")
print(f"IMAP login OK: {imap_login.host}:{imap_login.port} authenticated={imap_login.authenticated}")
_wait_for_service("IMAP folder creation", lambda: _ensure_folder(imap, SENT_FOLDER))
folders = _wait_for_service("IMAP folder listing", lambda: list_imap_folders(imap_config=imap))
print("IMAP folders: " + ", ".join(folder.name for folder in folders.folders))
before_inbox = list_imap_messages(imap_config=imap, folder="INBOX", limit=10).total_count
with tempfile.TemporaryDirectory(prefix="govoplan-campaign-mail-test-") as tmp:
root = Path(tmp)
attachment = root / "single-attachment.txt"
attachment.write_text("GovOPlaN single attachment smoke test\n", encoding="utf-8")
zip_source = root / "zip-source.txt"
zip_source.write_text("GovOPlaN password-protected ZIP smoke test\n", encoding="utf-8")
zip_path = create_zip_archive(root / "password-protected.zip", [zip_source], ZIP_PASSWORD)
messages = [
_message("[GovOPlaN test] no attachment", "No attachment SMTP/IMAP smoke."),
_message("[GovOPlaN test] one attachment", "One attachment SMTP/IMAP smoke.", [attachment]),
_message("[GovOPlaN test] password ZIP", "Password-protected ZIP SMTP/IMAP smoke.", [zip_path]),
]
for message in messages:
result = send_email_message(
message,
smtp_config=smtp,
envelope_from=SENDER,
envelope_recipients=[RECIPIENT],
)
print(f"SMTP accepted {result.accepted_count} recipient(s): {message['Subject']}")
append = append_message_to_sent(bytes(message), imap_config=imap, folder=SENT_FOLDER)
print(f"IMAP appended {append.bytes_appended} byte(s) to {append.folder}: {message['Subject']}")
_wait_for_delivery(imap, before_count=before_inbox, expected_increment=3)
sent_count = list_imap_messages(imap_config=imap, folder=SENT_FOLDER, limit=10).total_count
if sent_count < 3:
raise RuntimeError(f"Expected at least 3 messages in {SENT_FOLDER!r}, found {sent_count}.")
print("Transport smoke OK: no attachment, one attachment, and password-protected ZIP messages verified.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -15,6 +15,8 @@ been validated, built, reviewed, and locked.
## Before First Live Use ## Before First Live Use
- Use dedicated non-production SMTP/IMAP credentials. - Use dedicated non-production SMTP/IMAP credentials.
- Start the repository test bed in `dev/mail-testbed/` when a local
production-like SMTP/IMAP server is sufficient.
- Use a dedicated mailbox/folder for append-to-Sent tests. - Use a dedicated mailbox/folder for append-to-Sent tests.
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional - Confirm policy allows the SMTP host, envelope sender, recipients, and optional
IMAP append target. IMAP append target.
@@ -23,6 +25,23 @@ been validated, built, reviewed, and locked.
- Keep the report page open during tests; it is the operational source of truth - Keep the report page open during tests; it is the operational source of truth
for attempts, outcomes, and reconciliation. for attempts, outcomes, and reconciliation.
## Deliverability Preflight
Before the first live send for a sender domain or mail-server profile:
- Confirm the selected SMTP identity matches the visible From/envelope sender
policy.
- Confirm SPF, DKIM, and DMARC are handled by the sending infrastructure or
documented as out of scope for the selected test environment.
- Confirm rate limits are explicitly set for the expected provider and recipient
volume.
- Confirm bounce/reply/notification addresses are monitored by an operational
mailbox or intentionally disabled.
- Confirm large attachments and password-protected ZIPs are acceptable for the
recipient systems.
- Confirm owner transfer or policy changes force profile reselection and
revalidation before live delivery.
## Queue And Send ## Queue And Send
1. Validate the version with file checks enabled. 1. Validate the version with file checks enabled.
@@ -73,5 +92,7 @@ bed where possible:
- Accepted and unknown jobs must not appear in retry selections. - Accepted and unknown jobs must not appear in retry selections.
- Reconciled accepted jobs must remain protected from resend. - Reconciled accepted jobs must remain protected from resend.
- Reconciled not-sent jobs must appear only as explicit retry candidates. - Reconciled not-sent jobs must appear only as explicit retry candidates.
- The final report should include SMTP attempts, IMAP append attempts, and any - The final CSV export should include message id, resolved envelope headers,
reconciliation notes before the campaign is considered operationally closed. attachment evidence, EML reference/checksum, latest SMTP response/error, and
latest IMAP folder/error before the campaign is considered operationally
closed.

View File

@@ -0,0 +1,76 @@
# Example Campaigns And Release Checklist
This document defines the campaign examples and release gates that should be
kept working before a GovOPlaN Campaign release is tagged.
## Example Campaign Set
Maintain fixtures or guided examples for these scenarios. The canonical
scenario catalogue lives in `examples/README.md`; committed fixture files should
be added under `examples/` only when they validate against the current campaign
schema and are safe to run in non-production environments.
- simple announcement with one active recipient and no attachments
- multi-recipient message with To, CC, BCC, Reply-To, bounce, and disposition
notification fields
- campaign with global attachments and recipient-specific attachment rules
- campaign with password-protected ZIP attachments
- campaign using a reusable mail profile from the mail module
- campaign using inline SMTP/IMAP settings where policy allows campaign-local
settings
- campaign with validation warnings that may be sent only after explicit review
- campaign with blocked recipients or attachment errors that must not be sent
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
development mailbox
- real non-production delivery campaign against the GreenMail test bed
## Fixture Rules
- Examples must not contain production recipient data or production credentials.
- Attachment examples should use deterministic small files and checksums.
- Secret values must be represented through saved-credential placeholders or
secret references.
- Examples that require optional modules must declare the required modules and
capabilities in their README or fixture metadata.
- Examples must stay valid when files or mail modules are physically absent,
with optional behavior disabled instead of import failures.
## Release Gates
Before tagging a campaign release:
- Review `examples/README.md` and update the scenario catalogue when a release
adds or removes delivery behavior.
- Run core module permutation tests with campaign installed both with and
without files/mail.
- Validate and build each maintained example campaign.
- Run the mock delivery example when the mail development mailbox capability is
enabled.
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
- Confirm reusable mail profile selection is revalidated after campaign owner
transfer.
- Confirm inline SMTP/IMAP settings are hidden or blocked when policy disables
campaign-local mail settings.
- Confirm delivery reports include SMTP outcome, IMAP append outcome, latest
error, generated EML reference, and attachment evidence.
- Confirm retries cannot resend messages already accepted by SMTP unless an
explicit reconciliation path allows it.
## Ownership Transfer Check
Reusable user/group mail profiles are owner-context-sensitive. When campaign
ownership changes, the editable current version must require profile reselection
and validation before live delivery. A locked delivery-final version should not
be silently rewritten.
## Delivery Checklist
Use the Review & Send preflight panel and the delivery runbook together:
1. Validate and resolve all blocking policy/data issues.
2. Build exact messages.
3. Review warnings, generated recipients, body content, and attachment evidence.
4. Run mock delivery if available for the release channel.
5. Test SMTP and IMAP settings against non-production infrastructure.
6. Send only after queue, rate limit, and append-to-Sent behavior are understood.
7. Reconcile failed, unknown, or pending jobs from the report/audit surfaces.

View File

@@ -0,0 +1,64 @@
# Recipient And Address Management Boundary
Campaigns currently own campaign-local recipient entries because sending and
reporting need a frozen recipient snapshot. Long-lived address management is a
separate domain and should move to `govoplan-addresses`.
## `govoplan-campaign` Owns
- campaign-local recipient entries
- campaign-local recipient import mapping and validation
- message addressing for a concrete campaign version
- send/build/report evidence for the exact recipients used
- campaign-local exclusions, warnings, and review status
- recipient-specific attachment and template evidence
Campaign data is immutable once a version is built for sending. Later address
book changes must not rewrite historical campaign evidence.
## `govoplan-addresses` Should Own
- Adrema-style address management
- reusable person, organization, household, and postal-address records
- reusable email address lists and segments
- postal-letter recipient views
- consent, legal-basis, and communication-preference metadata
- deduplication and merge workflows
- import/export of reusable address directories
- address quality checks and change history
The addresses module should provide stable DTOs and capabilities that campaigns,
mail, forms, reporting, portal, and postbox modules can consume without direct
imports.
## Integration Contract
The campaign module should ask the platform whether the addresses module is
installed. When present, campaign can offer address-source choices through a
capability such as `addresses.recipientSource`.
The capability should return snapshots, not live ORM objects:
- selected source id and display label
- normalized recipient rows
- provenance fields for source, segment, legal basis, and import time
- update markers so campaigns can show whether a draft is based on stale source
data
Campaign stores the resolved snapshot in the campaign version. It may keep a
reference to the address source for traceability, but the built campaign remains
auditable even if the address source changes later.
## Non-Goals For Campaign
Campaign should not become the global address book. It should not own:
- deduplication across campaigns
- consent lifecycle
- master-data merge policy
- address-directory permissions beyond campaign use
- postal address normalization
- reusable segmentation rules
Those belong in `govoplan-addresses` or a dedicated records/identity module when
the domain needs stronger governance.

View File

@@ -0,0 +1,87 @@
# Recipient Import Guide
Recipient import lets campaign authors turn spreadsheet-like source data into
campaign-local recipient entries. It is intentionally campaign-local today:
reusable address books and Adrema-style address management belong in the future
`govoplan-addresses` module.
## Supported Inputs
The current importer is designed for tabular data with a header row. CSV and
spreadsheet-derived tables should be normalized before import so the campaign UI
sees:
- column headers
- row values
- source filename and sheet name when available
- a stable ordered and unordered header fingerprint
Avoid importing production secrets or credentials as recipient fields. Recipient
custom fields may be used in templates and reports, so they should be treated as
campaign data.
## Mapping Fields
Common headers are detected automatically:
- `email`, `e_mail`, `mail`, `to`, `to_email`, `recipient`,
`recipient_email`
- `name`, `full_name`, `recipient_name`, `to_name`
- `id`, `entry_id`, `recipient_id`
Authors can map columns to address fields:
- `from`
- `to`
- `cc`
- `bcc`
- `reply_to`
Rows without a valid `to` address should remain visible with validation issues
instead of disappearing silently. Authors should be able to fix the source file,
adjust the mapping, or exclude the row before building the campaign.
## Mapping Profiles
Mapping profiles save a known column layout so repeated imports can reuse the
same mapping. The importer stores ordered and unordered header fingerprints so a
profile can distinguish exact column order from equivalent column sets.
Administrators should curate shared profiles only for stable recurring sources.
Campaign-local profiles are acceptable for one-off work and experiments.
## User Workflow
1. Open the campaign recipient/data import screen.
2. Upload or paste a tabular source.
3. Review detected headers and preview rows.
4. Pick or adjust a mapping profile.
5. Confirm validation issues, exclusions, and generated recipient ids.
6. Import into the campaign draft.
7. Build messages and review recipient-specific evidence before sending.
## Admin Workflow
Administrators should:
- define naming conventions for recurring mapping profiles
- verify that imported fields have a lawful processing basis for the campaign
- keep reusable address-directory ownership out of campaigns until
`govoplan-addresses` owns that domain
- use campaign reports and audit evidence to trace which source produced which
recipient entries
- remove obsolete shared profiles when an upstream source layout changes
## Evidence Expectations
The campaign should preserve enough evidence to explain a send:
- source filename and sheet name where available
- header fingerprints
- mapping profile id/name when used
- row number or source id
- validation status and exclusion reason
- final recipient addresses used for the built message
The evidence should be available in reports without requiring the original
source file to be reprocessed.

45
examples/README.md Normal file
View File

@@ -0,0 +1,45 @@
# GovOPlaN Campaign Examples
These examples are the maintained scenario catalogue for campaign release
checks. They are intentionally small and credential-free. Add concrete fixture
files next to this README only when they can be validated by the current
campaign schema and do not require production data.
## Scenarios
| Scenario | Required Modules | Release Check |
| --- | --- | --- |
| `simple-announcement` | core, access, campaigns | Validate and build one active recipient without attachments. |
| `addressing-matrix` | core, access, campaigns | Exercise To, CC, BCC, Reply-To, bounce, and disposition-notification fields. |
| `global-attachment` | core, access, campaigns; optional files | Build one deterministic attachment and verify evidence. |
| `recipient-attachment-rules` | core, access, campaigns; optional files | Match recipient-specific attachment rules and verify per-recipient evidence. |
| `zip-protected` | core, access, campaigns | Build password-protected AES ZIP output and verify password-source metadata. |
| `mail-profile-send` | core, access, campaigns, mail | Select a reusable mail profile and send through the GreenMail test bed. |
| `inline-mail-settings` | core, access, campaigns, mail | Use campaign-local SMTP/IMAP settings only when policy allows it. |
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
## Fixture Rules
- Do not commit real recipients, mail credentials, or production attachment
names.
- Keep attachments deterministic and small.
- Store secrets as placeholders, saved-credential references, or local `.env`
values consumed by the test bed.
- Declare optional module requirements in fixture metadata.
- Fixtures must not import files or mail modules directly; optional behavior is
discovered through core module metadata and capabilities.
## Validation Flow
Before a release tag:
1. Run module permutation startup checks from core.
2. Validate every committed example fixture against the current campaign schema.
3. Build exact messages for each fixture.
4. Run the mock-delivery example when the dev mailbox capability is enabled.
5. Run `dev/mail-testbed/run_transport_smoke.py`.
6. Execute the delivery checklist in
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.

View File

@@ -23,7 +23,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"

View File

@@ -15,7 +15,7 @@ dependencies = [
"jsonschema>=4,<5", "jsonschema>=4,<5",
"pydantic>=2,<3", "pydantic>=2,<3",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
"pyzipper>=0.3,<1", "pyzipper>=0.4,<1",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -0,0 +1,350 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session as OrmSession
from govoplan_core.core.change_sequence import record_change
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
SendAttempt,
new_uuid,
)
CAMPAIGNS_MODULE_ID = "campaigns"
CAMPAIGNS_COLLECTION = "campaigns.campaigns"
CAMPAIGN_VERSIONS_COLLECTION = "campaigns.versions"
CAMPAIGN_JOBS_COLLECTION = "campaigns.jobs"
CAMPAIGN_ISSUES_COLLECTION = "campaigns.issues"
CAMPAIGN_ATTEMPTS_COLLECTION = "campaigns.attempts"
_REGISTERED = False
def register_campaign_change_tracking() -> None:
global _REGISTERED
if _REGISTERED:
return
event.listen(OrmSession, "before_flush", _record_campaign_changes)
_REGISTERED = True
def _record_campaign_changes(session: OrmSession, _flush_context: object, _instances: object) -> None:
for obj in tuple(session.new) + tuple(session.dirty) + tuple(session.deleted):
if isinstance(obj, Campaign):
_record_campaign_change(session, obj)
elif isinstance(obj, CampaignShare):
_record_share_visibility_change(session, obj)
elif isinstance(obj, CampaignVersion):
_record_version_change(session, obj)
elif isinstance(obj, CampaignJob):
_record_job_change(session, obj)
elif isinstance(obj, CampaignIssue):
_record_issue_change(session, obj)
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
_record_attempt_change(session, obj)
def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
operation = _operation_for_campaign(
campaign,
changed_attrs=(
"external_id",
"name",
"description",
"status",
"current_version_id",
"owner_user_id",
"owner_group_id",
"settings",
"mail_profile_policy",
),
)
if operation is None:
return
campaign_id = _ensure_id(campaign)
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=campaign_id,
operation=operation,
tenant_id=campaign.tenant_id,
actor_type="user" if campaign.created_by_user_id else None,
actor_id=campaign.created_by_user_id,
payload=_campaign_payload(campaign),
)
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
state = inspect(share)
if not share.campaign_id:
return
if not state.deleted and not state.pending and not _has_attr_changes(
state,
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
):
return
_ensure_id(share)
operation = "deleted" if state.deleted or share.revoked_at is not None else "updated"
campaign = session.get(Campaign, share.campaign_id)
payload = _campaign_payload(campaign) if campaign is not None else {"campaign_id": share.campaign_id}
payload.update({
"share_id": share.id,
"share_target_type": share.target_type,
"share_target_id": share.target_id,
"share_permission": share.permission,
"share_revoked_at": _isoformat(share.revoked_at),
})
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGNS_COLLECTION,
resource_type="campaign",
resource_id=share.campaign_id,
operation=operation,
tenant_id=share.tenant_id,
actor_type="user" if share.created_by_user_id else None,
actor_id=share.created_by_user_id,
payload=payload,
)
def _record_version_change(session: OrmSession, version: CampaignVersion) -> None:
operation = _operation_for_object(
version,
changed_attrs=(
"raw_json",
"schema_version",
"source_filename",
"source_base_path",
"workflow_state",
"current_flow",
"current_step",
"is_complete",
"editor_state",
"autosaved_at",
"published_at",
"locked_at",
"locked_by_user_id",
"user_lock_state",
"user_locked_at",
"user_locked_by_user_id",
"validation_summary",
"build_summary",
"execution_snapshot_hash",
"execution_snapshot_at",
),
)
if operation is None:
return
version_id = _ensure_id(version)
campaign = session.get(Campaign, version.campaign_id) if version.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_VERSIONS_COLLECTION,
resource_type="campaign_version",
resource_id=version_id,
operation=operation,
tenant_id=campaign.tenant_id if campaign is not None else None,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": version.campaign_id}),
"version_id": version_id,
"version_number": version.version_number,
"workflow_state": version.workflow_state,
"current_flow": version.current_flow,
"current_step": version.current_step,
},
)
def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
operation = _operation_for_object(
job,
changed_attrs=(
"build_status",
"validation_status",
"queue_status",
"send_status",
"imap_status",
"attempt_count",
"last_error",
"queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at",
"sent_at",
"resolved_recipients",
"resolved_attachments",
"issues_snapshot",
),
)
if operation is None:
return
job_id = _ensure_id(job)
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_JOBS_COLLECTION,
resource_type="campaign_job",
resource_id=job_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"job_id": job_id,
"version_id": job.campaign_version_id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
},
)
def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
operation = _operation_for_object(issue, changed_attrs=("severity", "code", "message", "source", "behavior"))
if operation is None:
return
issue_id = _ensure_id(issue)
campaign = session.get(Campaign, issue.campaign_id) if issue.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ISSUES_COLLECTION,
resource_type="campaign_issue",
resource_id=issue_id,
operation=operation,
tenant_id=issue.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": issue.campaign_id}),
"issue_id": issue_id,
"version_id": issue.campaign_version_id,
"job_id": issue.job_id,
"severity": issue.severity,
"code": issue.code,
},
)
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
operation = _operation_for_object(
attempt,
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
)
if operation is None:
return
attempt_id = _ensure_id(attempt)
job = session.get(CampaignJob, attempt.job_id) if attempt.job_id else None
if job is None:
return
campaign = session.get(Campaign, job.campaign_id) if job.campaign_id else None
record_change(
session,
module_id=CAMPAIGNS_MODULE_ID,
collection=CAMPAIGN_ATTEMPTS_COLLECTION,
resource_type="campaign_attempt",
resource_id=attempt_id,
operation=operation,
tenant_id=job.tenant_id,
payload={
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
"attempt_id": attempt_id,
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
"job_id": job.id,
"version_id": job.campaign_version_id,
},
)
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
state = inspect(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not _has_attr_changes(state, changed_attrs):
return None
status_history = state.attrs.status.history
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
return "deleted"
return "updated"
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
state = inspect(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not _has_attr_changes(state, changed_attrs):
return None
return "updated"
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def _previous_value(obj: object, attr_name: str) -> str | None:
state = inspect(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
def _ensure_id(obj: object) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_uuid()
setattr(obj, "id", resource_id)
return resource_id
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
if campaign is None:
return {}
return {
"campaign_id": campaign.id,
"owner_user_id": campaign.owner_user_id,
"owner_group_id": campaign.owner_group_id,
"previous_owner_user_id": _previous_value(campaign, "owner_user_id"),
"previous_owner_group_id": _previous_value(campaign, "owner_group_id"),
"status": campaign.status,
"previous_status": _previous_value(campaign, "status"),
"current_version_id": campaign.current_version_id,
"previous_current_version_id": _previous_value(campaign, "current_version_id"),
}
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value else None
__all__ = [
"CAMPAIGNS_COLLECTION",
"CAMPAIGNS_MODULE_ID",
"CAMPAIGN_ATTEMPTS_COLLECTION",
"CAMPAIGN_ISSUES_COLLECTION",
"CAMPAIGN_JOBS_COLLECTION",
"CAMPAIGN_VERSIONS_COLLECTION",
"register_campaign_change_tracking",
]

View File

@@ -108,10 +108,10 @@ class Campaign(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -130,12 +130,12 @@ class CampaignShare(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),) __table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
@@ -148,8 +148,8 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
) )
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
column_count: Mapped[int] = mapped_column(Integer, nullable=False) column_count: Mapped[int] = mapped_column(Integer, nullable=False)
headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
@@ -199,7 +199,7 @@ class CampaignVersion(Base, TimestampMixin):
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
# Explicit user-requested lock. This is deliberately separate from # Explicit user-requested lock. This is deliberately separate from
# locked_at, which represents the reversible validation lock used by the # locked_at, which represents the reversible validation lock used by the
@@ -207,7 +207,7 @@ class CampaignVersion(Base, TimestampMixin):
# RBAC permission for unlocking; permanent locks never unlock in place. # RBAC permission for unlocking; permanent locks never unlock in place.
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
@@ -223,7 +223,7 @@ class CampaignJob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),) __table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
entry_index: Mapped[int] = mapped_column(Integer, nullable=False) entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
@@ -263,7 +263,7 @@ class CampaignIssue(Base, TimestampMixin):
__tablename__ = "campaign_issues" __tablename__ = "campaign_issues"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True) campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True) job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
@@ -279,7 +279,7 @@ class AttachmentBlob(Base, TimestampMixin):
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),) __table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(255)) mime_type: Mapped[str | None] = mapped_column(String(255))
@@ -291,8 +291,8 @@ class AttachmentInstance(Base, TimestampMixin):
__tablename__ = "attachment_instances" __tablename__ = "attachment_instances"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True) campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True) blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
logical_name: Mapped[str | None] = mapped_column(String(500)) logical_name: Mapped[str | None] = mapped_column(String(500))

View File

@@ -12,8 +12,11 @@ from govoplan_core.core.campaigns import (
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 FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
register_campaign_change_tracking()
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)

View File

@@ -1,7 +1,7 @@
"""recipient import mapping profiles """recipient import mapping profiles
Revision ID: 2c3d4e5f7081 Revision ID: 2c3d4e5f7081
Revises: 1b2c3d4e5f70 Revises: 2e3f4a5b6c7d
Create Date: 2026-06-26 00:00:00.000000 Create Date: 2026-06-26 00:00:00.000000
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,7 +11,7 @@ import sqlalchemy as sa
revision = "2c3d4e5f7081" revision = "2c3d4e5f7081"
down_revision = "1b2c3d4e5f70" down_revision = "2e3f4a5b6c7d"
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -38,8 +38,8 @@ def upgrade() -> None:
sa.Column("mappings", sa.JSON(), nullable=False), sa.Column("mappings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["owner_user_id"], ["users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"), sa.ForeignKeyConstraint(["owner_user_id"], ["access_users.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_owner_user_id_users"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"), sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")), sa.PrimaryKeyConstraint("id", name=op.f("pk_campaign_recipient_import_mapping_profiles")),
) )
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False) op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)

View File

@@ -256,6 +256,99 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
} }
def _address_summary(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, dict):
email = str(value.get("email") or "").strip()
name = str(value.get("name") or "").strip()
if name and email:
return f"{name} <{email}>"
return email or name
if isinstance(value, list):
return "; ".join(item for item in (_address_summary(item) for item in value) if item)
return str(value)
def _latest_by_job_id(attempts: list[Any]) -> dict[str, Any]:
latest: dict[str, Any] = {}
for attempt in attempts:
current = latest.get(attempt.job_id)
if current is None or (attempt.attempt_number or 0) >= (current.attempt_number or 0):
latest[attempt.job_id] = attempt
return latest
def _attachment_names(attachments: list[dict[str, Any]] | None) -> str:
names: list[str] = []
for attachment in attachments or []:
if not isinstance(attachment, dict):
continue
for key in ("message_filenames", "zip_entry_names"):
values = attachment.get(key)
if isinstance(values, list):
names.extend(str(value) for value in values if value)
zip_filename = attachment.get("zip_filename")
if zip_filename:
names.append(str(zip_filename))
matches = attachment.get("matches")
if isinstance(matches, list):
for match in matches:
if isinstance(match, dict):
filename = match.get("filename") or match.get("name") or match.get("display_name")
if filename:
names.append(str(filename))
elif match:
names.append(str(match).rsplit("/", 1)[-1])
seen: set[str] = set()
deduplicated = []
for name in names:
if name and name not in seen:
seen.add(name)
deduplicated.append(name)
return "; ".join(deduplicated)
def _job_evidence_row(
job: CampaignJob,
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
) -> dict[str, Any]:
row = _job_row(job)
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")),
"bcc": _address_summary(recipients.get("bcc")),
"reply_to": _address_summary(recipients.get("reply_to")),
"attachment_names": _attachment_names(job.resolved_attachments),
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
"latest_imap_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None,
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
})
return row
def generate_campaign_report( def generate_campaign_report(
session: Session, session: Session,
*, *,
@@ -401,13 +494,47 @@ def generate_jobs_csv(
.order_by(CampaignJob.entry_index.asc()) .order_by(CampaignJob.entry_index.asc())
.all() .all()
) )
rows = [_job_row(job) for job in jobs] job_ids = [job.id for job in jobs]
smtp_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id.in_(job_ids))
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id.in_(job_ids))
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
.all()
if job_ids
else []
)
latest_smtp = _latest_by_job_id(smtp_attempts)
latest_imap = _latest_by_job_id(imap_attempts)
rows = [
_job_evidence_row(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
)
for job in jobs
]
fieldnames = [ fieldnames = [
"job_id", "job_id",
"campaign_id",
"campaign_version_id",
"entry_index", "entry_index",
"entry_id", "entry_id",
"recipient_email", "recipient_email",
"from",
"to",
"cc",
"bcc",
"reply_to",
"subject", "subject",
"message_id_header",
"build_status", "build_status",
"validation_status", "validation_status",
"queue_status", "queue_status",
@@ -422,9 +549,26 @@ def generate_jobs_csv(
"last_error", "last_error",
"eml_size_bytes", "eml_size_bytes",
"eml_sha256", "eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count", "issues_count",
"attachment_config_count", "attachment_config_count",
"matched_file_count", "matched_file_count",
"attachment_names",
"latest_smtp_attempt_number",
"latest_smtp_status",
"latest_smtp_status_code",
"latest_smtp_response",
"latest_smtp_error_type",
"latest_smtp_error_message",
"latest_smtp_started_at",
"latest_smtp_finished_at",
"latest_imap_attempt_number",
"latest_imap_status",
"latest_imap_folder",
"latest_imap_error_message",
"latest_imap_created_at",
"latest_imap_updated_at",
] ]
buffer = io.StringIO() buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=fieldnames) writer = csv.DictWriter(buffer, fieldnames=fieldnames)

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_any_scope from govoplan_access.auth import ApiPrincipal, require_any_scope
from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui from govoplan_campaign.backend.campaign.loader import load_campaign_schema, load_campaign_schema_ui

View File

@@ -5,6 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig from govoplan_core.mail.config import ImapConfig, SmtpConfig
@@ -145,6 +146,21 @@ class CampaignWorkspaceResponse(BaseModel):
selected_version_id: str | None = None selected_version_id: str | None = None
class CampaignDeltaResponse(BaseModel):
campaigns: list[CampaignResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignShareItem(BaseModel): class CampaignShareItem(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -257,11 +273,20 @@ class CampaignJobsResponse(BaseModel):
total: int = 0 total: int = 0
total_unfiltered: int = 0 total_unfiltered: int = 0
pages: int = 0 pages: int = 0
cursor: str | None = None
next_cursor: str | None = None
counts: dict[str, dict[str, int]] = Field(default_factory=dict) counts: dict[str, dict[str, int]] = Field(default_factory=dict)
filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict) filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict)
review: dict[str, Any] = Field(default_factory=dict) review: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsDeltaResponse(CampaignJobsResponse):
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CampaignJobDetailResponse(BaseModel): class CampaignJobDetailResponse(BaseModel):
job: dict[str, Any] job: dict[str, Any]
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
def _dt() -> datetime:
return datetime(2026, 7, 8, 12, 30, tzinfo=UTC)
def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
job = SimpleNamespace(
id="job-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=0,
entry_id="recipient-1",
recipient_email="recipient@govoplan.test",
subject="Evidence test",
message_id_header="<message-1@govoplan.test>",
eml_storage_key="eml/job-1.eml",
eml_local_path="/tmp/job-1.eml",
eml_size_bytes=1234,
eml_sha256="abc123",
build_status="built",
validation_status="ready",
queue_status="queued",
send_status="smtp_accepted",
imap_status="appended",
attempt_count=2,
queued_at=_dt(),
claimed_at=None,
smtp_started_at=_dt(),
outcome_unknown_at=None,
sent_at=_dt(),
last_error=None,
resolved_recipients={
"from": [{"name": "Sender", "email": "sender@govoplan.test"}],
"to": [{"name": "Recipient", "email": "recipient@govoplan.test"}],
"cc": [],
"bcc": [],
"reply_to": [{"email": "reply@govoplan.test"}],
},
resolved_attachments=[
{
"matches": [{"filename": "notice.pdf"}, "/tmp/evidence.xlsx"],
"zip_filename": "bundle.zip",
"message_filenames": ["notice.pdf"],
}
],
issues_snapshot=[],
created_at=_dt(),
updated_at=_dt(),
)
smtp = SimpleNamespace(
job_id="job-1",
attempt_number=2,
status="success",
smtp_status_code=250,
smtp_response="2.0.0 queued",
error_type=None,
error_message=None,
started_at=_dt(),
finished_at=_dt(),
)
imap = SimpleNamespace(
job_id="job-1",
attempt_number=1,
status="appended",
folder="Sent",
error_message=None,
created_at=_dt(),
updated_at=_dt(),
)
row = _job_evidence_row(job, latest_smtp=smtp, latest_imap=imap)
assert row["campaign_id"] == "campaign-1"
assert row["campaign_version_id"] == "version-1"
assert row["message_id_header"] == "<message-1@govoplan.test>"
assert row["from"] == "Sender <sender@govoplan.test>"
assert row["to"] == "Recipient <recipient@govoplan.test>"
assert row["reply_to"] == "reply@govoplan.test"
assert "bundle.zip" in row["attachment_names"]
assert "notice.pdf" in row["attachment_names"]
assert row["latest_smtp_status_code"] == 250
assert row["latest_smtp_response"] == "2.0.0 queued"
assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent"
def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
attempts = [
SimpleNamespace(job_id="job-1", attempt_number=1),
SimpleNamespace(job_id="job-1", attempt_number=3),
SimpleNamespace(job_id="job-2", attempt_number=2),
]
latest = _latest_by_job_id(attempts)
assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2

View File

@@ -18,7 +18,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.6", "@govoplan/core-webui": "^0.1.6",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"

View File

@@ -1,13 +1,13 @@
import type { ApiSettings, CampaignListItem } from "../types"; import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
import { apiDownload, apiFetch } from "./client"; import { apiDownload, apiFetch } from "./client";
export type CampaignListResponse = export type CampaignListResponse =
| CampaignListItem[] CampaignListItem[] |
| { {
campaigns?: CampaignListItem[]; campaigns?: CampaignListItem[];
items?: CampaignListItem[]; items?: CampaignListItem[];
results?: CampaignListItem[]; results?: CampaignListItem[];
}; };
@@ -20,8 +20,8 @@ export type CampaignShare = {
revoked_at?: string | null; revoked_at?: string | null;
}; };
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null }; export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] }; export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
export type CampaignUpdatePayload = { export type CampaignUpdatePayload = {
external_id?: string | null; external_id?: string | null;
@@ -85,11 +85,28 @@ export type CampaignWorkspaceResponse = {
selected_version_id?: string | null; selected_version_id?: string | null;
}; };
export type CampaignDeltaResponse = {
campaigns: CampaignListItem[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignWorkspaceQuery = { export type CampaignWorkspaceQuery = {
versionId?: string | null; versionId?: string | null;
includeCurrentVersion?: boolean; includeCurrentVersion?: boolean;
includeSummary?: boolean; includeSummary?: boolean;
includeVersions?: boolean; includeVersions?: boolean;
since?: string | null;
limit?: number;
}; };
export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern"; export type RecipientImportColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
@@ -279,6 +296,7 @@ export type CampaignJobsQuery = {
versionId?: string; versionId?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
cursor?: string | null;
sendStatus?: string[]; sendStatus?: string[];
validationStatus?: string[]; validationStatus?: string[];
imapStatus?: string[]; imapStatus?: string[];
@@ -292,6 +310,8 @@ export type CampaignJobsResponse = {
total: number; total: number;
total_unfiltered: number; total_unfiltered: number;
pages: number; pages: number;
cursor?: string | null;
next_cursor?: string | null;
counts: Record<string, Record<string, number>>; counts: Record<string, Record<string, number>>;
filtered_counts: Record<string, Record<string, number>>; filtered_counts: Record<string, Record<string, number>>;
review: { review: {
@@ -303,6 +323,13 @@ export type CampaignJobsResponse = {
}; };
}; };
export type CampaignJobsDeltaResponse = CampaignJobsResponse & {
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CampaignJobDetailResponse = { export type CampaignJobDetailResponse = {
job: Record<string, unknown>; job: Record<string, unknown>;
attempts: { attempts: {
@@ -330,15 +357,26 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
return response.campaigns ?? response.items ?? response.results ?? []; return response.campaigns ?? response.items ?? response.results ?? [];
} }
export async function listCampaignsDelta(
settings: ApiSettings,
options: {since?: string | null;limit?: number;} = {})
: Promise<CampaignDeltaResponse> {
const params = new URLSearchParams();
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignDeltaResponse>(settings, `/api/v1/campaigns/delta${suffix}`);
}
export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> { export async function listRecipientImportMappingProfiles(settings: ApiSettings): Promise<RecipientImportMappingProfile[]> {
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles"); const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
return response.profiles ?? []; return response.profiles ?? [];
} }
export async function createRecipientImportMappingProfile( export async function createRecipientImportMappingProfile(
settings: ApiSettings, settings: ApiSettings,
payload: RecipientImportMappingProfilePayload payload: RecipientImportMappingProfilePayload)
): Promise<RecipientImportMappingProfile> { : Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", { return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -346,10 +384,10 @@ export async function createRecipientImportMappingProfile(
} }
export async function updateRecipientImportMappingProfile( export async function updateRecipientImportMappingProfile(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
payload: RecipientImportMappingProfilePayload payload: RecipientImportMappingProfilePayload)
): Promise<RecipientImportMappingProfile> { : Promise<RecipientImportMappingProfile> {
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -365,10 +403,10 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
} }
export async function updateCampaignMetadata( export async function updateCampaignMetadata(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignUpdatePayload payload: CampaignUpdatePayload)
): Promise<CampaignListItem> { : Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, { return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -376,14 +414,14 @@ export async function updateCampaignMetadata(
} }
export async function createNewCampaign( export async function createNewCampaign(
settings: ApiSettings, settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {} overrides: CampaignCreateMinimalPayload = {})
): Promise<CampaignCreateResponse> { : Promise<CampaignCreateResponse> {
const now = new Date(); const now = new Date();
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, ""); const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const payload = { const payload = {
external_id: overrides.external_id ?? `new-campaign-${stamp}`, external_id: overrides.external_id ?? `new-campaign-${stamp}`,
name: overrides.name ?? "New Campaign", name: overrides.name ?? "i18n:govoplan-campaign.new_campaign.1f0d021c",
description: overrides.description ?? "", description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create", current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics" current_step: overrides.current_step ?? "basics"
@@ -404,10 +442,10 @@ export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknow
} }
export async function getCampaignWorkspace( export async function getCampaignWorkspace(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
options: CampaignWorkspaceQuery = {} options: CampaignWorkspaceQuery = {})
): Promise<CampaignWorkspaceResponse> { : Promise<CampaignWorkspaceResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId); if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion)); if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
@@ -417,67 +455,83 @@ export async function getCampaignWorkspace(
return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`); return apiFetch<CampaignWorkspaceResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`);
} }
export async function getCampaignWorkspaceDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {})
: Promise<CampaignWorkspaceDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion));
if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary));
if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions));
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
}
export async function listCampaignVersions( export async function listCampaignVersions(
settings: ApiSettings, settings: ApiSettings,
campaignId: string campaignId: string)
): Promise<CampaignVersionListItem[]> { : Promise<CampaignVersionListItem[]> {
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`); return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
} }
export async function getCampaignVersion( export async function getCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`); return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
} }
export async function unlockCampaignVersionValidation( export async function unlockCampaignVersionValidation(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
method: "POST" method: "POST"
}); });
} }
export async function lockCampaignVersionTemporarily( export async function lockCampaignVersionTemporarily(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
method: "POST" method: "POST"
}); });
} }
export async function unlockCampaignVersionUserLock( export async function unlockCampaignVersionUserLock(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
method: "POST" method: "POST"
}); });
} }
export async function lockCampaignVersionPermanently( export async function lockCampaignVersionPermanently(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
method: "POST" method: "POST"
}); });
} }
export async function updateCampaignVersion( export async function updateCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload payload: CampaignVersionUpdatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -485,11 +539,11 @@ export async function updateCampaignVersion(
} }
export async function forkCampaignVersion( export async function forkCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload = {} payload: CampaignVersionUpdatePayload = {})
): Promise<CampaignCreateResponse> { : Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, { return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -497,11 +551,11 @@ export async function forkCampaignVersion(
} }
export async function autosaveCampaignVersion( export async function autosaveCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignVersionUpdatePayload payload: CampaignVersionUpdatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -509,12 +563,12 @@ export async function autosaveCampaignVersion(
} }
export async function setCampaignVersionStep( export async function setCampaignVersionStep(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
currentStep: string, currentStep: string,
currentFlow?: string | null currentFlow?: string | null)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
method: "POST", method: "POST",
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep }) body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
@@ -522,11 +576,11 @@ export async function setCampaignVersionStep(
} }
export async function validatePartial( export async function validatePartial(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignPartialValidationPayload = {} payload: CampaignPartialValidationPayload = {})
): Promise<CampaignPartialValidationResponse> { : Promise<CampaignPartialValidationResponse> {
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, { return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -534,20 +588,20 @@ export async function validatePartial(
} }
export async function publishCampaignVersion( export async function publishCampaignVersion(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string versionId: string)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
method: "POST" method: "POST"
}); });
} }
export async function validateVersion( export async function validateVersion(
settings: ApiSettings, settings: ApiSettings,
versionId: string, versionId: string,
checkFiles = false checkFiles = false)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST", method: "POST",
body: JSON.stringify({ check_files: checkFiles }) body: JSON.stringify({ check_files: checkFiles })
@@ -555,10 +609,10 @@ export async function validateVersion(
} }
export async function buildVersion( export async function buildVersion(
settings: ApiSettings, settings: ApiSettings,
versionId: string, versionId: string,
writeEml = true writeEml = true)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST", method: "POST",
body: JSON.stringify({ write_eml: writeEml }) body: JSON.stringify({ write_eml: writeEml })
@@ -567,11 +621,11 @@ export async function buildVersion(
export function previewCampaignAttachments( export function previewCampaignAttachments(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignAttachmentPreviewPayload = {} payload: CampaignAttachmentPreviewPayload = {})
): Promise<CampaignAttachmentPreviewResponse> { : Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>( return apiFetch<CampaignAttachmentPreviewResponse>(
settings, settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`, `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
@@ -580,23 +634,24 @@ export function previewCampaignAttachments(
} }
export async function getCampaignSummary( export async function getCampaignSummary(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<CampaignSummary> { : Promise<CampaignSummary> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
} }
export async function getCampaignJobs( export async function getCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
options: CampaignJobsQuery = {} options: CampaignJobsQuery = {})
): Promise<CampaignJobsResponse> { : Promise<CampaignJobsResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId); if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page)); if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize)); if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value); for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value); for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value); for (const value of options.imapStatus ?? []) params.append("imap_status", value);
@@ -605,29 +660,49 @@ export async function getCampaignJobs(
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`); return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
} }
export async function getCampaignJobsDelta(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
: Promise<CampaignJobsDeltaResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.cursor) params.set("cursor", options.cursor);
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
if (options.since) params.set("since", options.since);
if (options.limit) params.set("limit", String(options.limit));
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
}
export async function getCampaignJobDetail( export async function getCampaignJobDetail(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string jobId: string)
): Promise<CampaignJobDetailResponse> { : Promise<CampaignJobDetailResponse> {
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`); return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
} }
export async function getCampaignReport( export async function getCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<CampaignSummary> { : Promise<CampaignSummary> {
const params = new URLSearchParams({ include_jobs: "false" }); const params = new URLSearchParams({ include_jobs: "false" });
if (versionId) params.set("version_id", versionId); if (versionId) params.set("version_id", versionId);
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
} }
export async function downloadCampaignJobsCsv( export async function downloadCampaignJobsCsv(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId?: string versionId?: string)
): Promise<void> { : Promise<void> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
await apiDownload( await apiDownload(
settings, settings,
@@ -637,10 +712,10 @@ export async function downloadCampaignJobsCsv(
} }
export async function emailCampaignReport( export async function emailCampaignReport(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignReportEmailPayload payload: CampaignReportEmailPayload)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -648,10 +723,10 @@ export async function emailCampaignReport(
} }
export async function retryCampaignJobs( export async function retryCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: Record<string, unknown> = {} payload: Record<string, unknown> = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -659,10 +734,10 @@ export async function retryCampaignJobs(
} }
export async function sendUnattemptedCampaignJobs( export async function sendUnattemptedCampaignJobs(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: Record<string, unknown> = {} payload: Record<string, unknown> = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -670,12 +745,12 @@ export async function sendUnattemptedCampaignJobs(
} }
export async function resolveCampaignJobOutcome( export async function resolveCampaignJobOutcome(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
jobId: string, jobId: string,
decision: "smtp_accepted" | "not_sent", decision: "smtp_accepted" | "not_sent",
note?: string note?: string)
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST", method: "POST",
body: JSON.stringify({ decision, note: note || null }) body: JSON.stringify({ decision, note: note || null })
@@ -683,11 +758,11 @@ export async function resolveCampaignJobOutcome(
} }
export async function updateCampaignReviewState( export async function updateCampaignReviewState(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
versionId: string, versionId: string,
payload: CampaignReviewStatePayload payload: CampaignReviewStatePayload)
): Promise<CampaignVersionDetail> { : Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, { return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -695,10 +770,10 @@ export async function updateCampaignReviewState(
} }
export async function queueCampaign( export async function queueCampaign(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignQueuePayload = {} payload: CampaignQueuePayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -706,10 +781,10 @@ export async function queueCampaign(
} }
export async function sendCampaignNow( export async function sendCampaignNow(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignSendNowPayload = {} payload: CampaignSendNowPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -718,10 +793,10 @@ export async function sendCampaignNow(
export async function mockSendCampaign( export async function mockSendCampaign(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignMockSendPayload = {} payload: CampaignMockSendPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
@@ -741,10 +816,10 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
} }
export async function appendSent( export async function appendSent(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: CampaignAppendSentPayload = {} payload: CampaignAppendSentPayload = {})
): Promise<Record<string, unknown>> { : Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, { return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
@@ -760,23 +835,23 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
} }
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> { export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`); const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares; return response.shares;
} }
export async function updateCampaignOwner( export async function updateCampaignOwner(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: { owner_user_id?: string | null; owner_group_id?: string | null } payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
): Promise<CampaignListItem> { : Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) }); return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
} }
export async function upsertCampaignShare( export async function upsertCampaignShare(
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" } payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
): Promise<CampaignShare> { : Promise<CampaignShare> {
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) }); return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
} }

View File

@@ -5,28 +5,28 @@ import { StatusBadge } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
const personalContacts = [ const personalContacts = [
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" }, { name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" } { name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
];
const groupContacts = [ const groupContacts = [
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" }, { name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" } { name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
];
const tenantContacts = [ const tenantContacts = [
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" }, { name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" } { name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
];
function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] {
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
return [ return [
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name }, { id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
{ id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email }, { id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
{ id: "scope", header: "Scope", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "Personal" }, { value: "Group", label: "Group" }, { value: "Tenant", label: "Tenant" }] }, value: (contact) => contact.source }, { id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
{ id: "tags", header: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags }, { id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" } { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }];
];
} }
export default function AddressBookPage() { export default function AddressBookPage() {
@@ -36,55 +36,55 @@ export default function AddressBookPage() {
<div className="content-pad workspace-data-page module-entry-page address-book-page"> <div className="content-pad workspace-data-page module-entry-page address-book-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle>Address Book</PageTitle> <PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p> <p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button disabled>Import</Button> <Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>Add contact</Button> <Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
</div> </div>
</div> </div>
<div className="metric-grid"> <div className="metric-grid">
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card> <Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card> <Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card> <Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card> <Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
</div> </div>
<div className="dashboard-grid settings-dashboard-grid"> <div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book scopes"> <Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
<div className="address-book-scope-list"> <div className="address-book-scope-list">
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" /> <AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" /> <AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" /> <AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
</div> </div>
</Card> </Card>
<Card title="Planned address actions"> <Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
<div className="placeholder-stack"> <div className="placeholder-stack">
<span>Choose addresses from personal, group or tenant source</span> <span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
<span>Remember addresses used in campaigns after opt-in</span> <span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
<span>Share selected contacts with a group</span> <span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span> <span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
</div> </div>
</Card> </Card>
</div> </div>
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}> <Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
<DataGrid <DataGrid
id="address-book-contacts" id="address-book-contacts"
rows={allContacts} rows={allContacts}
columns={contactColumns()} columns={contactColumns()}
getRowKey={(contact) => contact.source + "-" + contact.email} getRowKey={(contact) => contact.source + "-" + contact.email}
emptyText="No contacts found." emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
className="compact-table-wrap module-table" className="compact-table-wrap module-table" />
/>
</Card> </Card>
</div> </div>);
);
} }
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) { function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
return ( return (
<div className="address-book-scope-card"> <div className="address-book-scope-card">
<div> <div>
@@ -92,6 +92,6 @@ function AddressBookScope({ title, description, status }: { title: string; descr
<p>{description}</p> <p>{description}</p>
</div> </div>
<StatusBadge status={status} /> <StatusBadge status={status} />
</div> </div>);
);
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui"; import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
@@ -19,14 +19,15 @@ import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number }; type PathChooserState = {index: number;};
type IndividualDisableState = { index: number; usageCount: number }; type IndividualDisableState = {index: number;usageCount: number;};
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer"); const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null; const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
@@ -47,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
reload, reload,
setError, setError,
currentStep: "files", currentStep: "files",
unsavedTitle: "Unsaved attachment settings", unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue." unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
}); });
const attachments = asRecord(displayDraft.attachments); const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
@@ -78,10 +79,10 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
return; return;
} }
let cancelled = false; let cancelled = false;
void listManagedFileSpaces(settings) void listManagedFileSpaces(settings).
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); }) then((response) => {if (!cancelled) setFileSpaces(response.spaces);}).
.catch(() => { if (!cancelled) setFileSpaces([]); }); catch(() => {if (!cancelled) setFileSpaces([]);});
return () => { cancelled = true; }; return () => {cancelled = true;};
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) { function patchBasePaths(paths: AttachmentBasePath[]) {
@@ -153,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
} }
function setZipEnabled(enabled: boolean) { function setZipEnabled(enabled: boolean) {
const archives = enabled && zipConfig.archives.length === 0 const archives = enabled && zipConfig.archives.length === 0 ?
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
: zipConfig.archives; zipConfig.archives;
patchZipCollection({ enabled, archives }); patchZipCollection({ enabled, archives });
} }
@@ -195,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const resetRule = (value: unknown) => { const resetRule = (value: unknown) => {
const rule = asRecord(value); const rule = asRecord(value);
const ruleZip = asRecord(rule.zip); const ruleZip = asRecord(rule.zip);
return String(ruleZip.archive_id ?? "") === removed.id return String(ruleZip.archive_id ?? "") === removed.id ?
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } } { ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
: rule; rule;
}; };
setDraft((current) => { setDraft((current) => {
const source = asRecord(current ?? {}); const source = asRecord(current ?? {});
@@ -232,44 +233,44 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Attachments</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>} {filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
<Card title="Attachment sources"> <Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
<div className="admin-table-surface attachment-sources-table-surface"> <div className="admin-table-surface attachment-sources-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-attachment-sources`} id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths} rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })} columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id} getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured." emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />} emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
className="attachment-sources-table-wrap attachment-sources-table" className="attachment-sources-table-wrap attachment-sources-table" />
/>
</div> </div>
</Card> </Card>
<Card title="ZIP attachments" collapsible> <Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
<div className="attachment-zip-master-toggle"> <div className="attachment-zip-master-toggle">
<ToggleSwitch <ToggleSwitch
label="Enable ZIP attachments" label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
checked={zipConfig.enabled} checked={zipConfig.enabled}
disabled={locked} disabled={locked}
onChange={setZipEnabled} onChange={setZipEnabled} />
/>
</div> </div>
<div className="admin-table-surface attachment-zip-table-surface"> <div className="admin-table-surface attachment-zip-table-surface">
<DataGrid <DataGrid
@@ -288,48 +289,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
removeArchive: removeZipArchive removeArchive: removeZipArchive
})} })}
getRowKey={(archive) => archive.id} getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."} emptyText={zipConfig.enabled ? "i18n:govoplan-campaign.no_zip_attachments_configured.29c16424" : "i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d"}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined} emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
className="attachment-zip-table-wrap" className="attachment-zip-table-wrap" />
/>
</div> </div>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && ( {zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert> <DismissibleAlert tone="warning">i18n:govoplan-campaign.no_password_field_exists_add_one_under_fields_wi.2b6d4a7f <strong>i18n:govoplan-campaign.password.8be3c943</strong>.</DismissibleAlert>
)} }
{zipArchiveNameValidation.message && ( {zipArchiveNameValidation.message &&
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert> <DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
)} }
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p> <p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
</Card> </Card>
<Card title="Global Attachments" collapsible> <Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid <AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`} id={`campaign-${campaignId}-global-attachments`}
rules={globalRules} rules={globalRules}
disabled={locked} disabled={locked}
emptyText="No global attachments are configured yet. Add files here only if every message should include them." emptyText="i18n:govoplan-campaign.no_global_attachments_are_configured_yet_add_fil.d9fdfbb8"
basePaths={basePaths} basePaths={basePaths}
settings={settings} settings={settings}
campaignId={campaignId} campaignId={campaignId}
zipConfig={zipConfig} zipConfig={zipConfig}
filesModuleInstalled={managedFilesAvailable} filesModuleInstalled={managedFilesAvailable}
previewContext={attachmentPreviewContext} previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} onChange={(rules) => patch(["attachments", "global"], rules)} />
/>
</Card> </Card>
<Card title="Statistics" collapsible> <Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
<dl className="detail-list"> <dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div> <div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div> <div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div> <div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div> <div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
</dl> </dl>
<p className="muted small-note">{managedFilesAvailable <p className="muted small-note">{managedFilesAvailable ?
? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." "i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
: filesModuleInstalled filesModuleInstalled ?
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability." "i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p> "i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
</Card> </Card>
</> </>
@@ -337,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<TemplateExpressionEditorDialog <TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])} open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="Edit ZIP archive filename" title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""} value={zipNameEditorIndex !== null ? zipConfig.archives[zipNameEditorIndex]?.name ?? "" : ""}
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))} localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))} globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
placeholder="attachments.zip" placeholder="attachments.zip"
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted." description="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
saveLabel="Save filename" saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)} validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
onCancel={() => setZipNameEditorIndex(null)} onCancel={() => setZipNameEditorIndex(null)}
onSave={(name) => { onSave={(name) => {
@@ -356,52 +357,52 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const existingFields = asArray(draft.fields).map(asRecord); const existingFields = asArray(draft.fields).map(asRecord);
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return; if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
patch(["fields"], [ patch(["fields"], [
...existingFields, ...existingFields,
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true } { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }]
]); );
}} }}
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} />
/>
{pathChooser && ManagedFileChooser && ( {pathChooser && ManagedFileChooser &&
<ManagedFileChooser <ManagedFileChooser
open open
settings={settings} settings={settings}
storageScope={campaignId} storageScope={campaignId}
mode="folder" mode="folder"
source={basePaths[pathChooser.index]?.source} source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path} basePath={basePaths[pathChooser.index]?.path}
onClose={() => setPathChooser(null)} onClose={() => setPathChooser(null)}
onSelectFolder={(selection) => { onSelectFolder={(selection) => {
const current = basePaths[pathChooser.index]; const current = basePaths[pathChooser.index];
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label; const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
patchBasePath(pathChooser.index, { patchBasePath(pathChooser.index, {
source: selection.source, source: selection.source,
path: selection.folderPath || ".", path: selection.folderPath || ".",
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files" name: !current?.name || current.name === "i18n:govoplan-campaign.new_attachment_source.563145ba" || current.name === "i18n:govoplan-campaign.campaign_files.96e7004b" ?
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` :
: current.name current.name
}); });
setPathChooser(null); setPathChooser(null);
}} }} />
/>
)} }
<ConfirmDialog <ConfirmDialog
open={Boolean(individualDisable)} open={Boolean(individualDisable)}
title="Disable individual attachments for this source?" title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
message={individualDisable message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.` individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
: ""} ""}
confirmLabel="Remove individual attachments" confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
cancelLabel="Cancel" cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
tone="danger" tone="danger"
onConfirm={confirmIndividualDisable} onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)} onCancel={() => setIndividualDisable(null)} />
/>
</div> </div>);
);
} }
type ZipArchiveColumnContext = { type ZipArchiveColumnContext = {
@@ -419,73 +420,73 @@ type ZipArchiveColumnContext = {
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] { function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [ return [
{ {
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => ( render: (archive, index) =>
<button <button
type="button" type="button"
className="attachment-zip-name-button" className="attachment-zip-name-button"
disabled={disabled} disabled={disabled}
aria-invalid={invalidNameIndexes.has(index) || undefined} aria-invalid={invalidNameIndexes.has(index) || undefined}
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`} aria-label={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"} title={invalidNameIndexes.has(index) ? "i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639" : "i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"}
onClick={() => onEditName(index)} onClick={() => onEditName(index)}>
>
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span> <span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
<Pencil size={15} aria-hidden="true" /> <Pencil size={15} aria-hidden="true" />
</button> </button>,
),
value: (archive) => archive.name value: (archive) => archive.name
}, },
{ {
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true, id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] }, columnType: "from-list", list: { options: [{ value: "standard", label: "i18n:govoplan-campaign.standard.2dfa6607" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] },
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />, render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
value: (archive) => archive.standard ? "standard" : "optional" value: (archive) => archive.standard ? "standard" : "optional"
}, },
{ {
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true, id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] }, columnType: "from-list", list: { options: [{ value: "protected", label: "i18n:govoplan-campaign.protected.28531336" }, { value: "none", label: "i18n:govoplan-campaign.no_password.18518368" }] },
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />, render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none" value: (archive) => archive.password_enabled ? "protected" : "none"
}, },
{ {
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true, id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] }, columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
render: (archive, index) => ( render: (archive, index) =>
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}> <select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
<option value="">Select password field</option> <option value="">i18n:govoplan-campaign.select_password_field.4007aeb0</option>
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)} {passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select> </select>,
),
value: (archive) => archive.password_field value: (archive) => archive.password_field
}, },
{ {
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true, id: "password_scope", header: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] }, columnType: "from-list", list: { options: [{ value: "local", label: "i18n:govoplan-campaign.recipient_local.418a289f" }, { value: "global", label: "i18n:govoplan-campaign.campaign_global.ba944948" }] },
render: (archive, index) => ( render: (archive, index) =>
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}> <select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
<option value="local">Recipient / local</option> <option value="local">i18n:govoplan-campaign.recipient_local.418a289f</option>
<option value="global">Campaign / global</option> <option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
</select> </select>,
),
value: (archive) => archive.password_scope value: (archive) => archive.password_scope
}, },
{ {
id: "actions", header: "Actions", width: 180, sticky: "end", id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 180, sticky: "end",
render: (_archive, index) => <DataGridRowActions render: (_archive, index) => <DataGridRowActions
disabled={disabled} disabled={disabled}
onAddBelow={() => addArchive(index)} onAddBelow={() => addArchive(index)}
onRemove={() => removeArchive(index)} onRemove={() => removeArchive(index)}
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined} onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined} onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
addLabel="Add ZIP attachment below" addLabel="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
removeLabel="Remove ZIP attachment" removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
moveUpLabel="Move ZIP attachment up" moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
moveDownLabel="Move ZIP attachment down" moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
/>
} }];
];
} }
type ZipFilenameNamespace = "local" | "global"; type ZipFilenameNamespace = "local" | "global";
@@ -511,19 +512,19 @@ function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilena
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password"); const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name])); const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]); const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]);
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]) const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]).
.filter((name) => fieldDefinitions.get(name)?.type !== "password"); filter((name) => fieldDefinitions.get(name)?.type !== "password");
return [ return [
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })), ...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) })) ...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))];
];
} }
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string { function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
const normalized = normalizeZipArchiveName(value); const normalized = normalizeZipArchiveName(value);
if (!normalized) return "Archive filename must not be empty."; if (!normalized) return "i18n:govoplan-campaign.archive_filename_must_not_be_empty.4234b817";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized); const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "Another ZIP attachment already uses this effective filename." : ""; return duplicate ? "i18n:govoplan-campaign.another_zip_attachment_already_uses_this_effecti.d05eb098" : "";
} }
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation { function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
@@ -545,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
} }
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION; if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim()); const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
const duplicateNames = [...byName.entries()] const duplicateNames = [...byName.entries()].
.filter(([, indexes]) => indexes.length > 1) filter(([, indexes]) => indexes.length > 1).
.map(([, indexes]) => archives[indexes[0]]?.name.trim()) map(([, indexes]) => archives[indexes[0]]?.name.trim()).
.filter(Boolean); filter(Boolean);
const parts: string[] = []; const parts: string[] = [];
if (hasEmpty) parts.push("Archive filenames must not be empty"); if (hasEmpty) parts.push("i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427");
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`); if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` }; return { invalidIndexes, message: i18nMessage("i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0", { value0: parts.join(". ") }) };
} }
function normalizeZipArchiveName(value: string): string { function normalizeZipArchiveName(value: string): string {
@@ -580,70 +581,70 @@ type AttachmentSourceColumnContext = {
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] { function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [ return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, { id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{ {
id: "path", id: "path",
header: "Path", header: "i18n:govoplan-campaign.path.519e3913",
width: "minmax(260px, 1fr)", width: "minmax(260px, 1fr)",
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
render: (basePath, index) => ( render: (basePath, index) =>
<div className="field-with-action split-field-action"> <div className="field-with-action split-field-action">
<input <input
className="chooser-display-input" className="chooser-display-input"
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path} value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked} disabled={locked}
readOnly={managedFilesAvailable} readOnly={managedFilesAvailable}
tabIndex={managedFilesAvailable ? -1 : undefined} tabIndex={managedFilesAvailable ? -1 : undefined}
placeholder="attachments" placeholder="i18n:govoplan-campaign.attachments_placeholder"
onChange={(event) => { onChange={(event) => {
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" }); if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
}} }}
onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })} onClick={() => managedFilesAvailable && !locked && setPathChooser({ index })}
onKeyDown={(event) => { onKeyDown={(event) => {
if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) { if (managedFilesAvailable && !locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
setPathChooser({ index }); setPathChooser({ index });
} }
}} }} />
/>
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>} {managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
</div> </div>,
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
}, },
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" }, { id: "individual", header: "i18n:govoplan-campaign.individual_attachments.8164fc7a", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "i18n:govoplan-campaign.individual.a7abed83" }, { value: "global_only", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.individual.a7abed83" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global_only" },
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, { id: "unsent_warning", header: "i18n:govoplan-campaign.unsent_warning.11bdfbf7", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "i18n:govoplan-campaign.warn.3009d557" }, { value: "off", label: "i18n:govoplan-campaign.off.e3de5ab0" }] }, render: (basePath, index) => <ToggleSwitch label="i18n:govoplan-campaign.unsent.0d09fa0d" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180, width: 180,
sticky: "end", sticky: "end",
render: (_basePath, index) => ( render: (_basePath, index) =>
<DataGridRowActions <DataGridRowActions
disabled={locked} disabled={locked}
removeDisabled={basePaths.length <= 1} removeDisabled={basePaths.length <= 1}
onAddBelow={() => addBasePath(index)} onAddBelow={() => addBasePath(index)}
onRemove={() => removeBasePath(index)} onRemove={() => removeBasePath(index)}
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined} onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined} onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
addLabel="Add attachment source below" addLabel="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
removeLabel="Remove attachment source" removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
moveUpLabel="Move attachment source up" moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
moveDownLabel="Move attachment source down" moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
/>
)
} }];
];
} }
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string { function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
const parsedSource = parseManagedAttachmentSource(basePath.source); const parsedSource = parseManagedAttachmentSource(basePath.source);
const matchingSpace = parsedSource const matchingSpace = parsedSource ?
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) :
: undefined; undefined;
const rootLabel = matchingSpace?.label const rootLabel = matchingSpace?.label || (
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : ""); parsedSource?.ownerType === "user" ? "i18n:govoplan-campaign.my_files.71d01a41" : parsedSource?.ownerType === "group" ? "i18n:govoplan-campaign.group_files.1aacad0b" : "");
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, ""); const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`; if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`; return `${relativePath || "."}/`;

View File

@@ -7,7 +7,7 @@ import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion; const version = data.currentVersion;
@@ -15,21 +15,21 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Audit log</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} /> <VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading audit data"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
<Card title="Recent audit events"> <Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p> <p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }

View File

@@ -15,8 +15,8 @@ import FieldValueInput from "./components/FieldValueInput";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const fieldValueKeys = useRef<string[]>([]); const fieldValueKeys = useRef<string[]>([]);
@@ -30,8 +30,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
reload, reload,
setError, setError,
currentStep: "campaign-fields", currentStep: "campaign-fields",
unsavedTitle: "Unsaved fields", unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.", unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)), transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
onLoaded: (_loadedVersion, loadedDraft) => { onLoaded: (_loadedVersion, loadedDraft) => {
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name); fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
@@ -142,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
const fieldProblem = describeFieldNameProblem(fields); const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) { if (fieldProblem) {
setLocalError(fieldProblem); setLocalError(fieldProblem);
setSaveState("Save blocked"); setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
return false; return false;
} }
return saveDraft("manual"); return saveDraft("manual");
@@ -153,39 +153,39 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Fields</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button> <Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>} {fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
<Card title="Campaign fields"> <Card title="i18n:govoplan-campaign.campaign_fields.969e7d80">
<div className="admin-table-surface campaign-fields-table-surface"> <div className="admin-table-surface campaign-fields-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-fields`} id={`campaign-${campaignId}-fields`}
rows={fields} rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })} columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`} getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet." emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />} emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
className="field-editor-table-wrap field-editor-table" className="field-editor-table-wrap field-editor-table" />
/>
</div> </div>
</Card> </Card>
</> </>
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }
type FieldColumnContext = { type FieldColumnContext = {
@@ -203,32 +203,32 @@ type FieldColumnContext = {
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] { function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
return [ return [
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name }, { id: "name", header: "i18n:govoplan-campaign.field_id.4ee26ffd", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label }, { id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="i18n:govoplan-campaign.display_label.d747868d" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
{ id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type }, { id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" }, { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") }, { id: "global_value", header: "i18n:govoplan-campaign.global_value.ab230e7f", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="i18n:govoplan-campaign.optional_default.e04a3bfc" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" }, { id: "override", header: "i18n:govoplan-campaign.recipient_override.4d84be6d", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can_override", label: "i18n:govoplan-campaign.can_override.c37b61c4" }, { value: "locked", label: "i18n:govoplan-campaign.global_only.4641751c" }] }, render: (field, index) => <ToggleSwitch label="i18n:govoplan-campaign.can_override.c37b61c4" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can_override" : "locked" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180, width: 180,
sticky: "end", sticky: "end",
render: (_field, index) => ( render: (_field, index) =>
<DataGridRowActions <DataGridRowActions
disabled={locked} disabled={locked}
onAddBelow={() => addField(index)} onAddBelow={() => addField(index)}
onRemove={() => deleteField(index)} onRemove={() => deleteField(index)}
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined} onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined} onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
addLabel="Add field below" addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
removeLabel="Remove field" removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
moveUpLabel="Move field up" moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
moveDownLabel="Move field down" moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
/>
)
} }];
];
} }
function normalizeFields(value: unknown): CampaignFieldDefinition[] { function normalizeFields(value: unknown): CampaignFieldDefinition[] {
@@ -279,7 +279,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string { function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
const names = fields.map((field) => field.name.trim()); const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) { if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving."; return "i18n:govoplan-campaign.field_ids_must_not_be_empty_before_saving.3ac7b4ca";
} }
const seen = new Set<string>(); const seen = new Set<string>();
@@ -290,7 +290,7 @@ function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
} }
if (duplicates.size === 0) return ""; if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`; return i18nMessage("i18n:govoplan-campaign.duplicate_field_id_value_value_field_ids_must_be.9343a5e9", { value0: duplicates.size === 1 ? "" : "s", value1: [...duplicates].sort().join(", ") });
} }
function uniqueFieldName(fields: CampaignFieldDefinition[]): string { function uniqueFieldName(fields: CampaignFieldDefinition[]): string {

View File

@@ -9,7 +9,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView"; import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor"; import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function CampaignJsonView({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion; const version = data.currentVersion;
const campaignJson = getCampaignJson(version); const campaignJson = getCampaignJson(version);
@@ -20,20 +20,20 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>JSON</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} /> <VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button> <Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading JSON…"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
<Card> <Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>} {!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card> </Card>
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }

View File

@@ -1,32 +1,41 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui"; import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
import { Link, useNavigate } from "react-router-dom"; import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaigns } from "../../api/campaigns"; import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types"; import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) { export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
const navigate = useNavigate(); const navigate = useGuardedNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]); const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>(""); const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
async function load() { async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const data = await listCampaigns(settings); let nextWatermark = forcedSince ?? null;
setCampaigns(data); let nextCampaigns = campaigns;
let response: CampaignDeltaResponse;
do {
response = await listCampaignsDelta(settings, { since: nextWatermark });
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
nextWatermark = response.watermark ?? null;
} while (response.has_more);
setCampaigns(nextCampaigns);
setCampaignDeltaWatermark(nextWatermark);
setLastLoadedAt(formatLoadedAt(new Date())); setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -49,114 +58,115 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
} }
useEffect(() => { useEffect(() => {
load(); setCampaignDeltaWatermark(null);
load(null);
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const columns: DataGridColumn<CampaignListItem>[] = [ const columns: DataGridColumn<CampaignListItem>[] = [
{ {
id: "campaign", id: "campaign",
header: "Campaign", header: "i18n:govoplan-campaign.campaign.69390e16",
width: "minmax(260px, 1.8fr)", width: "minmax(260px, 1.8fr)",
sortable: true, sortable: true,
filterable: true, filterable: true,
sticky: "start", sticky: "start",
render: (campaign) => ( render: (campaign) =>
<div> <div>
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}> <Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
{campaign.name || campaign.external_id || campaign.id} {campaign.name || campaign.external_id || campaign.id}
</Link> </Link>
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div> <div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
</div> </div>,
),
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}` value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
},
{
id: "status",
header: "i18n:govoplan-campaign.status.bae7d5be",
width: 150,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
{ value: "completed", label: "i18n:govoplan-campaign.completed.1798b3ba" },
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }],
display: "pill"
}, },
{ render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
id: "status", value: (campaign) => campaign.status || "draft"
header: "Status", },
width: 150, {
sortable: true, id: "current_version",
filterable: true, header: "i18n:govoplan-campaign.current_version.7be72582",
columnType: "from-list", width: 170,
list: { sortable: true,
options: [ filterable: true,
{ value: "draft", label: "Draft" }, className: "version-cell mono-small",
{ value: "active", label: "Active" }, render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>,
{ value: "completed", label: "Completed" }, value: (campaign) => campaign.current_version_id ?? ""
{ value: "archived", label: "Archived" } },
], {
display: "pill" id: "updated",
}, header: "i18n:govoplan-campaign.updated.f2f8570d",
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />, width: 190,
value: (campaign) => campaign.status || "draft" sortable: true,
}, filterable: true,
{ filterType: "date",
id: "current_version", className: "updated-cell",
header: "Current version", render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
width: 170, value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
sortable: true, },
filterable: true, {
className: "version-cell mono-small", id: "actions",
render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>, header: "i18n:govoplan-campaign.actions.c3cd636a",
value: (campaign) => campaign.current_version_id ?? "" width: 70,
}, sticky: "end",
{ align: "right",
id: "updated", render: (campaign) =>
header: "Updated", <Link
width: 190, to={`/campaigns/${campaign.id}`}
sortable: true, className="btn btn-primary admin-icon-button"
filterable: true, aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
filterType: "date", title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
className: "updated-cell",
render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
},
{
id: "actions",
header: "Actions",
width: 70,
sticky: "end",
align: "right",
render: (campaign) => (
<Link
to={`/campaigns/${campaign.id}`}
className="btn btn-primary admin-icon-button"
aria-label={`Open ${campaign.name || campaign.external_id || campaign.id}`}
title={`Open ${campaign.name || campaign.external_id || campaign.id}`}
>
<ExternalLink aria-hidden="true" /> <ExternalLink aria-hidden="true" />
</Link> </Link>
)
} }];
];
return ( return (
<div className="content-pad campaigns-page"> <div className="content-pad workspace-data-page campaigns-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>All campaigns</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p> <p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={load} disabled={loading}>Reload</Button> <Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={create} disabled={creating}> <Button variant="primary" onClick={create} disabled={creating}>
{creating ? "Creating…" : "New campaign"} {creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button> </Button>
</div> </div>
</div> </div>
<Card title="Campaigns"> <Card title="i18n:govoplan-campaign.campaigns.01a23a28">
<LoadingFrame loading={loading} label="Loading campaigns"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
{campaigns.length === 0 ? ( {campaigns.length === 0 ?
<div className="empty-state"> <div className="empty-state">
<h2>No campaigns yet</h2> <h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p> <p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
<Button variant="primary" onClick={create} disabled={creating}> <Button variant="primary" onClick={create} disabled={creating}>
Create first campaign i18n:govoplan-campaign.create_first_campaign.9be974a4
</Button> </Button>
</div> </div> :
) : (
<div className="admin-table-surface campaigns-table-surface"> <div className="admin-table-surface campaigns-table-surface">
<DataGrid <DataGrid
id="campaigns" id="campaigns"
@@ -165,14 +175,14 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
getRowKey={(campaign) => campaign.id} getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }} initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap" className="campaign-table-wrap"
emptyText="No campaigns found." emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
/>
</div> </div>
)} }
</LoadingFrame> </LoadingFrame>
</Card> </Card>
</div> </div>);
);
} }
function shortId(value: string): string { function shortId(value: string): string {
@@ -187,3 +197,17 @@ function formatDateTime(value?: string): string {
function formatLoadedAt(value: Date): string { function formatLoadedAt(value: Date): string {
return formatDateTimeFromDate(value, { second: "2-digit" }); return formatDateTimeFromDate(value, { second: "2-digit" });
} }
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
if (response.full) return response.campaigns;
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc
});
}
function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
);
}

View File

@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { import {
lockCampaignVersionPermanently, lockCampaignVersionPermanently,
@@ -18,8 +18,8 @@ import {
unlockCampaignVersionUserLock, unlockCampaignVersionUserLock,
updateCampaignMetadata, updateCampaignMetadata,
type CampaignVersionDetail, type CampaignVersionDetail,
type CampaignVersionListItem, type CampaignVersionListItem } from
} from "../../api/campaigns"; "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { import {
asArray, asArray,
@@ -31,15 +31,15 @@ import {
isPermanentUserLockedVersion, isPermanentUserLockedVersion,
isTemporaryUserLockedVersion, isTemporaryUserLockedVersion,
isVersionReadyForDelivery, isVersionReadyForDelivery,
summaryValue, summaryValue } from
} from "./utils/campaignView"; "./utils/campaignView";
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
const campaignModeOptions = ["draft", "test", "send"]; const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent"; type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null; type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign; const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]); const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -51,13 +51,28 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]); const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
useUnsavedDraftGuard({
dirty: identityDirty,
onSave: saveIdentity,
onDiscard: () => {
if (!campaign) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? ""
});
setIdentityDirty(false);
}
});
useEffect(() => { useEffect(() => {
if (!campaign || identityDirty) return; if (!campaign || identityDirty) return;
setIdentity({ setIdentity({
external_id: campaign.external_id ?? "", external_id: campaign.external_id ?? "",
name: campaign.name ?? "", name: campaign.name ?? "",
status: campaign.status ?? "", status: campaign.status ?? "",
description: campaign.description ?? "", description: campaign.description ?? ""
}); });
}, [campaign, identityDirty]); }, [campaign, identityDirty]);
@@ -67,8 +82,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
setMessage(""); setMessage("");
} }
async function saveIdentity() { async function saveIdentity(): Promise<boolean> {
if (!campaign || savingIdentity || !identityDirty) return; if (!campaign || savingIdentity || !identityDirty) return false;
setSavingIdentity(true); setSavingIdentity(true);
setError(""); setError("");
setMessage(""); setMessage("");
@@ -77,12 +92,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
external_id: identity.external_id, external_id: identity.external_id,
name: identity.name, name: identity.name,
status: identity.status, status: identity.status,
description: identity.description, description: identity.description
}); });
setIdentityDirty(false); setIdentityDirty(false);
await reload(); await reload();
return true;
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
return false;
} finally { } finally {
setSavingIdentity(false); setSavingIdentity(false);
} }
@@ -97,13 +114,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
try { try {
if (pending.action === "temporary") { if (pending.action === "temporary") {
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id); await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} temporarily locked.`); setMessage(i18nMessage("i18n:govoplan-campaign.version_value_temporarily_locked.4ed4ffa7", { value0: pending.version.version_number }));
} else if (pending.action === "unlock") { } else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id); await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`); setMessage(i18nMessage("i18n:govoplan-campaign.temporary_lock_removed_from_version_value.9a05bcfb", { value0: pending.version.version_number }));
} else { } else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id); await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`); setMessage(i18nMessage("i18n:govoplan-campaign.version_value_permanently_locked.60595f45", { value0: pending.version.version_number }));
} }
setPendingLockAction(null); setPendingLockAction(null);
await reload(); await reload();
@@ -118,70 +135,70 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle> <PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
<p className="mono-small">Campaign overview · version-independent identity and version history</p> <p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button> <Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
<Link to="wizard/create"><Button>Edit with wizard</Button></Link> <Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button> <Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
<div className="metric-grid campaign-overview-metrics current-version-metrics"> <div className="metric-grid campaign-overview-metrics current-version-metrics">
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" /> <MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" /> <MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" /> <MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} /> <MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div> </div>
<div className="metric-grid campaign-overview-metrics"> <div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" /> <MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" /> <MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" /> <MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" /> <MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
</div> </div>
<Card title="Current version state" actions={<Link <Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
to={`send?version=${campaign?.current_version_id}`} to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`} className={`btn btn-primary`}
aria-label={`Open curent version`} aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
title={`Open curent version`} title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
>
Open i18n:govoplan-campaign.open.cf9b7706
</Link>}> </Link>}>
<div className="summary-grid overview-summary-grid"> <div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} /> <SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} /> <SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} /> <SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} /> <SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
</div> </div>
</Card> </Card>
<Card title="Campaign identity" collapsible> <Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
<div className="form-grid campaign-identity-grid"> <div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID"> <FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} /> <input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField> </FormField>
<FormField label="Mode"> <FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}> <select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)} {campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select> </select>
</FormField> </FormField>
<FormField label="Name"> <FormField label="i18n:govoplan-campaign.name.709a2322">
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} /> <input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
</FormField> </FormField>
<FormField label="Description"> <FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} /> <textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField> </FormField>
</div> </div>
</Card> </Card>
<Card title="Version history" collapsible> <Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
<div className="admin-table-surface"> <div className="admin-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-versions`} id={`campaign-${campaignId}-versions`}
@@ -189,10 +206,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)} columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id} getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }} initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found." emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
className="version-history-table" className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
/>
</div> </div>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
@@ -205,10 +222,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"} tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy} busy={lockBusy}
onCancel={() => setPendingLockAction(null)} onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()} onConfirm={() => void applyLockAction()} />
/>
</div> </div>);
);
} }
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info"; type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
@@ -253,10 +270,10 @@ function campaignVersionMetrics(version: CampaignVersionDetail | null): Campaign
templateHealthValue: `${score}/3`, templateHealthValue: `${score}/3`,
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger", templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
templateHealthDetail: [ templateHealthDetail: [
subjectOk ? "Subject OK" : "Subject missing", subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
bodyOk ? "Body OK" : "Body missing", bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined` placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
].join(" · ") join(" · ")
}; };
} }
@@ -270,106 +287,106 @@ function textValue(value: unknown, fallback = ""): string {
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] { function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [ return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 }, { id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" }, { id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "Current working version" }, { value: "Historical version", label: "Historical version" }, { value: "Temporarily locked", label: "Temporarily locked" }, { value: "Permanently locked", label: "Permanently locked" }, { value: "Delivery locked", label: "Delivery locked" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) }, { id: "lock", header: "i18n:govoplan-campaign.lock.891ebccd", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "i18n:govoplan-campaign.current_working_version.eda99d70" }, { value: "Historical version", label: "i18n:govoplan-campaign.historical_version.8880931f" }, { value: "Temporarily locked", label: "i18n:govoplan-campaign.temporarily_locked.1716dc95" }, { value: "Permanently locked", label: "i18n:govoplan-campaign.permanently_locked.327d59fd" }, { value: "Delivery locked", label: "i18n:govoplan-campaign.delivery_locked.2b664305" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "Not validated" }, { value: "Valid", label: "Valid" }, { value: "Validation issues", label: "Validation issues" }] }, render: validationLabel, value: validationLabel }, { id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "i18n:govoplan-campaign.not_validated.11bb4178" }, { value: "Valid", label: "i18n:govoplan-campaign.valid.a4aefa35" }, { value: "Validation issues", label: "i18n:govoplan-campaign.validation_issues.528305b1" }] }, render: validationLabel, value: validationLabel },
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "Not built" }, { value: "Built", label: "Built" }, { value: "Build issues", label: "Build issues" }] }, render: buildLabel, value: buildLabel }, { id: "build", header: "i18n:govoplan-campaign.build.bbd80cf7", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "i18n:govoplan-campaign.not_built.88bbe87a" }, { value: "Built", label: "i18n:govoplan-campaign.built.a6ad3f82" }, { value: "Build issues", label: "i18n:govoplan-campaign.build_issues.3e03bf8e" }] }, render: buildLabel, value: buildLabel },
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260, width: 260,
sticky: "end", sticky: "end",
render: (version) => { render: (version) => {
const isCurrent = version.id === currentVersionId; const isCurrent = version.id === currentVersionId;
return ( return (
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Link <Link
to={`send?version=${version.id}`} to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`} className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={`Open version ${version.version_number}`} aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
title={`Open version ${version.version_number}`} title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
>
<ExternalLink aria-hidden="true" /> <ExternalLink aria-hidden="true" />
</Link> </Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ? ( {isCurrent && (isTemporaryUserLockedVersion(version) ?
<> <>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button> <Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button> <Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> </> :
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? ( !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button <Button
className="admin-icon-button" className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })} onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={`Temporarily lock version ${version.version_number}`} aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="Temporarily lock version" title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
>
<LockKeyhole aria-hidden="true" /> <LockKeyhole aria-hidden="true" />
</Button> </Button> :
) : null)} null)}
</div> </div>);
);
}, }
}, }];
];
} }
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string { function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only"; if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock"; if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
if (isPermanentUserLockedVersion(version)) return "Permanent user lock"; if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
if (isFinalLockedVersion(version)) return "Permanent delivery lock"; if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
if (canUnlockValidationVersion(version)) return "Temporary validation lock"; if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
if (version.locked_at) return "Temporarily locked"; if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
return "Editable"; return "i18n:govoplan-campaign.editable.b91faec0";
} }
function validationLabel(version: CampaignVersionListItem): string { function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {}; const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed"; if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
if (validation.ok === false) return "Needs attention"; if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
if (validation.ok === true) return "Passed, unavailable while user-locked"; if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
return "Not validated"; return "i18n:govoplan-campaign.not_validated.11bb4178";
} }
function buildLabel(version: CampaignVersionListItem): string { function buildLabel(version: CampaignVersionListItem): string {
const build = version.build_summary ?? {}; const build = version.build_summary ?? {};
return String(build.built_count ?? build.ready_count ?? "Not built"); return String(build.built_count ?? build.ready_count ?? "i18n:govoplan-campaign.not_built.88bbe87a");
} }
function lockDialogTitle(pending: PendingLockAction): string { function lockDialogTitle(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Temporarily lock version?"; if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
if (pending?.action === "unlock") return "Unlock version?"; if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
if (pending?.action === "permanent") return "Lock version permanently?"; if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
return "Confirm lock action"; return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
} }
function lockDialogMessage(pending: PendingLockAction): string { function lockDialogMessage(pending: PendingLockAction): string {
if (pending?.action === "temporary") { if (pending?.action === "temporary") {
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent."; return "i18n:govoplan-campaign.this_makes_the_version_read_only_without_making_.9d86667b";
} }
if (pending?.action === "unlock") { if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained."; return "i18n:govoplan-campaign.this_removes_the_temporary_user_lock_and_makes_t.fdabd47a";
} }
if (pending?.action === "permanent") { if (pending?.action === "permanent") {
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy."; return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.14ae9b80";
} }
return "Continue?"; return "i18n:govoplan-campaign.continue.4e6302ed";
} }
function lockDialogLabel(pending: PendingLockAction): string { function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Lock temporarily"; if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
if (pending?.action === "unlock") return "Unlock"; if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
if (pending?.action === "permanent") return "Lock permanently"; if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "Confirm"; return "i18n:govoplan-campaign.confirm.04a21221";
} }
function SummaryTile({ label, value }: { label: string; value: string | number }) { function SummaryTile({ label, value }: {label: string;value: string | number;}) {
return ( return (
<div className="summary-tile"> <div className="summary-tile">
<span>{label}</span> <span>{label}</span>
<strong>{value}</strong> <strong>{value}</strong>
</div> </div>);
);
} }

View File

@@ -1,16 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
downloadCampaignJobsCsv, downloadCampaignJobsCsv,
emailCampaignReport, emailCampaignReport,
getCampaignJobDetail, getCampaignJobDetail,
getCampaignJobs, getCampaignJobsDelta,
resolveCampaignJobOutcome, resolveCampaignJobOutcome,
retryCampaignJobs, retryCampaignJobs,
sendUnattemptedCampaignJobs, sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse, type CampaignJobDetailResponse,
type CampaignJobsResponse, type CampaignJobsResponse } from
} from "../../api/campaigns"; "../../api/campaigns";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
@@ -20,47 +20,50 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
const SEND_STATUS_OPTIONS: DataGridListOption[] = [ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued", "not_queued",
"queued", "queued",
"claimed", "claimed",
"sending", "sending",
"smtp_accepted", "smtp_accepted",
"sent", "sent",
"outcome_unknown", "outcome_unknown",
"failed_temporary", "failed_temporary",
"failed_permanent", "failed_permanent",
"cancelled", "cancelled"].
].map((value) => ({ value, label: humanize(value) })); map((value) => ({ value, label: humanize(value) }));
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null; const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
"not_requested",
"pending",
"appended",
"failed",
"skipped"].
map((value) => ({ value, label: humanize(value) }));
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const version = data.currentVersion; const version = data.currentVersion;
const cards = data.summary?.cards; const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery); const delivery = asRecord(data.summary?.delivery);
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent); const imapPolicy = asRecord(delivery.imap_append_sent);
const [jobs, setJobs] = useState<CampaignJobsResponse>({ const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
jobs: [], const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
page: 1, const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
page_size: 50,
total: 0,
total_unfiltered: 0,
pages: 0,
counts: {},
filtered_counts: {},
review: {},
});
const [jobsLoading, setJobsLoading] = useState(false); const [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sendStatus, setSendStatus] = useState(""); const [sendStatus, setSendStatus] = useState("");
const [imapStatus, setImapStatus] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [appliedQuery, setAppliedQuery] = useState(""); const [appliedQuery, setAppliedQuery] = useState("");
const [actionMessage, setActionMessage] = useState(""); const [actionMessage, setActionMessage] = useState("");
@@ -81,30 +84,75 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
}, [query]); }, [query]);
useEffect(() => { const jobsQueryKey = useMemo(
void loadJobs(); () => JSON.stringify({
}, [campaignId, version?.id, page, sendStatus, appliedQuery]); campaignId,
versionId: version?.id ?? null,
page,
pageSize: 50,
sendStatus,
imapStatus,
appliedQuery,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
async function loadJobs() { useEffect(() => {
jobPageCursorsRef.current = { 1: null };
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const loadJobs = useCallback(async () => {
if (!campaignId) return; if (!campaignId) return;
setJobsLoading(true); setJobsLoading(true);
setActionError(""); setActionError("");
try { try {
const response = await getCampaignJobs(settings, campaignId, { let nextWatermark = getDeltaWatermark(jobsQueryKey);
versionId: version?.id, let merged = jobsRef.current;
page, let hasMore = false;
pageSize: 50, const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
sendStatus: sendStatus ? [sendStatus] : undefined, do {
query: appliedQuery || undefined, const response = await getCampaignJobsDelta(settings, campaignId, {
}); versionId: version?.id,
setJobs(response); page,
if (response.pages > 0 && page > response.pages) setPage(response.pages); pageSize: 50,
cursor: pageCursor,
sendStatus: sendStatus ? [sendStatus] : undefined,
imapStatus: imapStatus ? [imapStatus] : undefined,
query: appliedQuery || undefined,
since: nextWatermark
});
merged = mergeCampaignJobsDelta(merged, response);
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
if (response.next_cursor !== undefined) {
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
else delete jobPageCursorsRef.current[page + 1];
}
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(jobsQueryKey, nextWatermark);
jobsRef.current = merged;
setJobs(merged);
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
} catch (err) { } catch (err) {
setActionError(err instanceof Error ? err.message : String(err)); setActionError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setJobsLoading(false); setJobsLoading(false);
} }
} }, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
useEffect(() => {
resetDeltaWatermark(jobsQueryKey);
jobsRef.current = emptyCampaignJobsResponse();
setJobs(emptyCampaignJobsResponse());
}, [jobsQueryKey, resetDeltaWatermark]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
async function reloadAll() { async function reloadAll() {
await Promise.all([reload(), loadJobs()]); await Promise.all([reload(), loadJobs()]);
@@ -116,9 +164,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError(""); setActionError("");
setActionMessage(""); setActionMessage("");
try { try {
const response = action === "retry" const response = action === "retry" ?
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }); await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
const result = asRecord(response.result ?? response); const result = asRecord(response.result ?? response);
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`); setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
await reloadAll(); await reloadAll();
@@ -135,9 +183,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError(""); setActionError("");
try { try {
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision); await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
setActionMessage(reconcile.decision === "smtp_accepted" setActionMessage(reconcile.decision === "smtp_accepted" ?
? "The job was recorded as SMTP accepted and is protected from retry." "i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
: "The job was recorded as not sent. It is now an explicit retry candidate."); "i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
setReconcile(null); setReconcile(null);
await reloadAll(); await reloadAll();
} catch (err) { } catch (err) {
@@ -164,7 +212,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
setActionError(""); setActionError("");
try { try {
await downloadCampaignJobsCsv(settings, campaignId, version?.id); await downloadCampaignJobsCsv(settings, campaignId, version?.id);
setActionMessage("Campaign job CSV downloaded."); setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
} catch (err) { } catch (err) {
setActionError(err instanceof Error ? err.message : String(err)); setActionError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -183,7 +231,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
version_id: version?.id, version_id: version?.id,
include_jobs: false, include_jobs: false,
attach_jobs_csv: attachCsv, attach_jobs_csv: attachCsv,
attach_report_json: attachJson, attach_report_json: attachJson
}); });
setActionMessage(`Report sent to ${recipients.join(", ")}.`); setActionMessage(`Report sent to ${recipients.join(", ")}.`);
setEmailOpen(false); setEmailOpen(false);
@@ -195,163 +243,252 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
} }
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [ const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 }, { id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 },
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") }, {
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") }, id: "recipient",
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") }, header: "i18n:govoplan-campaign.recipient.90343260",
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") }, width: 260,
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") }, resizable: true,
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) }, sortable: true,
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") }, filterable: true,
{ render: (row) =>
id: "actions", <div className="recipient-outcome-cell">
header: "Actions", <strong>{String(row.recipient_email ?? "—")}</strong>
width: 250, <span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
sticky: "end", </div>,
render: (row) => {
const id = String(row.id ?? ""); value: (row) => String(row.recipient_email ?? "")
const status = String(row.send_status ?? ""); },
return ( { id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
<div className="button-row compact-actions"> { id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button> { id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>} { id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>} { id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
</div> { id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
); {
}, id: "evidence",
}, header: "i18n:govoplan-campaign.evidence.7ea014de",
], [busyAction]); width: 240,
resizable: true,
filterable: true,
render: (row) =>
<div className="recipient-outcome-cell">
<span title={String(row.message_id_header ?? "")}>{row.message_id_header ? i18nMessage("i18n:govoplan-campaign.message_id_value_value.24027e70", { value0: String(row.message_id_header).slice(0, 28), value1: String(row.message_id_header).length > 28 ? "..." : "" }) : "i18n:govoplan-campaign.no_message_id.43390ef7"}</span>
<span>{String(row.attachment_count ?? 0)} i18n:govoplan-campaign.attachment_rule_s.0e5ee66a {String(row.matched_file_count ?? 0)} i18n:govoplan-campaign.file_s.4bc4cc05</span>
</div>,
value: (row) => `${String(row.message_id_header ?? "")} ${String(row.eml_sha256 ?? "")}`
},
{
id: "error",
header: "i18n:govoplan-campaign.last_result.110b888b",
width: "minmax(220px, 1fr)",
resizable: true,
filterable: true,
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
value: (row) => String(row.last_error ?? "—")
},
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250,
sticky: "end",
render: (row) => {
const id = String(row.id ?? "");
const status = String(row.send_status ?? "");
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
</div>);
}
}],
[busyAction]);
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Report</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} /> <VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button> <Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
<Button onClick={() => setEmailOpen(true)}>Email report</Button> <Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button> <Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
</div> </div>
</div> </div>
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>} {(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>} {actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading report data"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
<div className="dashboard-grid"> <div className="dashboard-grid">
<Card title="Delivery outcome"> <Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<dl className="detail-list"> <dl className="detail-list">
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div> <div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div> <div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
</dl> </dl>
</Card> </Card>
<Card title="IMAP and execution plan"> <Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<dl className="detail-list"> <dl className="detail-list">
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div> <div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div> <div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div> <div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div> <div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}` : "Missing"}</dd></div> <div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
</dl> </dl>
</Card> </Card>
<Card title="Explicit delivery actions"> <Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p> <p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button> <Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button> <Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
</div> </div>
</Card> </Card>
</div> </div>
<Card title="Recipient delivery jobs"> <Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
<div className="page-heading split"> <div className="page-heading split">
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
<select value={sendStatus} onChange={(event) => { setSendStatus(event.target.value); setPage(1); }}> <select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
<option value="">All SMTP states</option> <option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)} {SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select> </select>
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div> </div>
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span> <span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
</div> </div>
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs"> <LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
<DataGrid<Record<string, unknown>> <DataGrid<Record<string, unknown>>
id={`campaign-report-jobs-${campaignId}`} id={`campaign-report-jobs-${campaignId}`}
rows={jobs.jobs} rows={jobs.jobs}
columns={columns} columns={columns}
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")} getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="No jobs match the current filters." emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
/>
</LoadingFrame> </LoadingFrame>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button> <Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span> <span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button> <Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
</div> </div>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
<Dialog <Dialog
open={emailOpen} open={emailOpen}
title="Email campaign report" title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
onClose={() => setEmailOpen(false)} onClose={() => setEmailOpen(false)}
closeDisabled={busyAction === "email"} closeDisabled={busyAction === "email"}
footer={( footer={
<div className="button-row"> <div className="button-row">
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button> <Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button> <Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
</div> </div>
)} }>
>
<label className="field-stack"> <label className="field-stack">
<span>Recipients</span> <span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} /> <textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label> </label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label> <label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label> <label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
</Dialog> </Dialog>
<Dialog <Dialog
open={Boolean(detail)} open={Boolean(detail)}
title="Campaign job detail" title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
onClose={() => setDetail(null)} onClose={() => setDetail(null)}
className="dialog-panel-wide" className="dialog-panel-wide">
>
{detail && ( {detail &&
<div className="stacked-sections"> <div className="stacked-sections">
<dl className="detail-list"> <dl className="detail-list">
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div> <div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div> <div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div> <div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div> <div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div> <div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl> </dl>
<h3>SMTP attempts</h3> <AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre> <AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
<h3>IMAP attempts</h3>
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
</div> </div>
)} }
</Dialog> </Dialog>
<ConfirmDialog <ConfirmDialog
open={Boolean(reconcile)} open={Boolean(reconcile)}
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"} title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
message={reconcile?.decision === "smtp_accepted" message={reconcile?.decision === "smtp_accepted" ?
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append." "i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."} "i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"} confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"} tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
busy={busyAction === "reconcile"} busy={busyAction === "reconcile"}
onConfirm={() => void reconcileOutcome()} onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)} onCancel={() => setReconcile(null)} />
/>
</div> </div>);
);
}
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
if (rows.length === 0) {
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
</section>);
}
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<div className="attempt-history-wrap">
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>);
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom"; import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import { useGuardedNavigate } from "@govoplan/core-webui";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types"; import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar"; import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage"; import CampaignOverviewPage from "./CampaignOverviewPage";
@@ -17,7 +18,6 @@ import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView"; import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage"; import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage"; import CampaignAuditPage from "./CampaignAuditPage";
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = { const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "", overview: "",
@@ -44,7 +44,7 @@ export default function CampaignWorkspace({ settings, auth }: { settings: ApiSet
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { campaignId } = useParams(); const { campaignId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges(); const guardedNavigate = useGuardedNavigate();
const location = useLocation(); const location = useLocation();
const active = sectionFromPath(location.pathname); const active = sectionFromPath(location.pathname);
const urlVersionId = new URLSearchParams(location.search).get("version"); const urlVersionId = new URLSearchParams(location.search).get("version");
@@ -73,7 +73,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
const query = params.toString(); const query = params.toString();
const target = query ? `${pathname}?${query}` : pathname; const target = query ? `${pathname}?${query}` : pathname;
if (`${location.pathname}${location.search}` === target) return; if (`${location.pathname}${location.search}` === target) return;
requestNavigation(() => navigate(target)); guardedNavigate(target);
} }
return ( return (

View File

@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react"; import { useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types"; import type { ApiSettings, AuthInfo } from "../../types";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
@@ -6,6 +6,8 @@ import { FormField } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { PolicyRow } from "@govoplan/core-webui";
import { PolicyTable } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard"; import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
@@ -44,10 +46,10 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
reload, reload,
setError, setError,
currentStep: isPolicyView ? "policies" : "global-settings", currentStep: isPolicyView ? "policies" : "global-settings",
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings", unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_campaign_policy_changes.181f3426" : "i18n:govoplan-campaign.unsaved_campaign_settings.0555d713",
unsavedMessage: isPolicyView unsavedMessage: isPolicyView ?
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue." "i18n:govoplan-campaign.campaign_policies_have_unsaved_changes_save_them.7b07827f" :
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.", "i18n:govoplan-campaign.campaign_settings_have_unsaved_changes_save_them.c1c5f65f",
extraPayload: () => ({ editor_state: editorState }), extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})), onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState)) onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
@@ -61,7 +63,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
const optIns = asRecord(editorState.opt_ins); const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read"); const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write"); const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings"; const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
function patchEditor(path: string[], value: unknown) { function patchEditor(path: string[], value: unknown) {
if (locked) return; if (locked) return;
@@ -77,171 +79,185 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
{isPolicyView ? ( {isPolicyView ?
<> <>
{canReadRetentionPolicy && ( {canReadRetentionPolicy &&
<RetentionPolicyEditor <RetentionPolicyEditor
settings={settings} settings={settings}
scopeType="campaign" scopeType="campaign"
scopeId={campaignId} scopeId={campaignId}
title="Campaign retention policy" title="i18n:govoplan-campaign.campaign_retention_policy.fcc9897c"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit." description="i18n:govoplan-campaign.campaign_level_retention_limits_applied_after_sy.e1a5fd45"
canWrite={canWriteRetentionPolicy} canWrite={canWriteRetentionPolicy}
locked={locked} locked={locked} />
/>
)} }
<div className="dashboard-grid below-grid"> <div className="dashboard-grid below-grid">
<Card title="Validation policy" collapsible> <Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
<PolicyTable> <PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
<PolicyRow <PolicyRow
label="Missing required attachment" className="campaign-policy-row"
note="Required attachment rule found no matching file." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.missing_required_attachment.a8aa73e0"
note="i18n:govoplan-campaign.required_attachment_rule_found_no_matching_file.d947b52b"
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} />
<PolicyRow <PolicyRow
label="Missing optional attachment" className="campaign-policy-row"
note="Optional attachment rule found no matching file." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.missing_optional_attachment.36640fa6"
note="i18n:govoplan-campaign.optional_attachment_rule_found_no_matching_file.709a2a13"
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} />
<PolicyRow <PolicyRow
label="Ambiguous attachment match" className="campaign-policy-row"
note="Attachment rule matched more files than expected." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.ambiguous_attachment_match.8043ddfa"
note="i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8"
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} />
<PolicyRow <PolicyRow
label="Unsent attachment file" className="campaign-policy-row"
note="A watched attachment directory contains files that were not selected." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.unsent_attachment_file.a25263ce"
note="i18n:govoplan-campaign.a_watched_attachment_directory_contains_files_th.03f58a42"
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} />
<PolicyRow <PolicyRow
label="Missing email address" className="campaign-policy-row"
note="The effective recipient list has no To address." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.missing_email_address.2a94b6d8"
note="i18n:govoplan-campaign.the_effective_recipient_list_has_no_to_address.df4e24f7"
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} />
<PolicyRow <PolicyRow
label="Template error" className="campaign-policy-row"
note="A selected template body contains unresolved placeholders." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.template_error.50b92586"
note="i18n:govoplan-campaign.a_selected_template_body_contains_unresolved_pla.d061acad"
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} />
<PolicyRow <PolicyRow
label="Ignore empty fields" className="campaign-policy-row"
note="Controls how missing placeholder values are rendered." labelClassName="campaign-policy-label"
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />} controlClassName="campaign-policy-control"
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.ignore_empty_fields.1ffcdc5d"
note="i18n:govoplan-campaign.controls_how_missing_placeholder_values_are_rend.c1ad492a"
control={<ToggleSwitch label="i18n:govoplan-campaign.enabled.df174a3f" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
effective={getBool(validationPolicy, "ignore_empty_fields") ? "i18n:govoplan-campaign.missing_values_render_as_empty_text.0a6bce45" : "i18n:govoplan-campaign.missing_values_remain_visible_and_can_trigger_te.31464ee2"} />
</PolicyTable> </PolicyTable>
</Card> </Card>
<Card title="Attachment policy" collapsible> <Card title="i18n:govoplan-campaign.attachment_policy.2fd471d4" collapsible>
<PolicyTable> <PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
<PolicyRow <PolicyRow
label="Default missing behavior" className="campaign-policy-row"
note="Used by attachment rules that do not override missing-file behavior." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.default_missing_behavior.ffbd9cd7"
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_mi.ab8bc0d5"
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} />
<PolicyRow <PolicyRow
label="Default ambiguous behavior" className="campaign-policy-row"
note="Used by attachment rules that do not override ambiguous-match behavior." labelClassName="campaign-policy-label"
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />} controlClassName="campaign-policy-control"
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.default_ambiguous_behavior.878b9345"
note="i18n:govoplan-campaign.used_by_attachment_rules_that_do_not_override_am.c31b4e9f"
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} />
<PolicyRow <PolicyRow
label="Send without attachments" className="campaign-policy-row"
note="Campaign-wide fallback when no attachment is available." labelClassName="campaign-policy-label"
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />} controlClassName="campaign-policy-control"
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."} effectiveClassName="campaign-policy-effective-note"
/> label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
</PolicyTable> </PolicyTable>
</Card> </Card>
</div> </div>
</> </> :
) : (
<> <>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />} {data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
<div className="dashboard-grid below-grid"> <div className="dashboard-grid below-grid">
<Card title="Delivery defaults" collapsible> <Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div> </div>
</Card> </Card>
<Card title="Opt-ins and local assistance" collapsible> <Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
<div className="toggle-grid"> <div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.suggest_addresses_from_this_campaign.5ebe2aea" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.remember_newly_used_addresses.aebe8adb" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div> </div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p> <p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
</Card> </Card>
</div> </div>
</> </>
)} }
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }
function PolicyTable({ children }: { children: ReactNode }) { function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: {value: string;disabled?: boolean;onChange: (value: string) => void;options?: string[];}) {
return (
<div className="campaign-policy-table policy-table">
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
<span>Policy</span>
<span>Setting</span>
<span>Effective behavior</span>
</div>
{children}
</div>
);
}
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
return (
<div className="campaign-policy-row policy-row">
<div className="campaign-policy-label policy-field-label">
<strong>{label}</strong>
<small>{note}</small>
</div>
<div className="campaign-policy-control policy-control">{control}</div>
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
</div>
);
}
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return ( return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}> <select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)} {options.map((option) => <option key={option} value={option}>{option}</option>)}
</select> </select>);
);
} }
function behaviorSummary(value: string): string { function behaviorSummary(value: string): string {
if (value === "block") return "Blocks the affected message."; if (value === "block") return "i18n:govoplan-campaign.blocks_the_affected_message.0157342a";
if (value === "ask") return "Marks the message for review."; if (value === "ask") return "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24";
if (value === "drop") return "Excludes the affected message."; if (value === "drop") return "i18n:govoplan-campaign.excludes_the_affected_message.73be963f";
if (value === "continue") return "Continues without a review issue."; if (value === "continue") return "i18n:govoplan-campaign.continues_without_a_review_issue.99ab0684";
if (value === "warn") return "Keeps sending possible with a warning."; if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
return "Uses the configured behavior."; return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
} }

View File

@@ -8,7 +8,7 @@ import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import { import {
createMailServerProfile, createMailServerProfile,
getMailProfilePolicy, getMailProfilePolicy,
@@ -21,8 +21,8 @@ import {
testSmtpSettings, testSmtpSettings,
type MailProfilePolicy, type MailProfilePolicy,
type MailSecurity, type MailSecurity,
type MailServerProfile type MailServerProfile } from
} from "../../api/mail"; "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -67,10 +67,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
reload, reload,
setError, setError,
currentStep: isPolicyView ? "mail-policy" : "mail-settings", currentStep: isPolicyView ? "mail-policy" : "mail-settings",
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings", unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
unsavedMessage: isPolicyView unsavedMessage: isPolicyView ?
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue." "i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue.", "i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
transformDraftBeforeSave: normalizeMailSettingsBeforeSave transformDraftBeforeSave: normalizeMailSettingsBeforeSave
}); });
const server = asRecord(displayDraft.server); const server = asRecord(displayDraft.server);
@@ -93,9 +93,9 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed; const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked; const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked; const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited); const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile; const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || (usingMailProfile && imapCredentialsInherited); const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable; const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
const imapAppendEnabled = getBool(imapAppend, "enabled"); const imapAppendEnabled = getBool(imapAppend, "enabled");
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled; const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
@@ -129,7 +129,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"), sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30) timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
}; };
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || (selectedProfileHasImap && !imapCredentialsInherited)); const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || selectedProfileHasImap && !imapCredentialsInherited);
useEffect(() => { useEffect(() => {
if (!mailModuleInstalled) { if (!mailModuleInstalled) {
@@ -149,10 +149,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
setProfileError(""); setProfileError("");
try { try {
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([ const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
listMailServerProfiles(settings, false, campaignId), listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true), listMailServerProfiles(settings, true),
getMailProfilePolicy(settings, "campaign", campaignId, campaignId) getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
]); );
setMailProfiles(allowedProfiles); setMailProfiles(allowedProfiles);
setPolicyProfiles(visibleProfiles); setPolicyProfiles(visibleProfiles);
setEffectiveMailPolicy(policyResponse.effective_policy ?? null); setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
@@ -182,11 +182,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
} }
function normalizeProfileCredentialProtocol( function normalizeProfileCredentialProtocol(
serverValue: Record<string, unknown>, serverValue: Record<string, unknown>,
credentialsValue: Record<string, unknown>, credentialsValue: Record<string, unknown>,
protocol: "smtp" | "imap", protocol: "smtp" | "imap",
inherit: boolean inherit: boolean)
) { {
serverValue["inherit_" + protocol + "_credentials"] = inherit; serverValue["inherit_" + protocol + "_credentials"] = inherit;
if (inherit === false) return; if (inherit === false) return;
@@ -283,21 +283,21 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
function smtpServerPayload() { function smtpServerPayload() {
return mailSmtpSettingsPayload<MailSecurity>( return mailSmtpSettingsPayload<MailSecurity>(
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) }, { host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions }, { fallbackSecurity: "starttls", allowedSecurity: securityOptions }
); );
} }
function imapServerPayload() { function imapServerPayload() {
return mailImapSettingsPayload<MailSecurity>( return mailImapSettingsPayload<MailSecurity>(
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) }, { host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }, { fallbackSecurity: "tls", allowedSecurity: securityOptions }
); );
} }
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) { function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
return { return {
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword), smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword), imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword)
}; };
} }
@@ -324,16 +324,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runSmtpTest() { async function runSmtpTest() {
if (!mailModuleInstalled) { if (!mailModuleInstalled) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: "Install and enable the Mail module to test SMTP settings.", details: {} }); setSmtpTestResult({ ok: false, protocol: "smtp", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_smtp_.a7ce04e1", details: {} });
return; return;
} }
if (locked || inlineMailSettingsBlocked) return; if (locked || inlineMailSettingsBlocked) return;
setMailActionState("smtp"); setMailActionState("smtp");
setLocalError(""); setLocalError("");
try { try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
? await testMailProfileSmtp(settings, selectedProfileId) await testMailProfileSmtp(settings, selectedProfileId) :
: await testSmtpSettings(settings, rawSmtpPayload())); await testSmtpSettings(settings, rawSmtpPayload()));
} catch (err) { } catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} }); setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally { } finally {
@@ -343,16 +343,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runImapTest() { async function runImapTest() {
if (!mailModuleInstalled) { if (!mailModuleInstalled) {
setImapTestResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to test IMAP settings.", details: {} }); setImapTestResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_test_imap_.d6537dfd", details: {} });
return; return;
} }
if (imapDisabled) return; if (imapDisabled) return;
setMailActionState("imap"); setMailActionState("imap");
setLocalError(""); setLocalError("");
try { try {
setImapTestResult(selectedProfileId && imapCredentialsInherited setImapTestResult(selectedProfileId && imapCredentialsInherited ?
? await testMailProfileImap(settings, selectedProfileId) await testMailProfileImap(settings, selectedProfileId) :
: await testImapSettings(settings, rawImapPayload())); await testImapSettings(settings, rawImapPayload()));
} catch (err) { } catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} }); setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally { } finally {
@@ -362,16 +362,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
async function runFolderLookup() { async function runFolderLookup() {
if (!mailModuleInstalled) { if (!mailModuleInstalled) {
setFolderResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to inspect IMAP folders.", folders: [], details: {} }); setFolderResult({ ok: false, protocol: "imap", message: "i18n:govoplan-campaign.install_and_enable_the_mail_module_to_inspect_im.52535774", folders: [], details: {} });
return; return;
} }
if (appendTargetFolderDisabled) return; if (appendTargetFolderDisabled) return;
setMailActionState("folders"); setMailActionState("folders");
setLocalError(""); setLocalError("");
try { try {
setFolderResult(selectedProfileId && imapCredentialsInherited setFolderResult(selectedProfileId && imapCredentialsInherited ?
? await listMailProfileImapFolders(settings, selectedProfileId) await listMailProfileImapFolders(settings, selectedProfileId) :
: await listImapFolders(settings, rawImapPayload())); await listImapFolders(settings, rawImapPayload()));
} catch (err) { } catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} }); setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally { } finally {
@@ -389,89 +389,89 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>{isPolicyView ? "Mail policy" : "Mail settings"}</PageTitle> <PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>} {!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
{!mailModuleInstalled && ( {!mailModuleInstalled &&
<DismissibleAlert tone="info" dismissible={false}> <DismissibleAlert tone="info" dismissible={false}>
The Mail module is not installed. Inline SMTP and IMAP values can be edited in the campaign draft, but reusable profiles, connection tests and sending require the Mail module. i18n:govoplan-campaign.the_mail_module_is_not_installed_inline_smtp_and.8e0f802e
</DismissibleAlert> </DismissibleAlert>
)} }
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && ( {isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
<MailProfilePolicyEditor <MailProfilePolicyEditor
settings={settings} settings={settings}
scopeType="campaign" scopeType="campaign"
scopeId={campaignId} scopeId={campaignId}
campaignId={campaignId} campaignId={campaignId}
profiles={policyProfiles} profiles={policyProfiles}
ownerUserId={data.campaign?.owner_user_id} ownerUserId={data.campaign?.owner_user_id}
ownerGroupId={data.campaign?.owner_group_id} ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked} canWrite={!locked}
locked={locked} locked={locked}
title="Campaign-local mail policy" title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
description="Campaign-local mail limits applied after system, tenant and owner policies." description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
onSaved={refreshMailProfiles} onSaved={refreshMailProfiles} />
/>
)}
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && ( }
<DismissibleAlert tone="warning" dismissible={false}>The Mail module did not expose profile-management UI capabilities.</DismissibleAlert>
)}
{!isPolicyView && mailModuleInstalled && ( {isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor &&
<DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_mail_module_did_not_expose_profile_managemen.2cdb57e1</DismissibleAlert>
}
{!isPolicyView && mailModuleInstalled &&
<Card <Card
title="Reusable mail profile" title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={ actions={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button> <Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button> <Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
</div> </div>
} }>
>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<FormField label="Profile"> <FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}> <select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option> <option value="" disabled={mailPolicyState.inlineOptionDisabled}>i18n:govoplan-campaign.inline_smtp_imap_settings.cf16c421</option>
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)} {mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select> </select>
</FormField> </FormField>
<FormField label="New profile name"> <FormField label="i18n:govoplan-campaign.new_profile_name.393313b6">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} /> <input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
</FormField> </FormField>
</div> </div>
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>} {!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
{selectedProfile && ( {selectedProfile &&
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p> <p className="muted small-note">i18n:govoplan-campaign.using.c25de2e8 {selectedProfile.name} ({profileScopeLabel(selectedProfile)}i18n:govoplan-campaign.smtp_credentials.10f75c8a {smtpCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c"}i18n:govoplan-campaign.imap_credentials.7442b238 {selectedProfile.imap ? imapCredentialsInherited ? "i18n:govoplan-campaign.inherited_from_profile.1947c2f3" : "i18n:govoplan-campaign.local_credentials_required.fdc9af1c" : "i18n:govoplan-campaign.not_configured.67f2141f"}.</p>
)} }
{selectedProfileNeedsLocalCredentials && ( {selectedProfileNeedsLocalCredentials &&
<p className="muted small-note">The selected profile supplies the server settings. Enter the required campaign-local credentials in the mail server settings panel below.</p> <p className="muted small-note">i18n:govoplan-campaign.the_selected_profile_supplies_the_server_setting.bdc830db</p>
)} }
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>} {profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>} {profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
</Card> </Card>
)} }
{!isPolicyView && ( {!isPolicyView &&
<Card title="Mail server settings" collapsible> <Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
{inlinePolicyMessages.length > 0 && ( {inlinePolicyMessages.length > 0 &&
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}> <DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
<strong>Effective mail policy blocks the current inline settings.</strong> <strong>i18n:govoplan-campaign.effective_mail_policy_blocks_the_current_inline_.99c34c6b</strong>
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul> <ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
</DismissibleAlert> </DismissibleAlert>
)} }
<MailServerSettingsPanel <MailServerSettingsPanel
smtp={displayedSmtp} smtp={displayedSmtp}
imap={displayedImap} imap={displayedImap}
@@ -497,8 +497,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked), onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder) onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
}} }}
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"} smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"} imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
busyAction={mailActionState} busyAction={mailActionState}
onTestSmtp={runSmtpTest} onTestSmtp={runSmtpTest}
onTestImap={runImapTest} onTestImap={runImapTest}
@@ -508,15 +508,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
folderLookupResult={folderResult} folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder} onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={appendTargetFolderDisabled} useDetectedFolderDisabled={appendTargetFolderDisabled}
floatingResults floatingResults />
/>
</Card> </Card>
)} }
</> </>
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,11 @@ import { importedRowsNeedAttachmentSource, materializeRecipientImport, type Reci
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage"; import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui"; import { addressesFromValue } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const filesModuleInstalled = usePlatformModuleInstalled("files"); const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@@ -40,8 +40,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
reload, reload,
setError, setError,
currentStep: "recipient-data", currentStep: "recipient-data",
unsavedTitle: "Unsaved recipient data changes", unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue." unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
}); });
const entries = asRecord(displayDraft.entries); const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord); const inlineEntries = asArray(entries.inline).map(asRecord);
@@ -95,16 +95,16 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
} }
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath }); const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource const nextDraft = needsAttachmentSource ?
? { {
...importedDraft, ...importedDraft,
attachments: { attachments: {
...asRecord(importedDraft.attachments), ...asRecord(importedDraft.attachments),
base_paths: nextBasePaths, base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "." base_path: nextBasePaths[0]?.path || "."
}
} }
: importedDraft; } :
importedDraft;
setDraft(nextDraft); setDraft(nextDraft);
markDirty(); markDirty();
setImportOpen(false); setImportOpen(false);
@@ -115,65 +115,65 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Recipient data</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
<Card title="Recipient field values and attachments" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}> <Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>} {inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
{inlineEntries.length === 0 && Boolean(source.type) && ( {inlineEntries.length === 0 && Boolean(source.type) &&
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert> <DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
)} }
{inlineEntries.length > 0 && ( {inlineEntries.length > 0 &&
<div className="admin-table-surface recipient-data-table-surface"> <div className="admin-table-surface recipient-data-table-surface">
<DataGrid <DataGrid
id={`campaign-${campaignId}-recipient-data`} id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries} rows={inlineEntries}
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })} columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)} getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found." emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table" className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
pagination={{ pagination={{
page: recipientDataPage, page: recipientDataPage,
pageSize: recipientDataPageSize, pageSize: recipientDataPageSize,
pageSizeOptions: [10, 25, 50, 100, 250], pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientDataPage, onPageChange: setRecipientDataPage,
onPageSizeChange: (pageSize) => { onPageSizeChange: (pageSize) => {
setRecipientDataPageSize(pageSize); setRecipientDataPageSize(pageSize);
setRecipientDataPage(1); setRecipientDataPage(1);
} }
}} }} />
/>
</div> </div>
)} }
</Card> </Card>
</> </>
</LoadingFrame> </LoadingFrame>
{importOpen && ( {importOpen &&
<RecipientImportDialog <RecipientImportDialog
settings={settings} settings={settings}
campaignId={campaignId} campaignId={campaignId}
existingEntries={inlineEntries} existingEntries={inlineEntries}
existingFields={fieldDefinitions} existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath} defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)} onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport} onImport={applyRecipientImport} />
/>
)} }
</div> </div>);
);
} }
type RecipientDataColumnContext = { type RecipientDataColumnContext = {
@@ -191,72 +191,72 @@ type RecipientDataColumnContext = {
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] { function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [ return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 }, { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{ {
id: "recipient", id: "recipient",
header: "Recipient", header: "i18n:govoplan-campaign.recipient.90343260",
width: 260, width: 260,
resizable: true, resizable: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
sticky: "start", sticky: "start",
render: (entry) => ( render: (entry) =>
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile"> <Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span> <span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>} {extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
</Link> </Link>,
),
value: firstRecipientEmail value: firstRecipientEmail
},
{
id: "attachments",
header: "i18n:govoplan-campaign.attachments.6771ade6",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
rules={attachments}
settings={settings}
campaignId={campaignId}
disabled={locked}
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} />);
}, },
{ value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
id: "attachments", },
header: "Attachments", ...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
width: 180, id: `field-${field.name}`,
filterable: true, header: field.label || field.name,
render: (entry, index) => { width: 190,
const attachments = normalizeAttachmentRules(entry.attachments); resizable: true,
return ( sortable: true,
<AttachmentRulesOverlay filterable: true,
title={`Attachments for recipient ${index + 1}`} filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
rules={attachments} render: (entry, index) => {
settings={settings} const fields = asRecord(entry.fields);
campaignId={campaignId} return (
disabled={locked} <FieldValueInput
buttonLabel={`entries: ${attachments.length}`} className="recipient-field-input"
basePaths={individualAttachmentBasePaths} fieldType={field.type}
zipConfig={zipConfig} value={fields[field.name]}
filesModuleInstalled={filesModuleInstalled} disabled={locked || field.can_override === false}
previewContext={buildTemplatePreviewContext(draft, entry)} placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
onChange={(rules) => updateEntryAttachments(index, rules)} onChange={(value) => updateEntryField(index, field.name, value)} />);
/>
);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}, },
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({ value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
id: `field-${field.name}`, }))];
header: field.label || field.name,
width: 190,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
render: (entry, index) => {
const fields = asRecord(entry.fields);
return (
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
}))
];
} }
function firstRecipientEmail(entry: Record<string, unknown>): string { function firstRecipientEmail(entry: Record<string, unknown>): string {

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, SegmentedControl, i18nMessage } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice"; import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay"; import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
@@ -23,7 +23,7 @@ type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html"; type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html"; type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text"); const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text"); const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
@@ -47,9 +47,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
reload, reload,
setError, setError,
currentStep: "template", currentStep: "template",
unsavedTitle: "Unsaved template changes", unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.", unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded", loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
onLoaded: () => setPreviewIndex(0) onLoaded: () => setPreviewIndex(0)
}); });
const template = asRecord(displayDraft.template); const template = asRecord(displayDraft.template);
@@ -78,10 +78,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const previewEntry = previewSelection.entry; const previewEntry = previewSelection.entry;
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false); const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = [ const templateText = [
getText(template, "subject"), getText(template, "subject"),
templateBodyMode !== "html" ? getText(template, "text") : "", templateBodyMode !== "html" ? getText(template, "text") : "",
templateBodyMode !== "text" ? getText(template, "html") : "" templateBodyMode !== "text" ? getText(template, "html") : ""].
].join("\n"); join("\n");
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]); const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]); const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo( const undefinedPlaceholders = useMemo(
@@ -122,19 +122,19 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
void previewCampaignAttachments(settings, campaignId, version.id, { void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false, include_unmatched: false,
campaign_json: campaignJsonForAttachmentPreview(displayDraft) campaign_json: campaignJsonForAttachmentPreview(displayDraft)
}) }).
.then((response) => { then((response) => {
if (!cancelled) setAttachmentPreviewRules(response.rules); if (!cancelled) setAttachmentPreviewRules(response.rules);
}) }).
.catch((reason: unknown) => { catch((reason: unknown) => {
if (!cancelled) { if (!cancelled) {
setAttachmentPreviewRules([]); setAttachmentPreviewRules([]);
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason)); setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
} }
}) }).
.finally(() => { finally(() => {
if (!cancelled) setAttachmentPreviewLoading(false); if (!cancelled) setAttachmentPreviewLoading(false);
}); });
}, 120); }, 120);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -179,15 +179,15 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name); const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) { if (!alreadyDefined) {
patch(["fields"], [ patch(["fields"], [
...existingFields, ...existingFields,
{ {
name: field.name, name: field.name,
label: humanizeFieldName(field.name), label: humanizeFieldName(field.name),
type: "string", type: "string",
required: false, required: false,
can_override: true can_override: true
} }]
]); );
} }
setUndefinedDialog(null); setUndefinedDialog(null);
} }
@@ -228,106 +228,123 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Template</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button disabled>Manage templates</Button> <Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
<Button onClick={reload} disabled={loading}>Reload</Button> <Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button> <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>} {localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />} {locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft"> <LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<> <>
<div className="dashboard-grid template-editor-grid"> <div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}> <Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
<div className="form-grid"> <div className="form-grid">
<FormField label="Subject"> <FormField label="i18n:govoplan-campaign.subject.8d183dbd">
<input <input
ref={subjectRef} ref={subjectRef}
value={getText(template, "subject")} value={getText(template, "subject")}
disabled={locked} disabled={locked}
onFocus={() => setActiveEditor("subject")} onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)} onChange={(event) => patchTemplateText("subject", event.target.value)} />
</FormField>
<FormField label="i18n:govoplan-campaign.message_body_format.5fec42d2">
<SegmentedControl
className="template-body-mode"
size="content"
width="inline"
ariaLabel="i18n:govoplan-campaign.message_body_format.5fec42d2"
value={templateBodyMode}
disabled={locked}
onChange={patchTemplateBodyMode}
options={[
{ id: "text", label: "i18n:govoplan-campaign.text_only.9ccbd022" },
{ id: "html", label: "i18n:govoplan-campaign.html_only.1c4fbcb1" },
{ id: "both", label: "i18n:govoplan-campaign.both.1f469838" }
]}
/> />
</FormField> </FormField>
<FormField label="Message body format"> {templateBodyMode === "both" &&
<div className="template-body-mode" role="tablist" aria-label="Message body format"> <SegmentedControl
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button> className="template-body-mode template-editor-mode"
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button> size="content"
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button> width="inline"
</div> ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
</FormField> value={visibleBodyEditor}
{templateBodyMode === "both" && ( onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor"> options={[
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button> { id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button> { id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
</div> ]}
)} />
{visibleBodyEditor === "text" && ( }
<FormField label="Plain text body"> {visibleBodyEditor === "text" &&
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
<textarea <textarea
ref={textRef} ref={textRef}
rows={16} rows={16}
value={getText(template, "text")} value={getText(template, "text")}
disabled={locked} disabled={locked}
onFocus={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }} onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
onChange={(event) => patchTemplateText("text", event.target.value)} onChange={(event) => patchTemplateText("text", event.target.value)} />
/>
</FormField> </FormField>
)} }
{visibleBodyEditor === "html" && ( {visibleBodyEditor === "html" &&
<FormField label="HTML body"> <FormField label="i18n:govoplan-campaign.html_body.77b5ba37">
<textarea <textarea
ref={htmlRef} ref={htmlRef}
rows={16} rows={16}
value={getText(template, "html")} value={getText(template, "html")}
disabled={locked} disabled={locked}
onFocus={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }} onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
onChange={(event) => patchTemplateText("html", event.target.value)} onChange={(event) => patchTemplateText("html", event.target.value)} />
/>
</FormField> </FormField>
)} }
<div className="button-row template-editor-actions"> <div className="button-row template-editor-actions">
<Button disabled>Load from library</Button> <Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
<Button disabled>Save to library</Button> <Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
</div> </div>
</div> </div>
</Card> </Card>
<div className="template-side-stack"> <div className="template-side-stack">
<Card title="Fields"> <Card title="i18n:govoplan-campaign.fields.e8b68527">
{invalidNamespacePlaceholders.length > 0 && ( {invalidNamespacePlaceholders.length > 0 &&
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert> <DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282 {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
)} }
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>} {usedPlaceholders.length === 0 && <p className="muted">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p> <p className="muted small-note">i18n:govoplan-campaign.click_a_field_to_insert_it_at_the_current_cursor.643aa7bc</p>
<h3 className="section-mini-heading">Global fields</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.global_fields.07f84ea4</h3>
<TemplateFieldChipList <TemplateFieldChipList
namespace="global" namespace="global"
fields={globalFieldOptions} fields={globalFieldOptions}
usedPlaceholders={usedPlaceholders} usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined." empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
onInsert={insertPlaceholder} onInsert={insertPlaceholder} />
/>
<h3 className="section-mini-heading">Local fields</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p> <p className="muted small-note">i18n:govoplan-campaign.address_fields_use_the_effective_merged_or_overr.d6930da6 <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_a_specific_additional_address.1199883f</p>
<TemplateFieldChipList <TemplateFieldChipList
namespace="local" namespace="local"
fields={localFieldOptions} fields={localFieldOptions}
usedPlaceholders={usedPlaceholders} usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined." empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
onInsert={insertPlaceholder} onInsert={insertPlaceholder} />
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.used_in_template_but_undefined.57b5f3da</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} /> <UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card> </Card>
</div> </div>
@@ -336,84 +353,89 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</> </>
</LoadingFrame> </LoadingFrame>
{previewOpen && ( {previewOpen &&
<CampaignMessagePreviewOverlay <CampaignMessagePreviewOverlay
title="Template preview" title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
bodyMode={visibleBodyEditor} bodyMode={visibleBodyEditor}
subject={previewSubject} subject={previewSubject}
text={templateBodyMode === "html" ? null : previewText} text={templateBodyMode === "html" ? null : previewText}
html={templateBodyMode === "text" ? null : previewHtml} html={templateBodyMode === "text" ? null : previewHtml}
metaItems={templatePreviewMetaItems(previewContext)} metaItems={templatePreviewMetaItems(previewContext)}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"} recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "i18n:govoplan-campaign.global_preview.f81f09b2"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."} recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
attachments={previewAttachments} attachments={previewAttachments}
navigation={{ navigation={{
index: Math.min(previewIndex, previewEntries.length - 1), index: Math.min(previewIndex, previewEntries.length - 1),
total: previewEntries.length, total: previewEntries.length,
onFirst: () => setPreviewIndex(0), onFirst: () => setPreviewIndex(0),
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)), onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)), onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
onLast: () => setPreviewIndex(previewEntries.length - 1) onLast: () => setPreviewIndex(previewEntries.length - 1)
}} }}
onClose={() => setPreviewOpen(false)} onClose={() => setPreviewOpen(false)} />
/>
)} }
<UndefinedPlaceholderDecisionDialog <UndefinedPlaceholderDecisionDialog
field={undefinedDialog} field={undefinedDialog}
contextLabel="template" contextLabel="template"
removeLabel="Remove from template" removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
onCancel={() => setUndefinedDialog(null)} onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder} onRemove={removePlaceholder}
onReplace={replacePlaceholder} onReplace={replacePlaceholder}
onAddField={addUndefinedField} onAddField={addUndefinedField}
localFields={localFieldOptions} localFields={localFieldOptions}
globalFields={globalFieldOptions} globalFields={globalFieldOptions} />
/>
</div> </div>);
);
} }
function mapResolvedAttachmentsToPreviewBoxes( function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[], rules: CampaignAttachmentPreviewRule[],
loading: boolean, loading: boolean,
error: string, error: string,
draft: Record<string, unknown> draft: Record<string, unknown>)
): CampaignMessagePreviewAttachment[] { : CampaignMessagePreviewAttachment[] {
if (loading) { if (loading) {
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }]; return [{
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
}];
} }
if (error) { if (error) {
return [{ filename: "Attachment preview unavailable", detail: error }]; return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
} }
return rules.flatMap((rule) => { return rules.flatMap((rule) => {
const zipProtection = zipProtectionForRule(rule, draft); const zipProtection = zipProtectionForRule(rule, draft);
const detailParts = [ const detailParts = [
rule.source === "global" ? "Global" : "Recipient", rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
rule.label, rule.label,
rule.required ? "required" : "optional", rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
rule.pattern rule.pattern].
].filter(Boolean); filter(Boolean);
const detail = detailParts.join(" · "); const detail = detailParts.join(" · ");
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
if (rule.matches.length > 0) { if (rule.matches.length > 0) {
return rule.matches.map((match) => ({ return rule.matches.map((match) => ({
filename: match.filename || match.display_path, filename: match.filename || match.display_path,
label: rule.label, label: rule.label,
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`, detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
contentType: match.content_type, contentType: match.content_type,
sizeBytes: match.size_bytes, sizeBytes: match.size_bytes,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null, archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null, archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
protected: zipProtection.protected, protected: zipProtection.protected,
protectionNote: zipProtection.note protectionNote: zipProtection.note
})); }));
} }
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
return [{ return [{
filename: `No file matched ${rule.pattern || "attachment pattern"}`, filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
label: rule.label, label: rule.label,
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`, detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null, archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null, archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
protected: zipProtection.protected, protected: zipProtection.protected,
protectionNote: zipProtection.note protectionNote: zipProtection.note
}]; }];
@@ -422,22 +444,22 @@ function mapResolvedAttachmentsToPreviewBoxes(
function templatePreviewMetaItems(context: Record<string, string>) { function templatePreviewMetaItems(context: Record<string, string>) {
return [ return [
{ label: "From", value: context["local:from"] || null }, { label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
{ label: "To", value: context["local:all_to"] || null }, { label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
{ label: "Reply-To", value: context["local:all_reply_to"] || null }, { label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
{ label: "Cc", value: context["local:all_cc"] || null }, { label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || null },
{ label: "Bcc", value: context["local:all_bcc"] || null } { label: "i18n:govoplan-campaign.bcc.8431acad", value: context["local:all_bcc"] || null }];
];
} }
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): { protected: boolean; note: string | null } { function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): {protected: boolean;note: string | null;} {
if (!rule.zip_included) return { protected: false, note: null }; if (!rule.zip_included) return { protected: false, note: null };
const zipConfig = asRecord(asRecord(draft.attachments).zip); const zipConfig = asRecord(asRecord(draft.attachments).zip);
const archives = asArray(zipConfig.archives).map(asRecord); const archives = asArray(zipConfig.archives).map(asRecord);
const archive = archives.find((item) => { const archive = archives.find((item) => {
const id = getText(item, "id"); const id = getText(item, "id");
const name = getText(item, "name", getText(item, "filename_template")); const name = getText(item, "name", getText(item, "filename_template"));
return (rule.zip_archive_id && id === rule.zip_archive_id) || (rule.zip_filename && name === rule.zip_filename); return rule.zip_archive_id && id === rule.zip_archive_id || rule.zip_filename && name === rule.zip_filename;
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0]; }) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
if (!archive) return { protected: false, note: null }; if (!archive) return { protected: false, note: null };
const legacyMode = getText(archive, "password_mode"); const legacyMode = getText(archive, "password_mode");
@@ -445,13 +467,18 @@ function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record
if (!protectedArchive) return { protected: false, note: null }; if (!protectedArchive) return { protected: false, note: null };
const field = getText(archive, "password_field"); const field = getText(archive, "password_field");
const scope = getText(archive, "password_scope") === "global" ? "global" : "local"; const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES"; const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
const source = field ? `${humanizeScope(scope)} field "${field}"` : ""; const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") }; return {
protected: true,
note: source ?
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
};
} }
function humanizeScope(scope: string): string { function humanizeScope(scope: string): string {
return scope === "global" ? "Global" : "Local"; return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
} }
function recipientLabel(entry: Record<string, unknown>, index: number): string { function recipientLabel(entry: Record<string, unknown>, index: number): string {
@@ -460,7 +487,7 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
if (name && email) return `${name} <${email}>`; if (name && email) return `${name} <${email}>`;
if (name) return name; if (name) return name;
if (email) return email; if (email) return email;
return `Recipient ${index + 1}`; return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
} }
function normalizeTemplateBodyMode(value: string): TemplateBodyMode { function normalizeTemplateBodyMode(value: string): TemplateBodyMode {

View File

@@ -8,7 +8,7 @@ import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn
import { ToggleSwitch } from "@govoplan/core-webui"; import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor"; import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments"; import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView"; import { asRecord } from "../utils/campaignView";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders"; import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
@@ -57,7 +57,7 @@ export default function AttachmentRulesOverlay({
campaignId, campaignId,
disabled = false, disabled = false,
buttonLabel, buttonLabel,
emptyText = "No attachment files or matching rules configured yet.", emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [], basePaths = [],
zipConfig = { enabled: false, archives: [] }, zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false, filesModuleInstalled = false,
@@ -91,13 +91,13 @@ export default function AttachmentRulesOverlay({
className="attachment-rules-modal" className="attachment-rules-modal"
bodyClassName="attachment-rules-body" bodyClassName="attachment-rules-body"
onClose={cancelOverlay} onClose={cancelOverlay}
footer={( footer={
<> <>
<Button onClick={cancelOverlay}>Cancel</Button> <Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button> <Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
</> </>
)} }>
>
<AttachmentRulesTable <AttachmentRulesTable
rules={draftRules} rules={draftRules}
settings={settings} settings={settings}
@@ -109,20 +109,20 @@ export default function AttachmentRulesOverlay({
filesModuleInstalled={filesModuleInstalled} filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext} previewContext={previewContext}
activeChooserRuleIndex={null} activeChooserRuleIndex={null}
onChange={setDraftRules} onChange={setDraftRules} />
/>
</Dialog>, </Dialog>,
document.body document.body
) : null; ) : null;
return ( return (
<> <>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}> <Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={i18nMessage("i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9", { value0: summary.direct, value1: summary.rules })}>
{label} {label}
</Button> </Button>
{dialog} {dialog}
</> </>);
);
} }
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] { function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
@@ -141,8 +141,8 @@ export function AttachmentRulesTable({
<div className="attachment-rules-main"> <div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} /> <AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
</div> </div>
</div> </div>);
);
} }
export function AttachmentRulesDataGrid({ export function AttachmentRulesDataGrid({
@@ -150,7 +150,7 @@ export function AttachmentRulesDataGrid({
settings, settings,
campaignId, campaignId,
disabled = false, disabled = false,
emptyText = "No attachment files or matching rules configured yet.", emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
basePaths = [], basePaths = [],
id = "attachment-rules", id = "attachment-rules",
activeChooserRuleIndex = null, activeChooserRuleIndex = null,
@@ -196,10 +196,10 @@ export function AttachmentRulesDataGrid({
const currentPath = getText(rule, "base_dir"); const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id"); const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath); const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId) const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
?? basePaths.find((item) => item.path === currentPath) basePaths.find((item) => item.path === currentPath) ?? (
?? (!explicitlyReferenced ? basePaths[0] : undefined) !explicitlyReferenced ? basePaths[0] : undefined) ??
?? null; null;
if (!basePath) return; if (!basePath) return;
setFileChooser({ ruleIndex, basePath }); setFileChooser({ ruleIndex, basePath });
} }
@@ -225,30 +225,30 @@ export function AttachmentRulesDataGrid({
rows={rules} rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })} columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)} getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText} emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />} emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
className="attachment-rules-table-wrap attachment-rules-table" className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
/>
{fileChooser && ManagedFileChooser && ( {fileChooser && ManagedFileChooser &&
<ManagedFileChooser <ManagedFileChooser
open open
settings={settings} settings={settings}
storageScope={campaignId} storageScope={campaignId}
linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }} linkTarget={{ type: "campaign", id: campaignId, label: "campaign" }}
mode="attachment" mode="attachment"
source={fileChooser.basePath?.source} source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."} basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")} initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`} rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext} previewContext={previewContext}
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)} renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
onClose={() => setFileChooser(null)} onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment} onSelectAttachment={selectAttachment} />
/>
)} }
</> </>);
);
} }
type AttachmentRuleColumnContext = { type AttachmentRuleColumnContext = {
@@ -267,122 +267,154 @@ type AttachmentRuleColumnContext = {
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] { function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [ return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") }, { id: "label", header: "i18n:govoplan-campaign.label.74341e3c", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="i18n:govoplan-campaign.attachment_label.a340f70e" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{ {
id: "base_path", id: "base_path",
header: "Base path", header: "i18n:govoplan-campaign.base_path.6a4867ca",
width: 250, width: 250,
sortable: true, sortable: true,
filterable: true, filterable: true,
columnType: "from-list", columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) }, list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => { render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir"); const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id"); const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue); const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
?? basePaths.find((basePath) => basePath.path === currentBasePathValue) basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
?? (!explicitlyReferenced ? basePaths[0] : undefined); !explicitlyReferenced ? basePaths[0] : undefined);
return ( return (
<select <select
value={selectedBasePath?.id ?? ""} value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0} disabled={disabled || basePaths.length === 0}
onChange={(event) => { onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value); const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path }); if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}} }}>
>
{!selectedBasePath && ( {!selectedBasePath &&
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option> <option value="" disabled>{basePaths.length === 0 ? "i18n:govoplan-campaign.no_individual_attachment_source.6b54176a" : "i18n:govoplan-campaign.unavailable_attachment_source.7ff1096e"}</option>
)} }
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)} {basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select> </select>);
);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
}, },
{ value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
id: "file_filter", },
header: "File / pattern", {
width: "minmax(260px, 1fr)", id: "file_filter",
resizable: true, header: "i18n:govoplan-campaign.file_pattern.86320b07",
sortable: true, width: "minmax(260px, 1fr)",
filterable: true, resizable: true,
render: (rule, index) => ( sortable: true,
<div className="field-with-action split-field-action"> filterable: true,
render: (rule, index) =>
<div className="field-with-action split-field-action">
<input <input
className="chooser-display-input" className="chooser-display-input"
value={getText(rule, "file_filter")} value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0} disabled={disabled || basePaths.length === 0}
readOnly={filesModuleInstalled} readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined} tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"} placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
onChange={(event) => { onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value }); if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}} }}
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)} onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) { if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
openFileChooser(index); openFileChooser(index);
} }
}} }} />
/>
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>} {filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
</div> </div>,
),
value: (rule) => getText(rule, "file_filter") value: (rule) => getText(rule, "file_filter")
},
{
id: "message_filename_template",
header: "i18n:govoplan-campaign.message_filename_template.0b7ac934",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "message_filename_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_filename.9b805045"
onChange={(event) => patchRule(index, { message_filename_template: event.target.value })} />,
value: (rule) => getText(rule, "message_filename_template")
},
{
id: "zip_entry_name_template",
header: "i18n:govoplan-campaign.zip_entry_name_template.83772a73",
width: 230,
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) =>
<input
value={getText(rule, "zip_entry_name_template")}
disabled={disabled}
placeholder="i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4"
onChange={(event) => patchRule(index, { zip_entry_name_template: event.target.value })} />,
value: (rule) => getText(rule, "zip_entry_name_template")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "i18n:govoplan-campaign.exclude_from_zip.a6422da1" },
{ value: "inherit", label: "i18n:govoplan-campaign.campaign_standard.c88ce44c" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))]
}, },
...(zipConfig.enabled ? [{ render: (rule: AttachmentRule, index: number) => {
id: "zip", const selection = attachmentRuleZipSelection(rule);
header: "ZIP archive", return (
width: 240, <select
sortable: true, value={selection}
filterable: true, disabled={disabled || zipConfig.archives.length === 0}
columnType: "from-list", aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
list: { onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
options: [
{ value: "exclude", label: "Exclude from ZIP" }, <option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
{ value: "inherit", label: "Campaign standard" }, <option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
>
<option value="exclude">Exclude from ZIP</option>
<option value="inherit">Campaign standard</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)} {zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select> </select>);
);
}, },
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule) value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []), } satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" }, { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "i18n:govoplan-campaign.required.eed6bfb4" }, { value: "optional", label: "i18n:govoplan-campaign.optional.0c6c4102" }] }, render: (rule, index) => <ToggleSwitch label="i18n:govoplan-campaign.required.eed6bfb4" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180, width: 180,
sticky: "end", sticky: "end",
render: (_rule, index) => ( render: (_rule, index) =>
<DataGridRowActions <DataGridRowActions
disabled={disabled} disabled={disabled}
onAddBelow={() => addRule(index)} onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)} onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined} onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined} onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below" addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
removeLabel="Remove attachment" removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
moveUpLabel="Move attachment up" moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
moveDownLabel="Move attachment down" moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
/>
)
} }];
];
} }

View File

@@ -7,15 +7,15 @@ import {
updateCampaignOwner, updateCampaignOwner,
upsertCampaignShare, upsertCampaignShare,
type CampaignShare, type CampaignShare,
type CampaignShareTargets type CampaignShareTargets } from
} from "../../../api/campaigns"; "../../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui"; import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group"; type TargetType = "user" | "group";
export default function CampaignAccessCard({ export default function CampaignAccessCard({
@@ -23,12 +23,12 @@ export default function CampaignAccessCard({
campaign, campaign,
onChanged, onChanged,
onError onError
}: {
settings: ApiSettings;
campaign: CampaignListItem;
onChanged: () => Promise<void>;
onError: (message: string) => void;
}) { }: {settings: ApiSettings;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
const [available, setAvailable] = useState<boolean | null>(null); const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] }); const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]); const [shares, setShares] = useState<CampaignShare[]>([]);
@@ -41,20 +41,33 @@ export default function CampaignAccessCard({
const [shareTargetId, setShareTargetId] = useState(""); const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read"); const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const currentOwnerType: TargetType = campaign.owner_group_id ? "group" : "user";
const currentOwnerId = campaign.owner_group_id || campaign.owner_user_id || "";
const ownerDirty = ownerOpen && (ownerType !== currentOwnerType || ownerId !== currentOwnerId);
const shareDirty = shareOpen && (shareType !== "user" || Boolean(shareTargetId) || sharePermission !== "read");
useUnsavedDraftGuard({
dirty: ownerDirty || shareDirty,
onSave: saveActiveDialog,
onDiscard: () => {
closeOwnerDialog();
closeShareDialog();
}
});
async function load() { async function load() {
try { try {
const [nextTargets, nextShares] = await Promise.all([ const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id), getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id) getCampaignShares(settings, campaign.id)]
]); );
setTargets(nextTargets); setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at)); setShares(nextShares.filter((item) => !item.revoked_at));
setAvailable(true); setAvailable(true);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
if (message.startsWith("403 ")) setAvailable(false); if (message.startsWith("403 ")) setAvailable(false);else
else onError(message); onError(message);
} }
} }
@@ -67,38 +80,59 @@ export default function CampaignAccessCard({
const targetOptions = ownerType === "user" ? targets.users : targets.groups; const targetOptions = ownerType === "user" ? targets.users : targets.groups;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups; const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const targetMap = useMemo(() => new Map([ const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const), ...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const) ...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
]), [targets]); ), [targets]);
const ownerLabel = campaign.owner_group_id const ownerLabel = campaign.owner_group_id ?
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner" targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner"; targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
async function saveOwner() { function closeOwnerDialog() {
if (!ownerId) return; setOwnerOpen(false);
setOwnerType(currentOwnerType);
setOwnerId(currentOwnerId);
}
function closeShareDialog() {
setShareOpen(false);
setShareType("user");
setShareTargetId("");
setSharePermission("read");
}
async function saveActiveDialog(): Promise<boolean> {
if (ownerDirty) return saveOwner();
if (shareDirty) return saveShare();
return true;
}
async function saveOwner(): Promise<boolean> {
if (!ownerId) return false;
setBusy(true); setBusy(true);
try { try {
await updateCampaignOwner(settings, campaign.id, ownerType === "user" await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
? { owner_user_id: ownerId, owner_group_id: null } { owner_user_id: ownerId, owner_group_id: null } :
: { owner_user_id: null, owner_group_id: ownerId }); { owner_user_id: null, owner_group_id: ownerId });
setOwnerOpen(false); setOwnerOpen(false);
await onChanged(); await onChanged();
await load(); await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); } return true;
finally { setBusy(false); } } catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
} }
async function saveShare() { async function saveShare(): Promise<boolean> {
if (!shareTargetId) return; if (!shareTargetId) return false;
setBusy(true); setBusy(true);
try { try {
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission }); await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
setShareOpen(false); setShareOpen(false);
setShareTargetId(""); setShareTargetId("");
await load(); await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); } return true;
finally { setBusy(false); } } catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
{setBusy(false);}
} }
async function revoke() { async function revoke() {
@@ -108,52 +142,53 @@ export default function CampaignAccessCard({
await revokeCampaignShare(settings, campaign.id, revokeTarget.id); await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null); setRevokeTarget(null);
await load(); await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); } } catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
finally { setBusy(false); } {setBusy(false);}
} }
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [ const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{ {
id: "target", id: "target",
header: "Shared with", header: "i18n:govoplan-campaign.shared_with.6203f449",
width: "minmax(220px, 1fr)", width: "minmax(220px, 1fr)",
minWidth: 190, minWidth: 190,
resizable: true, resizable: true,
sticky: "start", sticky: "start",
sortable: true, sortable: true,
filterable: true, filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id, value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => { render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`); const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>; return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
} }
}, },
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> }, { id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> } { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
], [targetMap]); [targetMap]);
if (available === false) return null; if (available === false) return null;
return ( return (
<> <>
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}> <Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.change_owner.d3ce16a8</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button></div>}>
<p><strong>Owner:</strong> {ownerLabel}</p> <p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p> <p className="muted small-note">i18n:govoplan-campaign.permissions_define_what_a_role_may_do_ownership_.9f8baaa2</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} /> <DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
</Card> </Card>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}> <Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField> <FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
</Dialog> </Dialog>
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}> <Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField> <FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField> <FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
</Dialog> </Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} /> <ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</> </>);
);
} }

View File

@@ -9,16 +9,16 @@ import {
unlockCampaignVersionUserLock, unlockCampaignVersionUserLock,
unlockCampaignVersionValidation, unlockCampaignVersionValidation,
type CampaignVersionDetail, type CampaignVersionDetail,
type CampaignVersionListItem, type CampaignVersionListItem } from
} from "../../../api/campaigns"; "../../../api/campaigns";
import { import {
canUnlockValidationVersion, canUnlockValidationVersion,
formatDateTime, formatDateTime,
isFinalLockedVersion, isFinalLockedVersion,
isHistoricalCampaignVersion, isHistoricalCampaignVersion,
isPermanentUserLockedVersion, isPermanentUserLockedVersion,
isTemporaryUserLockedVersion, isTemporaryUserLockedVersion } from
} from "../utils/campaignView"; "../utils/campaignView";
type LockedVersionNoticeProps = { type LockedVersionNoticeProps = {
settings: ApiSettings; settings: ApiSettings;
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
validationLock, validationLock,
temporaryUserLock, temporaryUserLock,
permanentUserLock, permanentUserLock,
finalLock, finalLock
}); });
async function runAction(action: Exclude<ConfirmAction, null>) { async function runAction(action: Exclude<ConfirmAction, null>) {
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try { try {
if (action === "unlock-validation") { if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id); await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated."); setLocalMessage("i18n:govoplan-campaign.validation_lock_removed_validation_build_and_gen.ea26975f");
} else if (action === "unlock-user") { } else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id); await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again."); setLocalMessage("i18n:govoplan-campaign.temporary_user_lock_removed_the_version_is_edita.0ca4c587");
} else { } else {
await lockCampaignVersionPermanently(settings, campaignId, version.id); await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy."); setLocalMessage("i18n:govoplan-campaign.version_permanently_locked_future_changes_requir.fd718a84");
} }
await reload(); await reload();
} catch (err) { } catch (err) {
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
try { try {
const result = await forkCampaignVersion(settings, campaignId, version.id, { const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual", current_flow: "manual",
current_step: version.current_step ?? null, current_step: version.current_step ?? null
}); });
setLocalMessage(`Created editable version #${result.version.version_number}.`); setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload(); await reload();
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
{localError && <span className="locked-version-error"> {localError}</span>} {localError && <span className="locked-version-error"> {localError}</span>}
</div> </div>
<div className="button-row compact-actions locked-version-actions"> <div className="button-row compact-actions locked-version-actions">
{validationLock && ( {validationLock &&
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}> <Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"} {busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock_validation.f01952b6"}
</Button> </Button>
)} }
{temporaryUserLock && ( {temporaryUserLock &&
<> <>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}> <Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"} {busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
</Button> </Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}> <Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently i18n:govoplan-campaign.lock_permanently.cc0ce9e7
</Button> </Button>
</> </>
)} }
{canCreateEditableCopy && ( {canCreateEditableCopy &&
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}> <Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"} {busy ? "i18n:govoplan-campaign.creating_copy.4247f587" : "i18n:govoplan-campaign.create_editable_copy.92d6d91b"}
</Button> </Button>
)} }
</div> </div>
<ConfirmDialog <ConfirmDialog
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
const action = confirmAction; const action = confirmAction;
setConfirmAction(null); setConfirmAction(null);
if (action) void runAction(action); if (action) void runAction(action);
}} }} />
/>
</DismissibleAlert> </DismissibleAlert>);
);
} }
type LockFlags = { type LockFlags = {
@@ -154,82 +154,82 @@ type LockFlags = {
}; };
function lockPresentation( function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null, version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags, flags: LockFlags)
): { kind: string; title: string; description: string; info: string } { : {kind: string;title: string;description: string;info: string;} {
if (flags.historicalVersion) { if (flags.historicalVersion) {
return { return {
kind: "historical", kind: "historical",
title: "Historical version.", title: "i18n:govoplan-campaign.historical_version.adac5dba",
description: "This version is review-only. Only the campaign's current working version may be edited in place.", description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
info: `Version #${version?.version_number ?? "—"}.`, info: `Version #${version?.version_number ?? "—"}.`
}; };
} }
if (flags.temporaryUserLock) { if (flags.temporaryUserLock) {
return { return {
kind: "temporary-user", kind: "temporary-user",
title: "Temporarily locked version.", title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.", description: "i18n:govoplan-campaign.campaign_data_is_read_only_but_an_authorized_use.f2e513b1",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "", info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : ""
}; };
} }
if (flags.permanentUserLock) { if (flags.permanentUserLock) {
return { return {
kind: "permanent-user", kind: "permanent-user",
title: "Permanently locked version.", title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.", description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
info: version?.user_locked_at || version?.published_at info: version?.user_locked_at || version?.published_at ?
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
: "", ""
}; };
} }
if (flags.validationLock) { if (flags.validationLock) {
return { return {
kind: "validation", kind: "validation",
title: "Temporarily locked after validation.", title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.", description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "", info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
}; };
} }
if (flags.finalLock) { if (flags.finalLock) {
return { return {
kind: "delivery", kind: "delivery",
title: "Permanently locked delivery snapshot.", title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.", description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "", info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
}; };
} }
return { return {
kind: "other", kind: "other",
title: "Locked version.", title: "i18n:govoplan-campaign.locked_version.e755b179",
description: "This version is read-only. Create an editable copy to continue working.", description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "", info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
}; };
} }
function confirmDialogTitle(action: ConfirmAction): string { function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?"; if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
if (action === "unlock-user") return "Unlock temporary lock?"; if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
if (action === "permanent") return "Lock permanently?"; if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
return "Confirm action"; return "i18n:govoplan-campaign.confirm_action.50649c5c";
} }
function confirmDialogMessage(action: ConfirmAction): string { function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") { if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version."; return "i18n:govoplan-campaign.this_makes_the_version_editable_and_clears_valid.89a6cd1b";
} }
if (action === "unlock-user") { if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed."; return "i18n:govoplan-campaign.this_removes_the_reversible_user_lock_campaign_d.fcf8cb1d";
} }
if (action === "permanent") { if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy."; return "i18n:govoplan-campaign.this_lock_cannot_be_removed_by_any_role_the_vers.3054b076";
} }
return "Continue?"; return "i18n:govoplan-campaign.continue.4e6302ed";
} }
function confirmDialogLabel(action: ConfirmAction): string { function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation"; if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
if (action === "unlock-user") return "Unlock"; if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
if (action === "permanent") return "Lock permanently"; if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
return "Confirm"; return "i18n:govoplan-campaign.confirm.04a21221";
} }

View File

@@ -41,7 +41,7 @@ export type CampaignMessagePreviewOverlayProps = {
}; };
export default function CampaignMessagePreviewOverlay({ export default function CampaignMessagePreviewOverlay({
title = "Message preview", title = "i18n:govoplan-campaign.message_preview.58de1450",
subject, subject,
bodyMode = "text", bodyMode = "text",
text, text,
@@ -51,12 +51,12 @@ export default function CampaignMessagePreviewOverlay({
metaItems = [], metaItems = [],
attachments = [], attachments = [],
raw, raw,
rawLabel = "Raw MIME", rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
navigation, navigation,
closeLabel = "Close", closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
onClose onClose
}: CampaignMessagePreviewOverlayProps) { }: CampaignMessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject"; const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value })); const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
useEffect(() => { useEffect(() => {
@@ -95,23 +95,23 @@ export default function CampaignMessagePreviewOverlay({
<button className="modal-close" onClick={onClose}>×</button> <button className="modal-close" onClick={onClose}>×</button>
</header> </header>
<div className="modal-body"> <div className="modal-body">
{(recipientLabel || recipientNote || navigation) && ( {(recipientLabel || recipientNote || navigation) &&
<div className="template-preview-toolbar"> <div className="template-preview-toolbar">
<div> <div>
{recipientLabel && <strong>{recipientLabel}</strong>} {recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>} {recipientNote && <p className="muted small-note">{recipientNote}</p>}
</div> </div>
{navigation && ( {navigation &&
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation"> <div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button> <button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button> <button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span> <span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button> <button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button> <button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
</div> </div>
)} }
</div> </div>
)} }
<MessageDisplayPanel <MessageDisplayPanel
title={shownSubject} title={shownSubject}
@@ -121,20 +121,20 @@ export default function CampaignMessagePreviewOverlay({
preferredBodyMode={bodyMode} preferredBodyMode={bodyMode}
deriveTextFromHtml={false} deriveTextFromHtml={false}
attachments={attachments} attachments={attachments}
emptyText="No message content is available." emptyText="i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6" />
/>
{raw && ( {raw &&
<details className="message-preview-raw"> <details className="message-preview-raw">
<summary>{rawLabel}</summary> <summary>{rawLabel}</summary>
<pre className="mock-message-raw">{raw}</pre> <pre className="mock-message-raw">{raw}</pre>
</details> </details>
)} }
</div> </div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer> <footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
</div> </div>
</div> </div>);
);
} }
function isEditableTarget(target: EventTarget | null): boolean { function isEditableTarget(target: EventTarget | null): boolean {
@@ -146,4 +146,4 @@ function isEditableTarget(target: EventTarget | null): boolean {
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment; export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem; export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation; export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps; export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;

View File

@@ -9,14 +9,14 @@ import {
removePlaceholderFromText, removePlaceholderFromText,
replacePlaceholderInText, replacePlaceholderInText,
type TemplateNamespace, type TemplateNamespace,
type UndefinedPlaceholder type UndefinedPlaceholder } from
} from "../utils/templatePlaceholders"; "../utils/templatePlaceholders";
import { import {
TemplateFieldChipList, TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList, UndefinedPlaceholderList,
type TemplateFieldOption type TemplateFieldOption } from
} from "./TemplatePlaceholderControls"; "./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({ export default function TemplateExpressionEditorDialog({
open, open,
@@ -26,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
globalFields, globalFields,
placeholder, placeholder,
description, description,
saveLabel = "Save", saveLabel = "i18n:govoplan-campaign.save.efc007a3",
validate, validate,
onCancel, onCancel,
onSave, onSave,
onAddField, onAddField,
canAddField = () => true canAddField = () => true
}: {
open: boolean;
title: string;
value: string;
localFields: TemplateFieldOption[];
globalFields: TemplateFieldOption[];
placeholder?: string;
description?: string;
saveLabel?: string;
validate?: (value: string) => string;
onCancel: () => void;
onSave: (value: string) => void;
onAddField: (name: string) => void;
canAddField?: (name: string) => boolean;
}) { }: {open: boolean;title: string;value: string;localFields: TemplateFieldOption[];globalFields: TemplateFieldOption[];placeholder?: string;description?: string;saveLabel?: string;validate?: (value: string) => string;onCancel: () => void;onSave: (value: string) => void;onAddField: (name: string) => void;canAddField?: (name: string) => boolean;}) {
const [draftValue, setDraftValue] = useState(value); const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null); const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -113,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
<Dialog <Dialog
open={open && !undefinedDialog} open={open && !undefinedDialog}
title={title} title={title}
closeLabel="Cancel filename changes" closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
className="template-expression-dialog" className="template-expression-dialog"
headerClassName="template-expression-dialog-header" headerClassName="template-expression-dialog-header"
bodyClassName="template-expression-dialog-body" bodyClassName="template-expression-dialog-body"
footerClassName="template-expression-dialog-footer" footerClassName="template-expression-dialog-footer"
onClose={onCancel} onClose={onCancel}
footer={( footer={
<> <>
<Button onClick={onCancel}>Cancel</Button> <Button onClick={onCancel}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button> <Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
</> </>
)} }>
>
{description && <p className="muted template-expression-description">{description}</p>} {description && <p className="muted template-expression-description">{description}</p>}
<label className="template-expression-value-field"> <label className="template-expression-value-field">
<span>Archive filename</span> <span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
<input <input
ref={inputRef} ref={inputRef}
value={draftValue} value={draftValue}
@@ -140,47 +140,47 @@ export default function TemplateExpressionEditorDialog({
event.preventDefault(); event.preventDefault();
requestSave(); requestSave();
} }
}} }} />
/>
</label> </label>
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>} {validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
<h3 className="section-mini-heading">Recipient fields</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p> <p className="muted small-note">i18n:govoplan-campaign.select_a_field_to_insert_it_at_the_current_curso.1d1b51be <code>i18n:govoplan-campaign.to_2.8c1db0c7</code> or <code>i18n:govoplan-campaign.to_2_email.5ad61c1a</code> i18n:govoplan-campaign.for_another_single_value.ce57d76a</p>
<TemplateFieldChipList <TemplateFieldChipList
namespace="local" namespace="local"
fields={localFields} fields={localFields}
usedPlaceholders={usedPlaceholders} usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available." empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
onInsert={insertPlaceholder} onInsert={insertPlaceholder} />
/>
<h3 className="section-mini-heading">Campaign fields</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
<TemplateFieldChipList <TemplateFieldChipList
namespace="global" namespace="global"
fields={globalFields} fields={globalFields}
usedPlaceholders={usedPlaceholders} usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available." empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
onInsert={insertPlaceholder} onInsert={insertPlaceholder} />
/>
<h3 className="section-mini-heading">Used, but undefined</h3> <h3 className="section-mini-heading">i18n:govoplan-campaign.used_but_undefined.3b829043</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} /> <UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog> </Dialog>
<UndefinedPlaceholderDecisionDialog <UndefinedPlaceholderDecisionDialog
field={undefinedDialog} field={undefinedDialog}
contextLabel="archive filename" contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
removeLabel="Remove from filename" removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
onCancel={() => setUndefinedDialog(null)} onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined} onRemove={removeUndefined}
onReplace={replaceUndefined} onReplace={replaceUndefined}
onAddField={addUndefined} onAddField={addUndefined}
localFields={localFields} localFields={localFields}
globalFields={globalFields} globalFields={globalFields}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
/>
</>, </>,
document.body document.body
); );
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui"; import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui"; import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders"; import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = { export type TemplateFieldOption = {
@@ -16,14 +16,14 @@ export function TemplateFieldChipList({
empty, empty,
disabled = false, disabled = false,
onInsert onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) { }: {namespace: TemplateNamespace;fields: TemplateFieldOption[];usedPlaceholders?: TemplatePlaceholder[];empty: string;disabled?: boolean;onInsert: (namespace: TemplateNamespace, name: string) => void;}) {
if (fields.length === 0) return <p className="muted">{empty}</p>; if (fields.length === 0) return <p className="muted">{empty}</p>;
return ( return (
<div className="field-chip-list"> <div className="field-chip-list">
@@ -35,46 +35,46 @@ export function TemplateFieldChipList({
className={`field-chip field-chip-button ${used ? "used" : ""}`} className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`} key={`${namespace}:${field.name}`}
disabled={disabled} disabled={disabled}
onClick={() => onInsert(namespace, field.name)} onClick={() => onInsert(namespace, field.name)}>
>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span> <span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button> </button>);
);
})} })}
</div> </div>);
);
} }
export function UndefinedPlaceholderList({ export function UndefinedPlaceholderList({
items, items,
empty = "No undefined placeholders detected.", empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
onSelect onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) { }: {items: UndefinedPlaceholder[];empty?: string;onSelect: (item: UndefinedPlaceholder) => void;}) {
if (items.length === 0) return <p className="muted">{empty}</p>; if (items.length === 0) return <p className="muted">{empty}</p>;
return ( return (
<div className="field-chip-list"> <div className="field-chip-list">
{items.map((item) => ( {items.map((item) =>
<button <button
type="button" type="button"
className="field-chip field-chip-button undefined" className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`} key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)} onClick={() => onSelect(item)}>
>
{item.display} {item.display}
</button> </button>
))} )}
</div> </div>);
);
} }
export function UndefinedPlaceholderDecisionDialog({ export function UndefinedPlaceholderDecisionDialog({
field, field,
contextLabel = "template", contextLabel = "template",
removeLabel = "Remove placeholder", removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
localFields = [], localFields = [],
globalFields = [], globalFields = [],
onCancel, onCancel,
@@ -82,23 +82,23 @@ export function UndefinedPlaceholderDecisionDialog({
onReplace, onReplace,
onAddField, onAddField,
addDisabled = false addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
localFields?: TemplateFieldOption[];
globalFields?: TemplateFieldOption[];
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) { }: {field: UndefinedPlaceholder | null;contextLabel?: string;removeLabel?: string;localFields?: TemplateFieldOption[];globalFields?: TemplateFieldOption[];onCancel: () => void;onRemove: (field: UndefinedPlaceholder) => void;onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;onAddField: (field: UndefinedPlaceholder) => void;addDisabled?: boolean;}) {
const replacementOptions = useMemo( const replacementOptions = useMemo(
() => [ () => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })), ...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item })) ...globalFields.map((item) => ({ namespace: "global" as const, ...item }))],
],
[globalFields, localFields] [globalFields, localFields]
); );
const [replacement, setReplacement] = useState(""); const [replacement, setReplacement] = useState("");
@@ -121,42 +121,42 @@ export function UndefinedPlaceholderDecisionDialog({
return ( return (
<Dialog <Dialog
open={Boolean(field)} open={Boolean(field)}
title="Undefined field reference" title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
closeLabel="Return to editing" closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
className="template-action-dialog" className="template-action-dialog"
onClose={onCancel} onClose={onCancel}
footer={field ? ( footer={field ?
<> <>
<Button onClick={onCancel}>Continue editing</Button> <Button onClick={onCancel}>i18n:govoplan-campaign.continue_editing.b101465f</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button> <Button onClick={() => onRemove(field)}>{removeLabel}</Button>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>} {onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>i18n:govoplan-campaign.replace.a7cf7b25</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button> <Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>i18n:govoplan-campaign.add_field.039c6315</Button>
</> </> :
) : undefined} undefined}>
>
{field && ( {field &&
<> <>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p> <p>i18n:govoplan-campaign.the.93ef0dd8 {contextLabel} uses <code>{i18nMessage("i18n:govoplan-campaign.value.91317e63", { value0: field.raw })}</code>i18n:govoplan-campaign.but_it_cannot_be_matched_to_a_known_field.e75c4dde</p>
{field.reason === "invalid-namespace" && ( {field.reason === "invalid-namespace" &&
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert> <DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)} }
{field.reason === "missing-field" && ( {field.reason === "missing-field" &&
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p> <p className="muted">i18n:govoplan-campaign.you_can_add.66093419 <strong>{field.name}</strong> i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8</p>
)} }
{onReplace && replacementOptions.length > 0 && ( {onReplace && replacementOptions.length > 0 &&
<label className="template-replacement-field"> <label className="template-replacement-field">
<span>Replace by existing field</span> <span>i18n:govoplan-campaign.replace_by_existing_field.ee3dc0eb</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}> <select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) => ( {replacementOptions.map((option) =>
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}> <option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name} {option.namespace}:{option.label || option.name}
</option> </option>
))} )}
</select> </select>
</label> </label>
)} }
</> </>
)} }
</Dialog> </Dialog>);
);
} }

View File

@@ -1,7 +1,7 @@
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react"; import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns"; import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView"; import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = { type VersionLineProps = {
@@ -13,8 +13,7 @@ type VersionLineProps = {
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) { export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useGuardedNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0)); const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1; const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null; const first = currentIndex > 0 ? sorted[0] : null;
@@ -25,7 +24,7 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
function openVersion(target: CampaignVersionListItem) { function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id); const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl)); navigate(targetUrl);
} }
return ( return (
@@ -33,32 +32,32 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
<VersionArrow <VersionArrow
icon="first" icon="first"
target={first} target={first}
fallbackLabel="Already at first version" fallbackLabel="i18n:govoplan-campaign.already_at_first_version.b90fad64"
onOpen={openVersion} onOpen={openVersion} />
/>
<VersionArrow <VersionArrow
icon="previous" icon="previous"
target={previous} target={previous}
fallbackLabel="No older version" fallbackLabel="i18n:govoplan-campaign.no_older_version.4355dee8"
onOpen={openVersion} onOpen={openVersion} />
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span> <span>i18n:govoplan-campaign.version.2da600bf {version ? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: version.version_number }) : "—"}</span>
<VersionArrow <VersionArrow
icon="next" icon="next"
target={next} target={next}
fallbackLabel="No newer version" fallbackLabel="i18n:govoplan-campaign.no_newer_version.b210a41c"
onOpen={openVersion} onOpen={openVersion} />
/>
<VersionArrow <VersionArrow
icon="latest" icon="latest"
target={latest} target={latest}
fallbackLabel="Already at latest version" fallbackLabel="i18n:govoplan-campaign.already_at_latest_version.7533bd9d"
onOpen={openVersion} onOpen={openVersion} />
/>
<span className="version-line-separator">·</span> <span className="version-line-separator">·</span>
<span>{suffix}</span> <span>{suffix}</span>
</p> </p>);
);
} }
type VersionArrowProps = { type VersionArrowProps = {
@@ -76,8 +75,8 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
return ( return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}> <span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
</span> </span>);
);
} }
return ( return (
@@ -86,11 +85,11 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
className="version-arrow" className="version-arrow"
onClick={() => onOpen(target)} onClick={() => onOpen(target)}
title={actionLabel} title={actionLabel}
aria-label={actionLabel} aria-label={actionLabel}>
>
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
</button> </button>);
);
} }
function getVersionIcon(icon: VersionArrowProps["icon"]) { function getVersionIcon(icon: VersionArrowProps["icon"]) {
@@ -110,13 +109,13 @@ function getVersionActionLabel(icon: VersionArrowProps["icon"], target: Campaign
const versionLabel = target ? `version #${target.version_number}` : "version"; const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) { switch (icon) {
case "first": case "first":
return `Open first ${versionLabel}`; return i18nMessage("i18n:govoplan-campaign.open_first_value.ea8f5c5b", { value0: versionLabel });
case "previous": case "previous":
return `Open previous ${versionLabel}`; return i18nMessage("i18n:govoplan-campaign.open_previous_value.e8d4794b", { value0: versionLabel });
case "next": case "next":
return `Open next ${versionLabel}`; return i18nMessage("i18n:govoplan-campaign.open_next_value.1315037e", { value0: versionLabel });
case "latest": case "latest":
return `Open latest ${versionLabel}`; return i18nMessage("i18n:govoplan-campaign.open_latest_value.40bdb320", { value0: versionLabel });
} }
} }

View File

@@ -33,7 +33,7 @@ function resolveStep(value: StepValue): string | null | undefined {
} }
function defaultLoadedLabel(version: CampaignVersionDetail): string { function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded"; return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
} }
export function useCampaignDraftEditor({ export function useCampaignDraftEditor({
@@ -70,7 +70,7 @@ export function useCampaignDraftEditor({
const [draft, setDraft] = useState<Record<string, unknown> | null>(null); const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded"); const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useState("");
useEffect(() => { useEffect(() => {
@@ -97,7 +97,7 @@ export function useCampaignDraftEditor({
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => { const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false; if (!draft || !version || locked) return false;
setSaveState("Saving…"); setSaveState("i18n:govoplan-campaign.saving.56a2285c");
setError(""); setError("");
setLocalError(""); setLocalError("");
try { try {
@@ -119,7 +119,7 @@ export function useCampaignDraftEditor({
} catch (err) { } catch (err) {
const text = err instanceof Error ? err.message : String(err); const text = err instanceof Error ? err.message : String(err);
setLocalError(text); setLocalError(text);
setSaveState("Save failed"); setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false; return false;
} }
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]); }, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
@@ -147,4 +147,4 @@ export function useCampaignDraftEditor({
markDirty, markDirty,
saveDraft saveDraft
}; };
} }

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useDeltaWatermarks } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types"; import type { ApiSettings } from "../../../types";
import { import {
getCampaignWorkspace getCampaignWorkspaceDelta,
type CampaignWorkspaceDeltaResponse
} from "../../../api/campaigns"; } from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView"; import type { CampaignWorkspaceData } from "../utils/campaignView";
@@ -34,6 +36,21 @@ export function useCampaignWorkspaceData(
const [data, setData] = useState<CampaignWorkspaceData>(initialData); const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const dataRef = useRef<CampaignWorkspaceData>(initialData);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const queryKey = useMemo(
() => JSON.stringify({
campaignId,
selectedVersionId,
includeCurrentVersion,
includeSummary,
includeVersions,
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
const reload = useCallback(async () => { const reload = useCallback(async () => {
if (!campaignId) return; if (!campaignId) return;
@@ -41,26 +58,39 @@ export function useCampaignWorkspaceData(
setError(""); setError("");
try { try {
const shouldLoadVersions = includeCurrentVersion || includeVersions; const shouldLoadVersions = includeCurrentVersion || includeVersions;
const response = await getCampaignWorkspace(settings, campaignId, { let nextWatermark = getDeltaWatermark(queryKey);
versionId: selectedVersionId, let merged: CampaignWorkspaceData = dataRef.current;
includeCurrentVersion, let hasMore = false;
includeSummary, do {
includeVersions: shouldLoadVersions, const response = await getCampaignWorkspaceDelta(settings, campaignId, {
}); versionId: selectedVersionId,
includeCurrentVersion,
setData({ includeSummary,
campaign: response.campaign, includeVersions: shouldLoadVersions,
versions: response.versions, since: nextWatermark,
currentVersion: response.current_version, });
summary: response.summary merged = mergeWorkspaceDelta(merged, response);
}); nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(queryKey, nextWatermark);
dataRef.current = merged;
setData(merged);
} catch (err) { } catch (err) {
dataRef.current = initialData;
setData(initialData); setData(initialData);
resetDeltaWatermark(queryKey);
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]); }, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
useEffect(() => {
resetDeltaWatermark(queryKey);
dataRef.current = initialData;
setData(initialData);
}, [queryKey, resetDeltaWatermark]);
useEffect(() => { useEffect(() => {
reload(); reload();
@@ -68,3 +98,34 @@ export function useCampaignWorkspaceData(
return { data, loading, error, reload, setError }; return { data, loading, error, reload, setError };
} }
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
if (response.full) {
return {
campaign: response.campaign,
versions: response.versions,
currentVersion: response.current_version,
summary: response.summary
};
}
const deletedVersionIds = new Set(
response.deleted
.filter((item) => item.resource_type === "campaign_version")
.map((item) => item.id)
);
const versionMap = new Map(current.versions.map((version) => [version.id, version]));
for (const version of response.versions) {
versionMap.set(version.id, version);
}
for (const versionId of deletedVersionIds) {
versionMap.delete(versionId);
}
const currentVersionDeleted = current.currentVersion ? deletedVersionIds.has(current.currentVersion.id) : false;
return {
campaign: response.campaign ?? current.campaign,
versions: Array.from(versionMap.values()).sort((left, right) => (right.version_number ?? 0) - (left.version_number ?? 0)),
currentVersion: response.current_version ?? (currentVersionDeleted ? null : current.currentVersion),
summary: response.summary ?? current.summary
};
}

View File

@@ -1,4 +1,4 @@
export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "Inline SMTP/IMAP settings are blocked by the effective mail policy. Select an allowed reusable profile."; export const INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE = "i18n:govoplan-campaign.inline_smtp_imap_settings_are_blocked_by_the_eff.90c94538";
export type CampaignMailPolicy = { export type CampaignMailPolicy = {
allow_campaign_profiles?: boolean | null; allow_campaign_profiles?: boolean | null;
@@ -17,11 +17,11 @@ export function campaignMailSettingsPolicyState({
effectivePolicy, effectivePolicy,
selectedProfileId, selectedProfileId,
locked = false locked = false
}: {
effectivePolicy: CampaignMailPolicy | null | undefined;
selectedProfileId: string | null | undefined;
locked?: boolean;
}): CampaignMailSettingsPolicyState { }: {effectivePolicy: CampaignMailPolicy | null | undefined;selectedProfileId: string | null | undefined;locked?: boolean;}): CampaignMailSettingsPolicyState {
const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true; const campaignProfilesAllowed = effectivePolicy?.allow_campaign_profiles === true;
const usingMailProfile = Boolean(selectedProfileId); const usingMailProfile = Boolean(selectedProfileId);
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile; const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;
@@ -33,4 +33,4 @@ export function campaignMailSettingsPolicyState({
canSelectInlineSettings: !locked && campaignProfilesAllowed, canSelectInlineSettings: !locked && campaignProfilesAllowed,
inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE inlineBlockedMessage: INLINE_MAIL_SETTINGS_BLOCKED_MESSAGE
}; };
} }

View File

@@ -112,7 +112,7 @@ export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentS
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null; if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":"); const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim(); const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null; if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
return { ownerType, ownerId }; return { ownerType, ownerId };
} }
@@ -135,12 +135,12 @@ export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
}; };
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [ export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Campaign attachments", path: "attachments" }, { label: "i18n:govoplan-campaign.campaign_attachments.926bcbe6", path: "attachments" },
{ label: "Campaign root", path: "." }, { label: "i18n:govoplan-campaign.campaign_root.7eccd949", path: "." },
{ label: "Shared group files", path: "group/shared" }, { label: "i18n:govoplan-campaign.shared_group_files.fffa6e27", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" }, { label: "i18n:govoplan-campaign.tenant_templates.ac6653bf", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" } { label: "i18n:govoplan-campaign.personal_upload_area.babd7b7f", path: "user/uploads" }];
];
@@ -205,11 +205,11 @@ export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: Attac
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number { export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
const entries = asRecord(entriesValue); const entries = asRecord(entriesValue);
return asArray(entries.inline) return asArray(entries.inline).
.map(asRecord) map(asRecord).
.flatMap((entry) => normalizeAttachmentRules(entry.attachments)) flatMap((entry) => normalizeAttachmentRules(entry.attachments)).
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath)) filter((rule) => attachmentRuleUsesBasePath(rule, basePath)).
.length; length;
} }
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> { export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
@@ -261,10 +261,10 @@ export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSum
export function countIndividualAttachmentRules(entriesValue: unknown): number { export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue); const entries = asRecord(entriesValue);
return asArray(entries.inline) return asArray(entries.inline).
.map(asRecord) map(asRecord).
.flatMap((entry) => asArray(entry.attachments)) flatMap((entry) => asArray(entry.attachments)).
.length; length;
} }
export function isDirectAttachmentRule(rule: AttachmentRule): boolean { export function isDirectAttachmentRule(rule: AttachmentRule): boolean {

View File

@@ -177,9 +177,9 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
} }
export function buildImportTableFromRows( export function buildImportTableFromRows(
sourceRows: string[][], sourceRows: string[][],
options: { headerRows: number; delimiter?: "," | ";" | "\t" } options: {headerRows: number;delimiter?: "," | ";" | "\t";})
): RecipientImportTable { : RecipientImportTable {
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? ""))); const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length)); const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
const headerRows = rows.slice(0, normalizedHeaderRows); const headerRows = rows.slice(0, normalizedHeaderRows);
@@ -269,37 +269,37 @@ export function recipientImportHeaderFingerprints(headers: string[]): Pick<Recip
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] { export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
const fingerprints = recipientImportHeaderFingerprints(table.headers); const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles return profiles.
.map((profile): RecipientMappingProfileMatch | null => { map((profile): RecipientMappingProfileMatch | null => {
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders); const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint; const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint; const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar"; const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1); const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length; const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore; const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
if (mode === "similar" && score < 0.45) return null; if (mode === "similar" && score < 0.45) return null;
return { return {
profile, profile,
mode, mode,
score, score,
matchedHeaders: diff.matchedHeaders, matchedHeaders: diff.matchedHeaders,
missingHeaders: diff.missingHeaders, missingHeaders: diff.missingHeaders,
extraHeaders: diff.extraHeaders extraHeaders: diff.extraHeaders
}; };
}) }).
.filter((match): match is RecipientMappingProfileMatch => match !== null) filter((match): match is RecipientMappingProfileMatch => match !== null).
.sort((left, right) => { sort((left, right) => {
if (right.score !== left.score) return right.score - left.score; if (right.score !== left.score) return right.score - left.score;
return right.profile.updatedAt.localeCompare(left.profile.updatedAt); return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
}); });
} }
export function applyRecipientMappingProfile( export function applyRecipientMappingProfile(
table: RecipientImportTable, table: RecipientImportTable,
profile: RecipientMappingProfile, profile: RecipientMappingProfile,
existingFields: ImportFieldDefinition[] existingFields: ImportFieldDefinition[])
): RecipientColumnMapping[] { : RecipientColumnMapping[] {
const defaults = defaultColumnMappings(table.headers, existingFields); const defaults = defaultColumnMappings(table.headers, existingFields);
const fingerprints = recipientImportHeaderFingerprints(table.headers); const fingerprints = recipientImportHeaderFingerprints(table.headers);
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length); const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
@@ -325,10 +325,10 @@ export function applyRecipientMappingProfile(
} }
export function buildRecipientImportPreview( export function buildRecipientImportPreview(
table: RecipientImportTable, table: RecipientImportTable,
mappings: RecipientColumnMapping[], mappings: RecipientColumnMapping[],
options: RecipientImportPreviewOptions options: RecipientImportPreviewOptions)
): RecipientImportPreview { : RecipientImportPreview {
const existingFields = options.existingFields; const existingFields = options.existingFields;
const existingFieldNames = new Set(existingFields.map((field) => field.name)); const existingFieldNames = new Set(existingFields.map((field) => field.name));
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean)); const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
@@ -337,45 +337,45 @@ export function buildRecipientImportPreview(
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping])); const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
const valueSeparators = options.valueSeparators || ",;|"; const valueSeparators = options.valueSeparators || ",;|";
const rows = table.dataRows const rows = table.dataRows.
.map((cells, index): ImportedRecipientRow | null => { map((cells, index): ImportedRecipientRow | null => {
if (cells.every((cell) => !cell.trim())) return null; if (cells.every((cell) => !cell.trim())) return null;
const rowNumber = table.firstDataRowNumber + index; const rowNumber = table.firstDataRowNumber + index;
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] }; const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
const fields: Record<string, string> = {}; const fields: Record<string, string> = {};
const patterns: string[] = []; const patterns: string[] = [];
const issues: string[] = []; const issues: string[] = [];
let preferredId = ""; let preferredId = "";
let name = ""; let name = "";
let active = true; let active = true;
cells.forEach((rawValue, columnIndex) => { cells.forEach((rawValue, columnIndex) => {
const value = String(rawValue ?? "").trim(); const value = String(rawValue ?? "").trim();
if (!value) return; if (!value) return;
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const }; const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
switch (mapping.kind) { switch (mapping.kind) {
case "id": case "id":
preferredId = value; preferredId = value;
break; break;
case "active": case "active":
active = parseOptionalBoolean(value, true); active = parseOptionalBoolean(value, true);
break; break;
case "name": case "name":
name = value; name = value;
break; break;
case "from": case "from":
case "to": case "to":
case "cc": case "cc":
case "bcc": case "bcc":
case "reply_to": case "reply_to":
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators)); addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
break; break;
case "field": { case "field":{
const fieldName = mapping.fieldName?.trim(); const fieldName = mapping.fieldName?.trim();
if (fieldName) fields[fieldName] = value; if (fieldName) fields[fieldName] = value;
break; break;
} }
case "new_field": { case "new_field":{
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`); const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
if (fieldName) { if (fieldName) {
fields[fieldName] = value; fields[fieldName] = value;
@@ -383,41 +383,41 @@ export function buildRecipientImportPreview(
} }
break; break;
} }
case "attachment_pattern": case "attachment_pattern":
patterns.push(...splitCell(value, valueSeparators)); patterns.push(...splitCell(value, valueSeparators));
break; break;
case "ignore": case "ignore":
default: default:
break; break;
}
});
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
const email = primaryAddress?.email ?? "";
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
usedIds.add(id);
for (const [key, list] of Object.entries(addresses)) {
for (const address of list) {
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
}
} }
if (addresses.to.length === 0) issues.push("Missing To address"); });
return { if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
rowNumber, const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
id, const email = primaryAddress?.email ?? "";
name: name || primaryAddress?.name || "", const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
email, usedIds.add(id);
active,
addresses, for (const [key, list] of Object.entries(addresses)) {
fields, for (const address of list) {
patterns, if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
issues: [...new Set(issues)] }
}; }
}) if (addresses.to.length === 0) issues.push("i18n:govoplan-campaign.missing_to_address.d4ff53b4");
.filter((row): row is ImportedRecipientRow => row !== null);
return {
rowNumber,
id,
name: name || primaryAddress?.name || "",
email,
active,
addresses,
fields,
patterns,
issues: [...new Set(issues)]
};
}).
filter((row): row is ImportedRecipientRow => row !== null);
return { return {
table, table,
@@ -430,16 +430,16 @@ export function buildRecipientImportPreview(
} }
export function materializeRecipientImport( export function materializeRecipientImport(
draft: Record<string, unknown>, draft: Record<string, unknown>,
preview: RecipientImportPreview, preview: RecipientImportPreview,
options: MaterializeImportOptions options: MaterializeImportOptions)
): Record<string, unknown> { : Record<string, unknown> {
const currentEntries = asRecord(draft.entries); const currentEntries = asRecord(draft.entries);
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord); const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
const previousImports = asArray(currentEntries.imports).map(asRecord); const previousImports = asArray(currentEntries.imports).map(asRecord);
const importedEntries = preview.rows const importedEntries = preview.rows.
.filter((row) => row.issues.length === 0) filter((row) => row.issues.length === 0).
.map((row) => importedRowToEntry(row, options.attachmentBasePath)); map((row) => importedRowToEntry(row, options.attachmentBasePath));
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries; const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries }; const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance]; if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
@@ -537,15 +537,15 @@ function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: Impo
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] { function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
const existing = asArray(fieldsValue).map(asRecord); const existing = asArray(fieldsValue).map(asRecord);
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean)); const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
const additions = fieldNamesToCreate const additions = fieldNamesToCreate.
.filter((name) => !existingNames.has(name)) filter((name) => !existingNames.has(name)).
.map((name) => ({ map((name) => ({
name, name,
label: humanizeFieldName(name), label: humanizeFieldName(name),
type: "string", type: "string",
required: false, required: false,
can_override: true can_override: true
})); }));
return [...existing, ...additions]; return [...existing, ...additions];
} }
@@ -594,9 +594,9 @@ export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", qu
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" { function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
const firstLine = text.split(/\r?\n/, 1)[0] ?? ""; const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"]; const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
return candidates return candidates.
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })) map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })).
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ","; sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
} }
function headerForColumn(headerRows: string[][], columnIndex: number): string { function headerForColumn(headerRows: string[][], columnIndex: number): string {
@@ -636,7 +636,7 @@ function stableHash(value: string): string {
return (hash >>> 0).toString(36); return (hash >>> 0).toString(36);
} }
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): { matchedHeaders: number; missingHeaders: string[]; extraHeaders: string[] } { function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): {matchedHeaders: number;missingHeaders: string[];extraHeaders: string[];} {
const currentCounts = countHeaders(currentHeaders); const currentCounts = countHeaders(currentHeaders);
const profileCounts = countHeaders(profileHeaders); const profileCounts = countHeaders(profileHeaders);
let matchedHeaders = 0; let matchedHeaders = 0;
@@ -664,10 +664,10 @@ function countHeaders(headers: string[]): Map<string, number> {
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] { function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
const currentCounts = countHeaders(currentHeaders); const currentCounts = countHeaders(currentHeaders);
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length) return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length).
.filter((mapping) => mapping.kind !== "ignore") filter((mapping) => mapping.kind !== "ignore").
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "") map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "").
.filter((header) => header && !currentCounts.has(header)); filter((header) => header && !currentCounts.has(header));
} }
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] { function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
@@ -684,12 +684,12 @@ function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, colu
} }
function normalizeProfileMapping( function normalizeProfileMapping(
mapping: RecipientColumnMapping | undefined, mapping: RecipientColumnMapping | undefined,
columnIndex: number, columnIndex: number,
header: string, header: string,
existingFields: ImportFieldDefinition[], existingFields: ImportFieldDefinition[],
fallback: RecipientColumnMapping | undefined fallback: RecipientColumnMapping | undefined)
): RecipientColumnMapping { : RecipientColumnMapping {
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const }; const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
if (!mapping) return fallbackMapping; if (!mapping) return fallbackMapping;
const existingFieldNames = new Set(existingFields.map((field) => field.name)); const existingFieldNames = new Set(existingFields.map((field) => field.name));
@@ -707,25 +707,25 @@ function normalizeProfileMapping(
} }
function isPatternColumn(key: string): boolean { function isPatternColumn(key: string): boolean {
return key === "pattern" return key === "pattern" ||
|| key === "patterns" key === "patterns" ||
|| key === "file" key === "file" ||
|| key === "files" key === "files" ||
|| key === "file_pattern" key === "file_pattern" ||
|| key === "file_patterns" key === "file_patterns" ||
|| key === "attachment" key === "attachment" ||
|| key === "attachments" key === "attachments" ||
|| key === "attachment_pattern" key === "attachment_pattern" ||
|| key === "attachment_patterns" key === "attachment_patterns" ||
|| /^pattern_\d+$/.test(key) /^pattern_\d+$/.test(key) ||
|| /^file_\d+$/.test(key) /^file_\d+$/.test(key) ||
|| /^attachment_\d+$/.test(key); /^attachment_\d+$/.test(key);
} }
function parseAddressCell(value: string, separators: string): ImportedAddress[] { function parseAddressCell(value: string, separators: string): ImportedAddress[] {
return splitCell(value, separators) return splitCell(value, separators).
.map((item) => parseAddress(item)) map((item) => parseAddress(item)).
.filter((address) => Boolean(address.email)); filter((address) => Boolean(address.email));
} }
function parseAddress(value: string): ImportedAddress { function parseAddress(value: string): ImportedAddress {

View File

@@ -1,4 +1,4 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui"; import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
import type { CampaignListItem } from "../../../types"; import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns"; import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
@@ -54,8 +54,8 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
} }
export function userLockState( export function userLockState(
version: CampaignVersionDetail | CampaignVersionListItem | null version: CampaignVersionDetail | CampaignVersionListItem | null)
): "temporary" | "permanent" | null { : "temporary" | "permanent" | null {
if (!version) return null; if (!version) return null;
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") { if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
return version.user_lock_state; return version.user_lock_state;
@@ -79,18 +79,18 @@ export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVer
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false; if (!version) return false;
return [ return [
"queued", "queued",
"sending", "sending",
"sent", "sent",
"completed", "completed",
"partially_completed", "partially_completed",
"outcome_unknown", "outcome_unknown",
"failed", "failed",
"failed_partial", "failed_partial",
"partially_sent", "partially_sent",
"archived", "archived",
"cancelled" "cancelled"].
].includes((version.workflow_state ?? "").toLowerCase()); includes((version.workflow_state ?? "").toLowerCase());
} }
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean { export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
@@ -108,16 +108,16 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
} }
export function isHistoricalCampaignVersion( export function isHistoricalCampaignVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null, version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null, currentVersionId?: string | null)
): boolean { : boolean {
return Boolean(version && currentVersionId && version.id !== currentVersionId); return Boolean(version && currentVersionId && version.id !== currentVersionId);
} }
export function isAuditLockedVersion( export function isAuditLockedVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null, version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null, currentVersionId?: string | null)
): boolean { : boolean {
if (!version) return false; if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true; if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) return true; if (version.locked_at || isUserLockedVersion(version)) return true;
@@ -125,31 +125,31 @@ export function isAuditLockedVersion(
} }
export function versionLockReason( export function versionLockReason(
version: CampaignVersionDetail | CampaignVersionListItem | null, version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null, currentVersionId?: string | null)
): string { : string {
if (!version) return "No campaign version is loaded."; if (!version) return "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b";
if (isHistoricalCampaignVersion(version, currentVersionId)) { if (isHistoricalCampaignVersion(version, currentVersionId)) {
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable."; return "i18n:govoplan-campaign.historical_campaign_versions_are_review_only_con.c35b96af";
} }
if (isTemporaryUserLockedVersion(version)) { if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`; return i18nMessage("i18n:govoplan-campaign.temporarily_user_locked_at_value_authorized_user.7154865b", { value0: formatDateTime(version.user_locked_at) });
} }
if (isPermanentUserLockedVersion(version)) { if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`; return i18nMessage("i18n:govoplan-campaign.permanently_user_locked_at_value_it_cannot_be_un.bf843a0f", { value0: formatDateTime(version.user_locked_at ?? version.published_at) });
} }
if (canUnlockValidationVersion(version)) { if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`; return i18nMessage("i18n:govoplan-campaign.temporarily_locked_by_validation_at_value_unlock.2f1ace87", { value0: formatDateTime(version.locked_at) });
} }
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`; if (isFinalLockedVersion(version)) return i18nMessage("i18n:govoplan-campaign.permanently_locked_by_delivery_final_state_value.e31278ae", { value0: humanize(version.workflow_state ?? "locked") });
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`; if (version.locked_at) return i18nMessage("i18n:govoplan-campaign.temporarily_locked_at_value.3ce9192e", { value0: formatDateTime(version.locked_at) });
return "Editable working version."; return "i18n:govoplan-campaign.editable_working_version.379f898a";
} }
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string { export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—"; if (!version) return "—";
const flow = version.current_flow || "manual"; const flow = version.current_flow || "manual";
const step = version.current_step || "not set"; const step = version.current_step || "i18n:govoplan-campaign.not_set.ef374c57";
return `${humanize(flow)} / ${humanize(step)}`; return `${humanize(flow)} / ${humanize(step)}`;
} }
@@ -197,10 +197,10 @@ export function stringifyPreview(value: unknown, maxLength = 220): string {
} }
export function cloneCampaignJsonForCopy( export function cloneCampaignJsonForCopy(
source: Record<string, unknown>, source: Record<string, unknown>,
campaign: CampaignListItem | null, campaign: CampaignListItem | null,
stamp: string stamp: string)
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } { : {externalId: string;name: string;description: string;rawJson: Record<string, unknown>;} {
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>; const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
const campaignSection = asRecord(rawJson.campaign); const campaignSection = asRecord(rawJson.campaign);
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign"); const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");

View File

@@ -14,7 +14,7 @@ export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapabili
export type ImportFileLinkResolution = { export type ImportFileLinkResolution = {
basePath: AttachmentBasePath; basePath: AttachmentBasePath;
patterns: RenderedImportPattern[]; patterns: RenderedImportPattern[];
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[]; matchedPatterns: {pattern: RenderedImportPattern;matches: FilesManagedFile[];}[];
unmatchedPatterns: RenderedImportPattern[]; unmatchedPatterns: RenderedImportPattern[];
files: FilesManagedFile[]; files: FilesManagedFile[];
linkedFiles: FilesManagedFile[]; linkedFiles: FilesManagedFile[];
@@ -22,15 +22,15 @@ export type ImportFileLinkResolution = {
}; };
export async function resolveImportedAttachmentLinks( export async function resolveImportedAttachmentLinks(
filesCapability: ImportFileLinkCapability, filesCapability: ImportFileLinkCapability,
settings: ApiSettings, settings: ApiSettings,
campaignId: string, campaignId: string,
basePath: AttachmentBasePath, basePath: AttachmentBasePath,
preview: RecipientImportPreview preview: RecipientImportPreview)
): Promise<ImportFileLinkResolution> { : Promise<ImportFileLinkResolution> {
const parsedSource = parseManagedAttachmentSource(basePath.source); const parsedSource = parseManagedAttachmentSource(basePath.source);
if (!parsedSource) { if (!parsedSource) {
throw new Error("The selected attachment source is not connected to a managed file space."); throw new Error("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.965a0056");
} }
const patterns = renderedImportPatterns(preview); const patterns = renderedImportPatterns(preview);
@@ -40,25 +40,25 @@ export async function resolveImportedAttachmentLinks(
const root = normalizedPathPrefix(basePath.path); const root = normalizedPathPrefix(basePath.path);
const [resolved, visibleFiles] = await Promise.all([ const [resolved, visibleFiles] = await Promise.all([
filesCapability.resolveFilePatterns(settings, { filesCapability.resolveFilePatterns(settings, {
patterns: patterns.map((item) => item.renderedPattern), patterns: patterns.map((item) => item.renderedPattern),
owner_type: parsedSource.ownerType, owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId, owner_id: parsedSource.ownerId,
path_prefix: root || undefined, path_prefix: root || undefined,
include_unmatched: false include_unmatched: false
}), }),
filesCapability.listFiles(settings, { filesCapability.listFiles(settings, {
owner_type: parsedSource.ownerType, owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId, owner_id: parsedSource.ownerId,
path_prefix: root || undefined path_prefix: root || undefined
}) })]
]); );
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file])); const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
const fileById = new Map<string, FilesManagedFile>(); const fileById = new Map<string, FilesManagedFile>();
const matchedPatterns = patterns.map((pattern, index) => { const matchedPatterns = patterns.map((pattern, index) => {
const matches = (resolved.patterns[index]?.matches ?? []) const matches = (resolved.patterns[index]?.matches ?? []).
.map((file) => visibleById.get(file.id) ?? file); map((file) => visibleById.get(file.id) ?? file);
matches.forEach((file) => fileById.set(file.id, file)); matches.forEach((file) => fileById.set(file.id, file));
return { pattern, matches }; return { pattern, matches };
}); });
@@ -86,16 +86,16 @@ export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCap
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] { export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
const byKey = new Map<string, RenderedImportPattern>(); const byKey = new Map<string, RenderedImportPattern>();
preview.rows preview.rows.
.filter((row) => row.issues.length === 0) filter((row) => row.issues.length === 0).
.forEach((row) => { forEach((row) => {
row.patterns.forEach((sourcePattern) => { row.patterns.forEach((sourcePattern) => {
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim(); const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
if (!renderedPattern) return; if (!renderedPattern) return;
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`; const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern }); if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
});
}); });
});
return [...byKey.values()]; return [...byKey.values()];
} }
@@ -142,4 +142,4 @@ function normalizedPathPrefix(path: string): string {
const trimmed = path.trim(); const trimmed = path.trim();
if (!trimmed || trimmed === "." || trimmed === "/") return ""; if (!trimmed || trimmed === "." || trimmed === "/") return "";
return trimmed.replace(/^[\/]+|[\/]+$/g, ""); return trimmed.replace(/^[\/]+|[\/]+$/g, "");
} }

View File

@@ -0,0 +1,53 @@
import { mergeDeltaRows } from "@govoplan/core-webui";
import type { CampaignJobsDeltaResponse, CampaignJobsResponse } from "../../../api/campaigns";
export function emptyCampaignJobsResponse(): CampaignJobsResponse {
return {
jobs: [],
page: 1,
page_size: 50,
total: 0,
total_unfiltered: 0,
pages: 0,
cursor: null,
next_cursor: null,
counts: {},
filtered_counts: {},
review: {}
};
}
export function mergeCampaignJobsDelta(current: CampaignJobsResponse, response: CampaignJobsDeltaResponse): CampaignJobsResponse {
if (response.full) return campaignJobsResponseFromDelta(response);
return {
...campaignJobsResponseFromDelta(response),
jobs: mergeDeltaRows(current.jobs, response.jobs, response.deleted, (row) => String(row.id ?? ""), {
deletedResourceType: "campaign_job",
sort: sortJobsByEntry
}),
cursor: response.cursor ?? current.cursor,
next_cursor: response.next_cursor ?? current.next_cursor
};
}
export function campaignJobsResponseFromDelta(response: CampaignJobsDeltaResponse): CampaignJobsResponse {
return {
jobs: response.jobs,
page: response.page,
page_size: response.page_size,
total: response.total,
total_unfiltered: response.total_unfiltered,
pages: response.pages,
cursor: response.cursor,
next_cursor: response.next_cursor,
counts: response.counts,
filtered_counts: response.filtered_counts,
review: response.review
};
}
function sortJobsByEntry(left: Record<string, unknown>, right: Record<string, unknown>): number {
const indexDelta = Number(left.entry_index ?? 0) - Number(right.entry_index ?? 0);
if (indexDelta !== 0) return indexDelta;
return String(left.id ?? "").localeCompare(String(right.id ?? ""));
}

View File

@@ -14,17 +14,17 @@ import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps"; import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
const steps: WizardStep[] = [ const steps: WizardStep[] = [
{ id: "basics", label: "Basics", description: "Name and scenario" }, { id: "basics", label: "i18n:govoplan-campaign.basics.5fcebeef", description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241" },
{ id: "sender", label: "Sender", description: "Mail account and headers" }, { id: "sender", label: "i18n:govoplan-campaign.sender.17b874d2", description: "i18n:govoplan-campaign.mail_account_and_headers.9e269243" },
{ id: "fields", label: "Fields", description: "Define campaign data" }, { id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527", description: "i18n:govoplan-campaign.define_campaign_data.7045c0a4" },
{ id: "recipients", label: "Recipients", description: "Import and map source data" }, { id: "recipients", label: "i18n:govoplan-campaign.recipients.78cbf8eb", description: "i18n:govoplan-campaign.import_and_map_source_data.b875d471" },
{ id: "template", label: "Template", description: "Subject and body" }, { id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06", description: "i18n:govoplan-campaign.subject_and_body.915da39b" },
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" }, { id: "attachments", label: "i18n:govoplan-campaign.attachments.6771ade6", description: "i18n:govoplan-campaign.rules_and_zip_options.e3656774" },
{ id: "review", label: "Review", description: "Validate before build" }, { id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", description: "i18n:govoplan-campaign.validate_before_build.27723ad7" },
{ id: "send", label: "Send", description: "Test and queue" } { id: "send", label: "i18n:govoplan-campaign.send.9bc2575c", description: "i18n:govoplan-campaign.test_and_queue.c3c940e4" }];
];
export default function CreateWizard({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
export default function CreateWizard({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const [activeStep, setActiveStep] = useState("basics"); const [activeStep, setActiveStep] = useState("basics");
const [localError, setLocalError] = useState(""); const [localError, setLocalError] = useState("");
const [validationMessage, setValidationMessage] = useState(""); const [validationMessage, setValidationMessage] = useState("");
@@ -41,8 +41,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
setError: setLocalError, setError: setLocalError,
currentFlow: "create", currentFlow: "create",
currentStep: () => activeStep, currentStep: () => activeStep,
unsavedTitle: "Unsaved wizard changes", unsavedTitle: "i18n:govoplan-campaign.unsaved_wizard_changes.ba907144",
unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.", unsavedMessage: "i18n:govoplan-campaign.this_campaign_wizard_has_unsaved_changes_save_th.7e4be033",
onLoaded: (loadedVersion) => { onLoaded: (loadedVersion) => {
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) { if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
setActiveStep(loadedVersion.current_step); setActiveStep(loadedVersion.current_step);
@@ -68,7 +68,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
async function validateCurrentStep() { async function validateCurrentStep() {
if (!version || !draft) return; if (!version || !draft) return;
setValidationMessage("Validating…"); setValidationMessage("i18n:govoplan-campaign.validating.c07434c9");
try { try {
const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep }); const result = await validatePartial(settings, campaignId, version.id, { campaign_json: draft, section: activeStep });
setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`); setValidationMessage(`${result.error_count} errors, ${result.warning_count} warnings, ${result.info_count} info messages.`);
@@ -84,9 +84,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
<div className="wizard-body standalone-wizard-body"> <div className="wizard-body standalone-wizard-body">
<div className="wizard-heading"> <div className="wizard-heading">
<div> <div>
<PageTitle>Create campaign</PageTitle> <PageTitle>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
</div> </div>
<div className="save-state">Locked</div> <div className="save-state">i18n:govoplan-campaign.locked.a798882f</div>
</div> </div>
<Card> <Card>
<LockedVersionNotice <LockedVersionNotice
@@ -95,16 +95,16 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
version={data.currentVersion} version={data.currentVersion}
currentVersionId={data.campaign?.current_version_id} currentVersionId={data.campaign?.current_version_id}
reload={reload} reload={reload}
message="This wizard is read-only for the selected version." message="i18n:govoplan-campaign.this_wizard_is_read_only_for_the_selected_versio.b0865947" />
/>
<div className="button-row"> <div className="button-row">
<Link to="../.."><Button>Back to overview</Button></Link> <Link to="../.."><Button>i18n:govoplan-campaign.back_to_overview.ec986cba</Button></Link>
</div> </div>
</Card> </Card>
</div> </div>
</div> </div>
</div> </div>);
);
} }
return ( return (
@@ -114,7 +114,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
<div className="wizard-body"> <div className="wizard-body">
<div className="wizard-heading"> <div className="wizard-heading">
<div> <div>
<PageTitle loading={loading}>Create campaign</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
</div> </div>
<div className="save-state">{saveState}</div> <div className="save-state">{saveState}</div>
</div> </div>
@@ -131,13 +131,13 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />} {draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
</Card> </Card>
<div className="wizard-footer"> <div className="wizard-footer">
<Button onClick={previousStep}>Back</Button> <Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button> <Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
<Button onClick={validateCurrentStep}>Validate step</Button> <Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
<Button variant="primary" onClick={nextStep}>Continue</Button> <Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>);
);
} }

View File

@@ -3,20 +3,20 @@ import { MetricCard } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
export default function ReviewWizard() { export default function ReviewWizard() {
return ( return (
<div className="content-pad"> <div className="content-pad workspace-data-page">
<div className="page-heading"> <div className="page-heading workspace-heading">
<h1>Review Wizard</h1> <h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
</div> </div>
<div className="metric-grid"> <div className="metric-grid">
<MetricCard label="Needs review" value="—" tone="warning" /> <MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
<MetricCard label="Missing attachments" value="—" tone="warning" /> <MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
<MetricCard label="Ambiguous matches" value="—" tone="info" /> <MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
<MetricCard label="Blocked" value="—" tone="danger" /> <MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
</div> </div>
<Card title="Resolution workflow"> <Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
<p className="muted">This wizard will guide users through issues one class at a time.</p> <p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
<Button variant="primary">Start review</Button> <Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
</Card> </Card>
</div> </div>);
);
} }

View File

@@ -2,20 +2,20 @@ import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
export default function SendWizard() { export default function SendWizard() {
return ( return (
<div className="content-pad"> <div className="content-pad workspace-data-page">
<div className="page-heading"> <div className="page-heading workspace-heading">
<h1>Send Wizard</h1> <h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
</div> </div>
<div className="dashboard-grid"> <div className="dashboard-grid">
<Card title="Test send"> <Card title="i18n:govoplan-campaign.test_send.03dfff38">
<p className="muted">Send one generated message to a test address.</p> <p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
<Button>Open test-send dialog</Button> <Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
</Card> </Card>
<Card title="Queue estimate"> <Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p> <p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
<Button variant="primary">Queue dry run</Button> <Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
</Card> </Card>
</div> </div>
</div> </div>);
);
} }

View File

@@ -21,24 +21,24 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
const campaign = asRecord(draft.campaign); const campaign = asRecord(draft.campaign);
return ( return (
<div className="form-grid"> <div className="form-grid">
<FormField label="Campaign name" help="A human-readable name shown in lists and reports."> <FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} /> <input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
</FormField> </FormField>
<FormField label="Campaign ID" help="Stable technical identifier."> <FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e" help="i18n:govoplan-campaign.stable_technical_identifier.28c406b2">
<input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} /> <input value={getText(campaign, "id")} onChange={(event) => patch(["campaign", "id"], event.target.value)} />
</FormField> </FormField>
<FormField label="Mode"> <FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}> <select value={getText(campaign, "mode", "draft")} onChange={(event) => patch(["campaign", "mode"], event.target.value)}>
<option value="draft">Draft</option> <option value="draft">i18n:govoplan-campaign.draft.23d33e22</option>
<option value="test">Test</option> <option value="test">i18n:govoplan-campaign.test.640ab2ba</option>
<option value="send">Send</option> <option value="send">i18n:govoplan-campaign.send.9bc2575c</option>
</select> </select>
</FormField> </FormField>
<FormField label="Description"> <FormField label="i18n:govoplan-campaign.description.55f8ebc8">
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} /> <textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
</FormField> </FormField>
</div> </div>);
);
} }
export function SenderStep({ draft, patch }: WizardStepProps) { export function SenderStep({ draft, patch }: WizardStepProps) {
@@ -55,86 +55,86 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
const imapAppend = asRecord(delivery.imap_append_sent); const imapAppend = asRecord(delivery.imap_append_sent);
return ( return (
<div className="form-grid"> <div className="form-grid">
<FormField label="Default From address"> <FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<EmailAddressInput <EmailAddressInput
value={from} value={from}
suggestions={suggestions} suggestions={suggestions}
allowMultiple allowMultiple
addLabel="Add From" addLabel="i18n:govoplan-campaign.add_from.a095bb50"
emptyText="No global From address configured." emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)} onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)} />
/>
</FormField> </FormField>
<FormField label="Global recipients"> <FormField label="i18n:govoplan-campaign.global_recipients.d9aaa427">
<EmailAddressInput <EmailAddressInput
value={globalTo} value={globalTo}
suggestions={suggestions} suggestions={suggestions}
allowMultiple allowMultiple
addLabel="Add recipient" addLabel="i18n:govoplan-campaign.add_recipient.a989d1f1"
emptyText="No global recipients configured." emptyText="i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)} onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)} />
/>
</FormField> </FormField>
<FormField label="CC"> <FormField label="i18n:govoplan-campaign.cc.c5a976de">
<EmailAddressInput <EmailAddressInput
value={globalCc} value={globalCc}
suggestions={suggestions} suggestions={suggestions}
allowMultiple allowMultiple
addLabel="Add CC" addLabel="i18n:govoplan-campaign.add_cc.bcb39ea3"
emptyText="No global CC recipients configured." emptyText="i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)} onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)} />
/>
</FormField> </FormField>
<FormField label="BCC"> <FormField label="i18n:govoplan-campaign.bcc.4c0145a3">
<EmailAddressInput <EmailAddressInput
value={globalBcc} value={globalBcc}
suggestions={suggestions} suggestions={suggestions}
allowMultiple allowMultiple
addLabel="Add BCC" addLabel="i18n:govoplan-campaign.add_bcc.ae9feacc"
emptyText="No global BCC recipients configured." emptyText="i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)} onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)} />
/>
</FormField> </FormField>
<FormField label="Reply-To"> <FormField label="i18n:govoplan-campaign.reply_to.c1733667">
<EmailAddressInput <EmailAddressInput
value={globalReplyTo.slice(0, 1)} value={globalReplyTo.slice(0, 1)}
suggestions={suggestions} suggestions={suggestions}
allowMultiple={false} allowMultiple={false}
showAddButton={false} showAddButton={false}
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"} addLabel={globalReplyTo.length ? "i18n:govoplan-campaign.replace.a7cf7b25" : "i18n:govoplan-campaign.add_reply_to.84b195f4"}
emptyText="No Reply-To address configured." emptyText="i18n:govoplan-campaign.no_reply_to_address_configured.0665c1d9"
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
/>
</FormField> </FormField>
<FormField label="SMTP host"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.smtp_host.2d4a434b"><input value={getText(smtp, "host")} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="SMTP port"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField> <FormField label="i18n:govoplan-campaign.smtp_port.65b5a108"><input type="number" value={getNumber(smtp, "port", 587)} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div> </div>);
);
} }
export function FieldsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) { export function FieldsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Campaign fields</h2> <h2>i18n:govoplan-campaign.campaign_fields.969e7d80</h2>
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p> <p>i18n:govoplan-campaign.define_reusable_fields_for_templates_attachment_.6424f892</p>
</div> </div>
<JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} /> <JsonEditor value={draft.fields ?? []} onValid={(value) => patchRoot("fields", value)} />
</div> </div>);
);
} }
export function RecipientsStep({ draft, patchRoot }: { draft: Record<string, unknown>; patchRoot: (key: string, value: unknown) => void }) { export function RecipientsStep({ draft, patchRoot }: {draft: Record<string, unknown>;patchRoot: (key: string, value: unknown) => void;}) {
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Recipients</h2> <h2>i18n:govoplan-campaign.recipients.78cbf8eb</h2>
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p> <p>i18n:govoplan-campaign.store_inline_recipients_or_source_mapping_config.77bbe98f</p>
</div> </div>
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} /> <JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
</div> </div>);
);
} }
export function TemplateStep({ draft, patch }: WizardStepProps) { export function TemplateStep({ draft, patch }: WizardStepProps) {
@@ -142,16 +142,16 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Template</h2> <h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p> <p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
</div> </div>
<div className="form-grid"> <div className="form-grid">
<FormField label="Subject"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
<FormField label="Plain text body"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
<FormField label="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.html_body.77b5ba37"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], event.target.value)} /></FormField>
</div> </div>
</div> </div>);
);
} }
export function AttachmentsStep({ draft, patch }: WizardStepProps) { export function AttachmentsStep({ draft, patch }: WizardStepProps) {
@@ -159,36 +159,36 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Attachments</h2> <h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
<p>Configure campaign-wide attachment behavior and global matching rules.</p> <p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
</div> </div>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<FormField label="Campaign attachment base path"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField> <FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
<FormField label="Missing behavior"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "ask")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Ambiguous behavior"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField> <FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} /> <ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
</div> </div>
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} /> <JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
</div> </div>);
);
} }
export function ReviewStep({ version, onValidate }: { version: unknown; onValidate: () => void }) { export function ReviewStep({ version, onValidate }: {version: unknown;onValidate: () => void;}) {
const record = asRecord(version); const record = asRecord(version);
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Review setup</h2> <h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
<p>Validate the campaign definition before building message drafts.</p> <p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
</div> </div>
<div className="metric-grid inside"> <div className="metric-grid inside">
<MetricCard label="Errors" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" /> <MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
<MetricCard label="Warnings" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" /> <MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
<MetricCard label="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" /> <MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
</div> </div>
<Button variant="primary" onClick={onValidate}>Validate campaign</Button> <Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
</div> </div>);
);
} }
export function SendStep({ draft, patch }: WizardStepProps) { export function SendStep({ draft, patch }: WizardStepProps) {
@@ -197,19 +197,19 @@ export function SendStep({ draft, patch }: WizardStepProps) {
return ( return (
<div> <div>
<div className="step-intro"> <div className="step-intro">
<h2>Send preparation</h2> <h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
<p>Configure rate limits and prepare the final send workflow.</p> <p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
</div> </div>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField> <FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
</div> </div>
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p> <p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
</div> </div>);
);
} }
function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unknown) => void }) { function JsonEditor({ value, onValid }: {value: unknown;onValid: (value: unknown) => void;}) {
const [text, setText] = useState(stringifyJson(value)); const [text, setText] = useState(stringifyJson(value));
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -228,8 +228,8 @@ function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unkno
return ( return (
<div className="json-edit-block"> <div className="json-edit-block">
<textarea rows={12} value={text} onChange={(event) => change(event.target.value)} /> <textarea rows={12} value={text} onChange={(event) => change(event.target.value)} />
{error ? <p className="form-help danger-text">Invalid JSON: {error}</p> : <p className="form-help">Valid JSON is saved with the wizard draft.</p>} {error ? <p className="form-help danger-text">i18n:govoplan-campaign.invalid_json.03f61ae0 {error}</p> : <p className="form-help">i18n:govoplan-campaign.valid_json_is_saved_with_the_wizard_draft.16f5d341</p>}
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>} {Array.isArray(value) && value.length > 0 && <p className="form-help">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
</div> </div>);
);
} }

View File

@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types"; import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns"; import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -10,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui"; import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView"; import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = { type OperatorRow = {
@@ -25,29 +24,40 @@ type OperatorRow = {
needsAttention: number; needsAttention: number;
}; };
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) { export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
const navigate = useNavigate(); const navigate = useGuardedNavigate();
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const [rows, setRows] = useState<OperatorRow[]>([]); const [rows, setRows] = useState<OperatorRow[]>([]);
const campaignsRef = useRef<CampaignListItem[]>([]);
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey]); const settingsKey = useMemo(
() => JSON.stringify({
apiBaseUrl: settings.apiBaseUrl,
apiKey: settings.apiKey,
accessToken: settings.accessToken
}),
[settings.apiBaseUrl, settings.apiKey, settings.accessToken]
);
useEffect(() => {
campaignsRef.current = [];
summariesRef.current = {};
resetDeltaWatermark();
void load();
}, [settingsKey, resetDeltaWatermark]);
async function load() { async function load() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const campaigns = await listCampaigns(settings); const campaigns = await loadCampaignsDelta();
const summaries = await Promise.all(campaigns.map(async (campaign) => { const summaries = await loadCampaignSummariesDelta(campaigns);
try { setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
return await getCampaignSummary(settings, campaign.id);
} catch {
return null;
}
}));
setRows(campaigns.map((campaign, index) => toRow(campaign, summaries[index] ?? null)));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@@ -61,11 +71,12 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
setError(""); setError("");
setMessage(""); setMessage("");
try { try {
const response = action === "retry" const response = action === "retry" ?
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
: await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }); await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
const result = asRecord(response.result ?? response); const result = asRecord(response.result ?? response);
setMessage(`${row.campaign.name}: ${humanize(String(result.action ?? action))}, ${String(result.enqueued_count ?? 0)} enqueued.`); setMessage(i18nMessage("i18n:govoplan-campaign.value_value_value_enqueued.35b33f6d", { value0: row.campaign.name, value1: humanize(String(result.action ?? action)), value2: String(result.enqueued_count ?? 0) }));
resetDeltaWatermark(operatorCampaignSummaryKey(row.campaign.id));
await load(); await load();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -74,49 +85,109 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
} }
} }
async function loadCampaignsDelta(): Promise<CampaignListItem[]> {
const key = operatorCampaignListKey();
let nextWatermark = getDeltaWatermark(key);
let campaigns = campaignsRef.current;
let hasMore = false;
do {
const response = await listCampaignsDelta(settings, { since: nextWatermark });
campaigns = mergeDeltaRows(campaigns, response.campaigns, response.deleted, (campaign) => campaign.id, {
deletedResourceType: "campaign",
sort: sortCampaignsByUpdatedDesc
});
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
const campaignIds = new Set(campaigns.map((campaign) => campaign.id));
for (const campaignId of Object.keys(summariesRef.current)) {
if (!campaignIds.has(campaignId)) delete summariesRef.current[campaignId];
}
campaignsRef.current = campaigns;
setDeltaWatermark(key, nextWatermark);
return campaigns;
}
async function loadCampaignSummariesDelta(campaigns: CampaignListItem[]): Promise<Record<string, CampaignSummary | null>> {
const summaries = { ...summariesRef.current };
await Promise.all(campaigns.map(async (campaign) => {
const key = operatorCampaignSummaryKey(campaign.id);
let nextWatermark = getDeltaWatermark(key);
let summary = summaries[campaign.id] ?? null;
let hasMore = false;
try {
do {
const response = await getCampaignWorkspaceDelta(settings, campaign.id, {
includeCurrentVersion: false,
includeVersions: false,
includeSummary: true,
since: nextWatermark
});
if (response.full || response.summary) summary = response.summary;
nextWatermark = response.watermark ?? null;
hasMore = response.has_more;
} while (hasMore);
setDeltaWatermark(key, nextWatermark);
summaries[campaign.id] = summary;
} catch {
summaries[campaign.id] = summary;
}
}));
summariesRef.current = summaries;
return summaries;
}
function operatorCampaignListKey(): string {
return JSON.stringify({ scope: "operator-campaigns", settingsKey });
}
function operatorCampaignSummaryKey(campaignId: string): string {
return JSON.stringify({ scope: "operator-campaign-summary", campaignId, settingsKey });
}
const totals = rows.reduce((acc, row) => ({ const totals = rows.reduce((acc, row) => ({
failed: acc.failed + row.failed, failed: acc.failed + row.failed,
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown, outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
notAttempted: acc.notAttempted + row.notAttempted, notAttempted: acc.notAttempted + row.notAttempted,
queuedOrActive: acc.queuedOrActive + row.queuedOrActive, queuedOrActive: acc.queuedOrActive + row.queuedOrActive,
imapFailed: acc.imapFailed + row.imapFailed, imapFailed: acc.imapFailed + row.imapFailed
}), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 }); }), { failed: 0, outcomeUnknown: 0, notAttempted: 0, queuedOrActive: 0, imapFailed: 0 });
const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [ const columns = useMemo<DataGridColumn<OperatorRow>[]>(() => [
{ id: "campaign", header: "Campaign", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name }, { id: "campaign", header: "i18n:govoplan-campaign.campaign.69390e16", width: "minmax(260px, 1.2fr)", sticky: "start", sortable: true, filterable: true, value: (row) => row.campaign.name },
{ id: "status", header: "Status", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status }, { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.campaign.status} />, value: (row) => row.campaign.status },
{ id: "attention", header: "Attention", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention }, { id: "attention", header: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
{ id: "failed", header: "Failed", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed }, { id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
{ id: "unknown", header: "Unknown", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown }, { id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
{ id: "unattempted", header: "Unattempted", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted }, { id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
{ id: "queued", header: "Queued/active", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive }, { id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
{ id: "updated", header: "Updated", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270, width: 270,
sticky: "end", sticky: "end",
render: (row) => ( render: (row) =>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}> <Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
<ExternalLink /> <ExternalLink />
</Button> </Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button> <Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</Button> <Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div> </div>
)
} }],
], [busy, navigate]); [busy, navigate]);
return ( return (
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Operator queue</PageTitle> <PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
<p>Queue state, retry candidates and reconciliation entry points across accessible campaigns.</p> <p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void load()} disabled={loading}>Refresh</Button> <Button onClick={() => void load()} disabled={loading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
</div> </div>
</div> </div>
@@ -124,30 +195,30 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title"> <section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">Queue pressure</h2> <h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
<QueuePressureGrid items={[ <QueuePressureGrid items={[
{ label: "Failed", value: totals.failed, tone: "danger" }, { label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" }, { label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
{ label: "Unattempted", value: totals.notAttempted, tone: "info" }, { label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" }, { label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" }, { label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
]} /> } />
</section> </section>
<LoadingFrame loading={loading} label="Loading operator queue"> <LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
<Card title="Campaign queues"> <Card title="i18n:govoplan-campaign.campaign_queues.657785f4">
<DataGrid <DataGrid
id="operator-queue-campaigns" id="operator-queue-campaigns"
rows={rows} rows={rows}
columns={columns} columns={columns}
getRowKey={(row) => row.campaign.id} getRowKey={(row) => row.campaign.id}
emptyText="No accessible campaigns." emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
className="data-table compact-table" className="data-table compact-table" />
/>
</Card> </Card>
</LoadingFrame> </LoadingFrame>
</div> </div>);
);
} }
function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow { function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): OperatorRow {
@@ -161,7 +232,7 @@ function toRow(campaign: CampaignListItem, summary: CampaignSummary | null): Ope
queuedOrActive: numberValue(cards.queued_or_active), queuedOrActive: numberValue(cards.queued_or_active),
imapFailed: numberValue(cards.imap_failed), imapFailed: numberValue(cards.imap_failed),
queueable: numberValue(cards.queueable), queueable: numberValue(cards.queueable),
needsAttention: numberValue(cards.needs_attention), needsAttention: numberValue(cards.needs_attention)
}; };
} }
@@ -169,12 +240,18 @@ function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0; return typeof value === "number" && Number.isFinite(value) ? value : 0;
} }
function QueuePressureGrid({ items }: { items: Array<{ label: string; value: number; tone: "neutral" | "good" | "warning" | "danger" | "info" }> }) { function sortCampaignsByUpdatedDesc(left: CampaignListItem, right: CampaignListItem): number {
return ( return String(right.updated_at ?? right.updatedAt ?? right.created_at ?? "").localeCompare(
<div className="metric-grid queue-pressure-grid"> String(left.updated_at ?? left.updatedAt ?? left.created_at ?? "")
{items.map((item) => (
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
))}
</div>
); );
} }
function QueuePressureGrid({ items }: {items: Array<{label: string;value: number;tone: "neutral" | "good" | "warning" | "danger" | "info";}>;}) {
return (
<div className="metric-grid queue-pressure-grid">
{items.map((item) =>
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
)}
</div>);
}

View File

@@ -4,7 +4,8 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui"; import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui"; import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui"; import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui"; import { StatusBadge, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -25,93 +26,98 @@ type TemplateRecord = {
type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions"; type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "usage" | "versions";
const templateRecords: TemplateRecord[] = [ const templateRecords: TemplateRecord[] = [
{ {
id: "monthly-statement", id: "monthly-statement",
name: "Monthly statement", name: "i18n:govoplan-campaign.monthly_statement.74029609",
description: "Reusable subject and body for monthly statement mailings.", description: "i18n:govoplan-campaign.reusable_subject_and_body_for_monthly_statement_.9754240d",
type: "Plain text", type: "plain_text",
status: "ready", status: "ready",
fields: ["recipient_name", "period", "amount"], fields: ["recipient_name", "period", "amount"],
updatedAt: "2026-06-08 16:42", updatedAt: "2026-06-08 16:42",
usedBy: "2 campaigns", usedBy: "i18n:govoplan-campaign.2_campaigns.35b84804",
subject: "Your statement for {{period}}", subject: "i18n:govoplan-campaign.subject_monthly_statement",
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.", body: "i18n:govoplan-campaign.hello_value_please_find_your_statement_for_value.48d94596",
versions: 4 versions: 4
}, },
{ {
id: "deadline-reminder", id: "deadline-reminder",
name: "Deadline reminder", name: "i18n:govoplan-campaign.deadline_reminder.84977a7f",
description: "Short reminder template with one deadline field.", description: "i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110",
type: "Plain text", type: "plain_text",
status: "draft", status: "draft",
fields: ["recipient_name", "deadline"], fields: ["recipient_name", "deadline"],
updatedAt: "2026-06-07 11:18", updatedAt: "2026-06-07 11:18",
usedBy: "Not used yet", usedBy: "i18n:govoplan-campaign.not_used_yet.962b7bbd",
subject: "Reminder: {{deadline}}", subject: "i18n:govoplan-campaign.subject_deadline_reminder",
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.", body: "i18n:govoplan-campaign.hello_value_this_is_a_reminder_that_the_deadline.c4fbdbd0",
versions: 1 versions: 1
}, },
{ {
id: "attachment-notice", id: "attachment-notice",
name: "Attachment notice", name: "i18n:govoplan-campaign.attachment_notice.b73a59fe",
description: "Generic note for campaigns where every recipient receives a file.", description: "i18n:govoplan-campaign.generic_note_for_campaigns_where_every_recipient.f21254fa",
type: "HTML-ready", type: "html_ready",
status: "ready", status: "ready",
fields: ["recipient_name", "file_label", "contact_email"], fields: ["recipient_name", "file_label", "contact_email"],
updatedAt: "2026-06-05 09:05", updatedAt: "2026-06-05 09:05",
usedBy: "1 campaign", usedBy: "i18n:govoplan-campaign.1_campaign.ccd70074",
subject: "Documents for {{recipient_name}}", subject: "i18n:govoplan-campaign.subject_documents_for_recipient",
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.", body: "i18n:govoplan-campaign.hello_value_your_value_is_attached_please_contac.da32415e",
versions: 3 versions: 3
} }];
];
const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [ const templateSubnav = (onBack: () => void): ModuleSubnavGroup<TemplateDetailSection>[] => [
{ {
items: [{ actionId: "template-library", label: "← Template library", primary: true, onClick: onBack }] items: [{ actionId: "template-library", label: "i18n:govoplan-campaign.template_library.6742c898", primary: true, onClick: onBack }]
}, },
{ {
title: "TEMPLATE", title: "i18n:govoplan-campaign.template.4c31d4ef",
items: [ items: [
{ id: "overview", label: "Overview" }, { id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b" },
{ id: "content", label: "Content" }, { id: "content", label: "i18n:govoplan-campaign.content.4f9be057" },
{ id: "fields", label: "Fields" }, { id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
{ id: "preview", label: "Preview" }, { id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
{ id: "usage", label: "Usage" }, { id: "usage", label: "i18n:govoplan-campaign.usage.0bb18642" },
{ id: "versions", label: "Versions" } { id: "versions", label: "i18n:govoplan-campaign.versions.a239107e" }]
]
} }];
];
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] { function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
return [ return [
{ id: "template", header: "Template", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` }, { id: "template", header: "i18n:govoplan-campaign.template.3ec1ae06", width: "minmax(240px, 1.4fr)", sortable: true, filterable: true, sticky: "start", render: (template) => <div className="module-title-cell"><strong>{template.name}</strong><span>{template.description}</span></div>, value: (template) => `${template.name} ${template.description}` },
{ id: "type", header: "Type", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Campaign", label: "Campaign" }, { value: "Notification", label: "Notification" }, { value: "Attachment", label: "Attachment" }] }, value: (template) => template.type }, { id: "type", header: "i18n:govoplan-campaign.type.3deb7456", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "plain_text", label: "i18n:govoplan-campaign.plain_text_template_type" }, { value: "html_ready", label: "i18n:govoplan-campaign.html_ready_template_type" }] }, value: (template) => template.type },
{ id: "fields", header: "Fields", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") }, { id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
{ id: "updated", header: "Updated", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt }, { id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "archived", label: "Archived" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status }, { id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" }, { value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
{ {
id: "actions", id: "actions",
header: "Actions", header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 70, width: 70,
sticky: "end", sticky: "end",
align: "right", align: "right",
render: (template) => ( render: (template) =>
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}> <Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}>
<ExternalLink /> <ExternalLink />
</Button> </Button>
)
} }];
];
} }
function templateFieldColumns(): DataGridColumn<{ id: string; field: string; source: string; required: string }>[] { function templateTypeLabel(type: string): string {
if (type === "html_ready") return "i18n:govoplan-campaign.html_ready_template_type";
return "i18n:govoplan-campaign.plain_text_template_type";
}
function templateFieldColumns(): DataGridColumn<{id: string;field: string;source: string;required: string;}>[] {
return [ return [
{ id: "field", header: "Field", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field }, { id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "Source", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Detected placeholder", label: "Detected placeholder" }] }, value: (row) => row.source }, { id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "Required", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Yes", label: "Yes" }, { value: "No", label: "No" }] }, value: (row) => row.required }, { id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required },
{ id: "actions", header: "Actions", width: 130, sticky: "end", render: () => <Button disabled>Configure</Button> } { id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
];
} }
export default function TemplatesPage() { export default function TemplatesPage() {
@@ -139,8 +145,8 @@ export default function TemplatesPage() {
<p>{selectedTemplate.description}</p> <p>{selectedTemplate.description}</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button disabled>Duplicate</Button> <Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
<Button variant="primary" disabled>Use in campaign</Button> <Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
</div> </div>
</div> </div>
@@ -152,133 +158,144 @@ export default function TemplatesPage() {
{active === "versions" && <TemplateVersions template={selectedTemplate} />} {active === "versions" && <TemplateVersions template={selectedTemplate} />}
</div> </div>
</section> </section>
</div> </div>);
);
} }
return ( return (
<div className="content-pad workspace-data-page module-entry-page"> <div className="content-pad workspace-data-page module-entry-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle>Templates</PageTitle> <PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p> <p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button disabled>Import</Button> <Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>Export</Button> <Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
</div> </div>
</div> </div>
<Card <Card
title={ title={
<div className="module-card-heading"> <div className="module-card-heading">
<h2>All templates</h2> <h2>i18n:govoplan-campaign.all_templates.0bba114c</h2>
<span>Last loaded: local demo data</span> <span>i18n:govoplan-campaign.last_loaded_local_demo_data.62ee2f5d</span>
</div> </div>
} }
actions={<Button disabled>Refresh</Button>} actions={<Button disabled>i18n:govoplan-campaign.refresh.56e3badc</Button>}>
>
<DataGrid <DataGrid
id="template-library" id="template-library"
rows={templateRecords} rows={templateRecords}
columns={templateLibraryColumns(openTemplate)} columns={templateLibraryColumns(openTemplate)}
getRowKey={(template) => template.id} getRowKey={(template) => template.id}
emptyText="No templates found." emptyText="i18n:govoplan-campaign.no_templates_found.326abcdd"
className="compact-table-wrap module-table-wrap module-entry-table" className="compact-table-wrap module-table-wrap module-entry-table" />
/>
</Card> </Card>
</div> </div>);
);
} }
function TemplateOverview({ template }: { template: TemplateRecord }) { function TemplateOverview({ template }: {template: TemplateRecord;}) {
return ( return (
<div className="dashboard-grid settings-dashboard-grid"> <div className="dashboard-grid settings-dashboard-grid">
<Card title="Template status"> <Card title="i18n:govoplan-campaign.template_status.8bec97cf">
<dl className="detail-list compact-detail-list"> <dl className="detail-list compact-detail-list">
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div> <div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
<div><dt>Type</dt><dd>{template.type}</dd></div> <div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div> <div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
<div><dt>Versions</dt><dd>{template.versions}</dd></div> <div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
</dl> </dl>
</Card> </Card>
<Card title="Compatibility notes"> <Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
<p className="muted">Campaigns can reuse this template, but each campaign still needs a field matching check because campaign fields and template placeholders can drift.</p> <p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
<div className="placeholder-stack"> <div className="placeholder-stack">
<span>Subject placeholders are detected</span> <span>i18n:govoplan-campaign.subject_placeholders_are_detected.96663276</span>
<span>Body placeholders are compared with campaign fields</span> <span>i18n:govoplan-campaign.body_placeholders_are_compared_with_campaign_fie.bbd1d67f</span>
<span>A mapping wizard can be added later</span> <span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
</div> </div>
</Card> </Card>
</div> </div>);
);
} }
function TemplateContent({ template }: { template: TemplateRecord }) { function TemplateContent({ template }: {template: TemplateRecord;}) {
const { translateText } = usePlatformLanguage();
const subject = translateText(template.subject);
const body = translateText(template.body);
return ( return (
<Card title="Template content" actions={<Button disabled>Save changes</Button>}> <Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
<div className="form-grid compact responsive-form-grid"> <div className="form-grid compact responsive-form-grid">
<label className="form-field"><FieldLabel className="form-label" help="Read-only subject stored in this reusable template record.">Subject</FieldLabel><input value={template.subject} readOnly /></label> <label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
<label className="form-field full-span"><FieldLabel className="form-label" help="Read-only body stored in this reusable template record.">Body</FieldLabel><textarea rows={10} value={template.body} readOnly /></label> <label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
</div> </div>
</Card> </Card>);
);
} }
function TemplateFields({ template }: { template: TemplateRecord }) { function TemplateFields({ template }: {template: TemplateRecord;}) {
return ( return (
<Card title="Template fields" actions={<Button disabled>Add field hint</Button>}> <Card title="i18n:govoplan-campaign.template_fields.e6d7d8ac" actions={<Button disabled>i18n:govoplan-campaign.add_field_hint.b5029047</Button>}>
<DataGrid <DataGrid
id={`template-${template.id}-fields`} id={`template-${template.id}-fields`}
rows={template.fields.map((field) => ({ id: field, field, source: "Detected placeholder", required: "Yes" }))} rows={template.fields.map((field) => ({ id: field, field, source: "detected_placeholder", required: "yes" }))}
columns={templateFieldColumns()} columns={templateFieldColumns()}
getRowKey={(field) => field.id} getRowKey={(field) => field.id}
emptyText="No fields detected." emptyText="i18n:govoplan-campaign.no_fields_detected.0cb30100"
className="compact-table-wrap module-table-wrap module-table" className="compact-table-wrap module-table-wrap module-table" />
/>
</Card> </Card>);
);
} }
function TemplatePreview({ template }: { template: TemplateRecord }) { function TemplatePreview({ template }: {template: TemplateRecord;}) {
const preview = template.body const { translateText } = usePlatformLanguage();
.replace(/\{\{recipient_name\}\}/g, "Jane Example") const subject = translateText(template.subject);
.replace(/\{\{period\}\}/g, "May 2026") const body = translateText(template.body);
.replace(/\{\{amount\}\}/g, "123.45 EUR") const sampleRecipientName = translateText("i18n:govoplan-campaign.sample_recipient_name");
.replace(/\{\{deadline\}\}/g, "30 June 2026") const samplePeriod = translateText("i18n:govoplan-campaign.sample_period");
.replace(/\{\{file_label\}\}/g, "statement") const sampleAmount = translateText("i18n:govoplan-campaign.sample_amount");
.replace(/\{\{contact_email\}\}/g, "support@example.org"); const sampleDeadline = translateText("i18n:govoplan-campaign.sample_deadline");
const sampleFileLabel = translateText("i18n:govoplan-campaign.sample_file_label");
const preview = body.
replace(/\{\{recipient_name\}\}/g, sampleRecipientName).
replace(/\{\{period\}\}/g, samplePeriod).
replace(/\{\{amount\}\}/g, sampleAmount).
replace(/\{\{deadline\}\}/g, sampleDeadline).
replace(/\{\{file_label\}\}/g, sampleFileLabel).
replace(/\{\{contact_email\}\}/g, "support@example.org");
return ( return (
<Card title="Preview" actions={<Button disabled>Change sample data</Button>}> <Card title="i18n:govoplan-campaign.preview.f1fbb2b4" actions={<Button disabled>i18n:govoplan-campaign.change_sample_data.e6903542</Button>}>
<div className="message-preview"> <div className="message-preview">
<strong>{template.subject.replace(/\{\{period\}\}/g, "May 2026").replace(/\{\{deadline\}\}/g, "30 June 2026").replace(/\{\{recipient_name\}\}/g, "Jane Example")}</strong> <strong>{subject.replace(/\{\{period\}\}/g, samplePeriod).replace(/\{\{deadline\}\}/g, sampleDeadline).replace(/\{\{recipient_name\}\}/g, sampleRecipientName)}</strong>
<pre>{preview}</pre> <pre>{preview}</pre>
</div> </div>
</Card> </Card>);
);
} }
function TemplateUsage({ template }: { template: TemplateRecord }) { function TemplateUsage({ template }: {template: TemplateRecord;}) {
return ( return (
<Card title="Usage"> <Card title="i18n:govoplan-campaign.usage.0bb18642">
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p> <p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
<dl className="detail-list compact-detail-list"> <dl className="detail-list compact-detail-list">
<div><dt>Currently used by</dt><dd>{template.usedBy}</dd></div> <div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
<div><dt>Safe to edit</dt><dd>Requires versioning once templates are shared</dd></div> <div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
</dl> </dl>
</Card> </Card>);
);
} }
function TemplateVersions({ template }: { template: TemplateRecord }) { function TemplateVersions({ template }: {template: TemplateRecord;}) {
return ( return (
<Card title="Versions and import/export" actions={<div className="button-row compact-actions"><Button disabled>Import</Button><Button disabled>Export</Button></div>}> <Card title="i18n:govoplan-campaign.versions_and_import_export.cc05cb43" actions={<div className="button-row compact-actions"><Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button><Button disabled>i18n:govoplan-campaign.export.f3e4fadb</Button></div>}>
<div className="placeholder-stack"> <div className="placeholder-stack">
<span>{template.versions} local versions in the planned model</span> <span>{template.versions} i18n:govoplan-campaign.local_versions_in_the_planned_model.c1bf26cb</span>
<span>Sent campaigns should keep a fixed template snapshot</span> <span>i18n:govoplan-campaign.sent_campaigns_should_keep_a_fixed_template_snap.167d56c2</span>
<span>Draft campaigns can update to a newer template version later</span> <span>i18n:govoplan-campaign.draft_campaigns_can_update_to_a_newer_template_v.0f854608</span>
</div> </div>
</Card> </Card>);
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
import "./styles/campaign-workspace.css";
export { default } from "./module"; export { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/campaigns"; export * from "./api/campaigns";

View File

@@ -2,58 +2,58 @@ import type { CampaignWorkspaceSection } from "../types";
import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
{ {
items: [{ id: "overview", label: "Overview", primary: true }] items: [{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b", primary: true }]
}, },
{ {
title: "CAMPAIGN", title: "i18n:govoplan-campaign.campaign.9e28fcfc",
items: [ items: [
{ id: "fields", label: "Fields" }, { id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
{ id: "files", label: "Attachments" }, { id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
{ id: "recipients", label: "Sender & Recipients" }, { id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
{ id: "recipient-data", label: "Recipient data" }, { id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
{ id: "template", label: "Template" } { id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
]
}, },
{ {
title: "SETTINGS", title: "i18n:govoplan-campaign.settings.c4b92432",
items: [ items: [
{ id: "mail-settings", label: "Mail settings" }, { id: "mail-settings", label: "i18n:govoplan-campaign.mail_settings.19e07f55" },
{ id: "global-settings", label: "Campaign settings" } { id: "global-settings", label: "i18n:govoplan-campaign.campaign_settings.efffec26" }]
]
}, },
{ {
title: "POLICIES", title: "i18n:govoplan-campaign.policies.f03ff937",
items: [ items: [
{ id: "mail-policy", label: "Mail policy" }, { id: "mail-policy", label: "i18n:govoplan-campaign.mail_policy.3eb5d32a" },
{ id: "policies", label: "Campaign policies" } { id: "policies", label: "i18n:govoplan-campaign.campaign_policies.0b5de1f5" }]
]
}, },
{ {
title: "SEND", title: "i18n:govoplan-campaign.send.a2469c47",
items: [ items: [
{ id: "review", label: "Review & Send" } { id: "review", label: "i18n:govoplan-campaign.review_send.1627617d" }]
]
}, },
{ {
title: "REPORT", title: "i18n:govoplan-campaign.report.7b8ddb90",
items: [ items: [
{ id: "report", label: "Report" }, { id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
{ id: "audit", label: "Audit log" } { id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
]
}, },
{ {
title: "ADVANCED", title: "i18n:govoplan-campaign.advanced.6052c880",
items: [{ id: "json", label: "JSON", subtle: true }] items: [{ id: "json", label: "i18n:govoplan-campaign.json.031a4e76", subtle: true }]
} }];
];
export default function SectionSidebar({ export default function SectionSidebar({
active, active,
onSelect onSelect
}: {
active: CampaignWorkspaceSection;
onSelect: (section: CampaignWorkspaceSection) => void;
}) { }: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />; return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
} }

View File

@@ -2,6 +2,8 @@ import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui"; import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns"; import { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage")); const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage")); const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
@@ -11,37 +13,42 @@ const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"]; const campaignRead = ["campaigns:campaign:read"];
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"]; const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const campaignModule: PlatformWebModule = { export const campaignModule: PlatformWebModule = {
id: "campaigns", id: "campaigns",
label: "Campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["files", "mail"], optionalDependencies: ["files", "mail"],
translations,
navItems: [ navItems: [
{ to: "/campaigns", label: "Campaigns", iconName: "campaign", anyOf: campaignRead, order: 20 }, { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 },
{ {
to: "/operator", to: "/operator",
label: "Operator Queue", label: "i18n:govoplan-campaign.operator_queue.ddf23260",
iconName: "activity", iconName: "activity",
anyOf: operatorScopes, anyOf: operatorScopes,
order: 30 order: 30
}, },
{ to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 }, { to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "Address Book", iconName: "users", order: 80 }, { to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
{ to: "/templates", label: "Templates", iconName: "form", order: 90 } { to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "form", order: 90 }],
],
routes: [ routes: [
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) }, { path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) }, { path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) }, { path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) }, { path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) } { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
]
}; };
function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const { campaignId = "" } = useParams(); const { campaignId = "" } = useParams();
const tenantId = (auth.active_tenant ?? auth.tenant).id; const tenantId = (auth.active_tenant ?? auth.tenant).id;
const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, tenantId]); const probe = useCallback(() => getCampaign(settings, campaignId), [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, tenantId]);
@@ -57,14 +64,14 @@ function CampaignResourceRoute({ settings, auth }: { settings: ApiSettings; auth
function ReportsPage() { function ReportsPage() {
return createElement( return createElement(
"div", "div",
{ className: "content-pad" }, { className: "content-pad workspace-data-page" },
createElement( createElement(
"div", "div",
{ className: "page-heading" }, { className: "page-heading workspace-heading" },
createElement("h1", null, "Reports"), createElement("h1", null, "i18n:govoplan-campaign.reports"),
createElement("p", null, "This module is prepared but not implemented yet.") createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared")
), ),
createElement(Card, null, createElement("p", { className: "muted" }, "Next passes will add functionality here.")) createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here"))
); );
} }

View File

@@ -1,16 +1,4 @@
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */ /* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
.workspace-data-page .card { margin-bottom: 18px; }
.workspace-data-page > .workspace-heading {
position: sticky;
top: 0;
z-index: 75;
background: var(--bg, #f8f7f4);
border-bottom: 1px solid var(--line);
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
margin: -28px -34px 22px;
padding: 18px 34px 16px;
}
.workspace-heading .mono-small { margin-top: 8px; }
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; } .alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; } .alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
.small-note { font-size: 12px; } .small-note { font-size: 12px; }
@@ -177,16 +165,6 @@
grid-template-columns: repeat(2, minmax(180px, 1fr)); grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 8px 16px; gap: 8px 16px;
} }
.mail-server-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(280px, 1fr));
gap: 22px;
}
.mail-server-subsection {
border-top: 0;
padding-top: 0;
align-content: start;
}
.form-span-full { .form-span-full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -238,7 +216,6 @@
.additional-global-values-table td:nth-child(1) { width: 260px; } .additional-global-values-table td:nth-child(1) { width: 260px; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.mail-server-settings-grid { grid-template-columns: 1fr; }
.toggle-grid { grid-template-columns: 1fr; } .toggle-grid { grid-template-columns: 1fr; }
} }
@@ -328,19 +305,9 @@
margin-top: 12px; margin-top: 12px;
} }
.campaigns-page {
padding: 28px 34px;
}
.campaigns-page > .alert { .campaigns-page > .alert {
margin: 0 0 18px; margin: 0 0 18px;
} }
.campaigns-page .card {
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
background: var(--panel);
overflow: hidden;
}
.campaigns-page .card-header { .campaigns-page .card-header {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -352,10 +319,6 @@
border-radius: var(--radius) var(--radius) 0 0; border-radius: var(--radius) var(--radius) 0 0;
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75); box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
} }
.campaigns-page .card-header::after {
left: 12px;
right: 12px;
}
.campaigns-page .card-body { .campaigns-page .card-body {
padding: 0; padding: 0;
} }
@@ -426,17 +389,7 @@
justify-content: center; justify-content: center;
} }
@media (max-width: 900px) { /* Module entry/detail pages. */
.campaigns-page {
padding: 18px;
}
}
/* Module entry/detail pages (Templates, Files). */
.module-entry-page {
max-width: none;
}
.module-card-heading { .module-card-heading {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -609,33 +562,7 @@
/* Template editor refinements. */ /* Template editor refinements. */
.template-body-mode { .template-body-mode {
display: inline-flex;
gap: 4px;
width: fit-content; width: fit-content;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
padding: 4px;
}
.template-body-mode button {
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 700;
padding: 7px 12px;
}
.template-body-mode button.active {
background: #fff;
color: var(--text-strong);
box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.template-body-mode button:disabled {
cursor: not-allowed;
opacity: .58;
} }
.template-editor-mode { .template-editor-mode {
margin-top: -2px; margin-top: -2px;
@@ -785,12 +712,6 @@
.recipient-add-row .email-address-input { .recipient-add-row .email-address-input {
max-width: 720px; max-width: 720px;
} }
.unsaved-changes-dialog {
max-width: 520px;
}
.unsaved-changes-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.campaign-header-grid, .campaign-header-grid,
@@ -1052,37 +973,6 @@
.recipient-data-table td:nth-child(3) { min-width: 192px; } .recipient-data-table td:nth-child(3) { min-width: 192px; }
.recipient-index-cell { white-space: nowrap; } .recipient-index-cell { white-space: nowrap; }
/* Admin overview and address book mock module. */
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.admin-overview-link {
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
text-align: left;
cursor: pointer;
color: var(--text);
}
.admin-overview-link:hover {
background: #fff;
border-color: var(--line-dark);
}
.admin-overview-link strong {
display: block;
color: var(--text-strong);
margin-bottom: 5px;
}
.admin-overview-link span {
display: block;
color: var(--muted);
font-size: 13px;
line-height: 1.35;
}
.address-book-scope-list { .address-book-scope-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1674,6 +1564,77 @@
color: var(--muted); color: var(--muted);
} }
.deliverability-preflight {
display: grid;
gap: 10px;
margin: 0 0 16px;
}
.deliverability-preflight-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.deliverability-preflight-header h3 {
margin: 0;
color: var(--text-strong);
font-size: 14px;
}
.deliverability-preflight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.deliverability-preflight-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-left: 3px solid var(--blue);
border-radius: 6px;
background: var(--panel-soft);
}
.deliverability-preflight-item[data-state="ready"] {
border-left-color: var(--green);
}
.deliverability-preflight-item[data-state="warning"] {
border-left-color: var(--amber);
}
.deliverability-preflight-item[data-state="blocked"] {
border-left-color: var(--red);
}
.deliverability-preflight-item > div {
display: grid;
gap: 5px;
min-width: 0;
}
.deliverability-preflight-item span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
}
.deliverability-preflight-item strong {
min-width: 0;
color: var(--text-strong);
font-size: 13px;
line-height: 1.35;
}
.review-flow-stage-actions { .review-flow-stage-actions {
margin-bottom: 16px; margin-bottom: 16px;
} }
@@ -1739,6 +1700,15 @@
.review-flow-execution-summary { .review-flow-execution-summary {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.deliverability-preflight-header {
align-items: flex-start;
flex-direction: column;
}
.deliverability-preflight-grid {
grid-template-columns: 1fr;
}
} }
/* Collapsible workflow stage headers keep status and disclosure controls grouped. */ /* Collapsible workflow stage headers keep status and disclosure controls grouped. */
@@ -1980,122 +1950,71 @@
word-break: break-word; word-break: break-word;
} }
/* Administration foundation ------------------------------------------------ */ .recipient-outcome-cell {
.admin-dialog {
width: min(680px, calc(100vw - 40px));
}
.admin-dialog-wide {
width: min(1040px, calc(100vw - 40px));
}
.admin-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
.admin-dialog > .dialog-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
}
.admin-form-grid {
display: grid;
gap: 14px;
}
.admin-form-grid.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-assignment-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 18px;
}
.admin-selection-list {
display: grid;
gap: 6px;
max-height: 300px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
}
.admin-selection-item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 8px 9px;
border-radius: 7px;
cursor: pointer;
}
.admin-selection-item:hover {
background: var(--surface, #fff);
}
.admin-selection-item.disabled {
opacity: .52;
cursor: not-allowed;
}
.admin-selection-item input {
margin-top: 3px;
}
.admin-selection-item span {
display: grid; display: grid;
gap: 3px; gap: 3px;
}
.admin-selection-item small {
color: var(--muted);
line-height: 1.35;
}
.admin-selection-item small code {
display: inline-block;
margin-left: 6px;
font-size: 11px;
}
.admin-inline-check {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.admin-permission-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}
.admin-permission-group {
display: grid;
align-content: start;
gap: 4px;
min-width: 0; min-width: 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
} }
.admin-permission-group legend { .recipient-outcome-cell strong,
padding: 0 6px; .recipient-outcome-cell span {
font-weight: 700; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.recipient-outcome-cell span {
color: var(--muted);
font-size: 12px;
}
.recipient-outcome-error {
color: var(--danger, #b91c1c);
}
.attempt-history-section {
display: grid;
gap: 8px;
}
.attempt-history-section h3 {
margin: 0;
}
.attempt-history-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
}
.attempt-history-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.attempt-history-table th,
.attempt-history-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.attempt-history-table th {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.attempt-history-table tr:last-child td {
border-bottom: 0;
}
.attempt-history-table td:last-child {
max-width: 420px;
overflow-wrap: anywhere;
} }
.admin-secret { .admin-secret {
@@ -2127,45 +2046,6 @@
font-size: 12px; font-size: 12px;
} }
@media (max-width: 850px) {
.admin-form-grid.two-columns,
.admin-assignment-grid,
.admin-permission-groups {
grid-template-columns: 1fr;
}
}
/* Administration visual alignment ---------------------------------------- */
.admin-section-page {
display: grid;
gap: 16px;
width: 100%;
max-width: 100%;
min-width: 0;
}
.admin-section-page > .loading-frame {
width: 100%;
max-width: 100%;
min-width: 0;
}
.admin-page-heading {
margin-bottom: 0;
}
.admin-page-actions {
align-self: start;
}
.admin-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.admin-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
}
.recipient-import-modal { .recipient-import-modal {
width: min(1100px, calc(100vw - 32px)); width: min(1100px, calc(100vw - 32px));
} }
@@ -2394,40 +2274,10 @@
min-height: 300px; min-height: 300px;
} }
} }
.card-body > .admin-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .admin-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.campaigns-page .card-body > .admin-table-surface:only-child { .campaigns-page .card-body > .admin-table-surface:only-child {
margin: 0; margin: 0;
width: 100%; width: 100%;
} }
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 36px;
justify-content: end;
gap: 5px;
width: 100%;
}
.admin-icon-button.btn {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
min-width: 36px;
padding: 0;
}
.admin-icon-button.btn svg {
width: 17px;
height: 17px;
}
.admin-audit-grid .data-grid-scroll-region { .admin-audit-grid .data-grid-scroll-region {
max-height: min(68vh, 720px); max-height: min(68vh, 720px);
} }
@@ -2499,21 +2349,6 @@
line-height: 1.45; line-height: 1.45;
} }
.admin-scope-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.admin-scope-list code {
padding: 5px 8px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-muted, #f6f7f9);
font-size: 12px;
}
.campaign-access-dialog > .dialog-header, .campaign-access-dialog > .dialog-header,
.campaign-access-dialog > .dialog-footer { .campaign-access-dialog > .dialog-footer {
background: var(--surface); background: var(--surface);
@@ -2523,27 +2358,6 @@
gap: 12px; gap: 12px;
} }
/* Administration scope separation and ownership safeguards ---------------- */
.admin-overview-section-label {
margin: 2px 0 14px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: .05em;
text-transform: uppercase;
}
.admin-protection-note {
margin: 4px 0 0;
padding: 10px 12px;
border: 1px solid #d8bd78;
border-radius: var(--radius-sm);
background: #fff8e6;
color: #6f5719;
font-size: 13px;
line-height: 1.45;
}
/* Campaign policy tables. */ /* Campaign policy tables. */
.campaign-policy-row { .campaign-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr); grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr);

View File

@@ -1 +1 @@
export type { ApiSettings, AuthInfo, CampaignListItem, CampaignWorkspaceSection, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule, WizardStep } from "@govoplan/core-webui"; export type { ApiSettings, AuthInfo, CampaignListItem, CampaignWorkspaceSection, DeltaDeletedItem, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule, WizardStep } from "@govoplan/core-webui";