chore: sync GovOPlaN module split state
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -331,3 +331,4 @@ cython_debug/
|
||||
# GovOPlaN WebUI test output
|
||||
webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
|
||||
@@ -77,6 +77,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||
## 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.
|
||||
- [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
|
||||
|
||||
|
||||
11
dev/mail-testbed/.env.example
Normal file
11
dev/mail-testbed/.env.example
Normal 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
|
||||
69
dev/mail-testbed/README.md
Normal file
69
dev/mail-testbed/README.md
Normal 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
|
||||
```
|
||||
17
dev/mail-testbed/docker-compose.yml
Normal file
17
dev/mail-testbed/docker-compose.yml
Normal 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"
|
||||
167
dev/mail-testbed/run_transport_smoke.py
Normal file
167
dev/mail-testbed/run_transport_smoke.py
Normal 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())
|
||||
@@ -15,6 +15,8 @@ been validated, built, reviewed, and locked.
|
||||
## Before First Live Use
|
||||
|
||||
- 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.
|
||||
- Confirm policy allows the SMTP host, envelope sender, recipients, and optional
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
- Reconciled accepted jobs must remain protected from resend.
|
||||
- Reconciled not-sent jobs must appear only as explicit retry candidates.
|
||||
- The final report should include SMTP attempts, IMAP append attempts, and any
|
||||
reconciliation notes before the campaign is considered operationally closed.
|
||||
- The final CSV export should include message id, resolved envelope headers,
|
||||
attachment evidence, EML reference/checksum, latest SMTP response/error, and
|
||||
latest IMAP folder/error before the campaign is considered operationally
|
||||
closed.
|
||||
|
||||
76
docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md
Normal file
76
docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md
Normal 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.
|
||||
64
docs/RECIPIENT_ADDRESS_BOUNDARY.md
Normal file
64
docs/RECIPIENT_ADDRESS_BOUNDARY.md
Normal 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.
|
||||
87
docs/RECIPIENT_IMPORT_GUIDE.md
Normal file
87
docs/RECIPIENT_IMPORT_GUIDE.md
Normal 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
45
examples/README.md
Normal 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`.
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
|
||||
@@ -15,7 +15,7 @@ dependencies = [
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
"pyzipper>=0.3,<1",
|
||||
"pyzipper>=0.4,<1",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
350
src/govoplan_campaign/backend/change_tracking.py
Normal file
350
src/govoplan_campaign/backend/change_tracking.py
Normal 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",
|
||||
]
|
||||
@@ -108,10 +108,10 @@ class Campaign(Base, TimestampMixin):
|
||||
__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)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("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)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("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)
|
||||
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("access_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("access_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
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"),)
|
||||
|
||||
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)
|
||||
target_type: Mapped[str] = mapped_column(String(20), 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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -148,8 +148,8 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
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)
|
||||
owner_user_id: Mapped[str] = mapped_column(ForeignKey("users.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("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
column_count: Mapped[int] = mapped_column(Integer, 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))
|
||||
published_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
|
||||
# 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.
|
||||
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_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)
|
||||
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"),)
|
||||
|
||||
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_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)
|
||||
@@ -263,7 +263,7 @@ class CampaignIssue(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_issues"
|
||||
|
||||
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_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)
|
||||
@@ -279,7 +279,7 @@ class AttachmentBlob(Base, TimestampMixin):
|
||||
__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)
|
||||
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)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||
@@ -291,8 +291,8 @@ class AttachmentInstance(Base, TimestampMixin):
|
||||
__tablename__ = "attachment_instances"
|
||||
|
||||
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)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, 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("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)
|
||||
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))
|
||||
|
||||
@@ -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.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
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
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""recipient import mapping profiles
|
||||
|
||||
Revision ID: 2c3d4e5f7081
|
||||
Revises: 1b2c3d4e5f70
|
||||
Revises: 2e3f4a5b6c7d
|
||||
Create Date: 2026-06-26 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -11,7 +11,7 @@ import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "2c3d4e5f7081"
|
||||
down_revision = "1b2c3d4e5f70"
|
||||
down_revision = "2e3f4a5b6c7d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
@@ -38,8 +38,8 @@ def upgrade() -> None:
|
||||
sa.Column("mappings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_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(["tenant_id"], ["tenants.id"], name=op.f("fk_campaign_recipient_import_mapping_profiles_tenant_id_tenants"), 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"], ["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")),
|
||||
)
|
||||
op.create_index(op.f("ix_campaign_recipient_import_mapping_profiles_tenant_id"), "campaign_recipient_import_mapping_profiles", ["tenant_id"], unique=False)
|
||||
|
||||
@@ -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(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -401,13 +494,47 @@ def generate_jobs_csv(
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.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 = [
|
||||
"job_id",
|
||||
"campaign_id",
|
||||
"campaign_version_id",
|
||||
"entry_index",
|
||||
"entry_id",
|
||||
"recipient_email",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply_to",
|
||||
"subject",
|
||||
"message_id_header",
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
@@ -422,9 +549,26 @@ def generate_jobs_csv(
|
||||
"last_error",
|
||||
"eml_size_bytes",
|
||||
"eml_sha256",
|
||||
"eml_storage_key",
|
||||
"eml_local_path",
|
||||
"issues_count",
|
||||
"attachment_config_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()
|
||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||
|
||||
|
||||
@@ -145,6 +146,21 @@ class CampaignWorkspaceResponse(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -257,11 +273,20 @@ class CampaignJobsResponse(BaseModel):
|
||||
total: int = 0
|
||||
total_unfiltered: int = 0
|
||||
pages: int = 0
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
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)
|
||||
|
||||
|
||||
class CampaignJobsDeltaResponse(CampaignJobsResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CampaignJobDetailResponse(BaseModel):
|
||||
job: dict[str, Any]
|
||||
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
104
tests/test_campaign_reports.py
Normal file
104
tests/test_campaign_reports.py
Normal 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
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.6",
|
||||
"lucide-react": "^0.555.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiSettings, CampaignListItem } from "../types";
|
||||
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||
import { apiDownload, apiFetch } from "./client";
|
||||
|
||||
export type CampaignListResponse =
|
||||
| CampaignListItem[]
|
||||
| {
|
||||
CampaignListItem[] |
|
||||
{
|
||||
campaigns?: CampaignListItem[];
|
||||
items?: CampaignListItem[];
|
||||
results?: CampaignListItem[];
|
||||
@@ -20,8 +20,8 @@ export type CampaignShare = {
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
|
||||
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
|
||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||
|
||||
export type CampaignUpdatePayload = {
|
||||
external_id?: string | null;
|
||||
@@ -85,11 +85,28 @@ export type CampaignWorkspaceResponse = {
|
||||
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 = {
|
||||
versionId?: string | null;
|
||||
includeCurrentVersion?: boolean;
|
||||
includeSummary?: 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";
|
||||
@@ -279,6 +296,7 @@ export type CampaignJobsQuery = {
|
||||
versionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
sendStatus?: string[];
|
||||
validationStatus?: string[];
|
||||
imapStatus?: string[];
|
||||
@@ -292,6 +310,8 @@ export type CampaignJobsResponse = {
|
||||
total: number;
|
||||
total_unfiltered: number;
|
||||
pages: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
counts: Record<string, Record<string, number>>;
|
||||
filtered_counts: Record<string, Record<string, number>>;
|
||||
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 = {
|
||||
job: Record<string, unknown>;
|
||||
attempts: {
|
||||
@@ -330,6 +357,17 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
|
||||
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[]> {
|
||||
const response = await apiFetch<RecipientImportMappingProfileListResponse>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles");
|
||||
return response.profiles ?? [];
|
||||
@@ -337,8 +375,8 @@ export async function listRecipientImportMappingProfiles(settings: ApiSettings):
|
||||
|
||||
export async function createRecipientImportMappingProfile(
|
||||
settings: ApiSettings,
|
||||
payload: RecipientImportMappingProfilePayload
|
||||
): Promise<RecipientImportMappingProfile> {
|
||||
payload: RecipientImportMappingProfilePayload)
|
||||
: Promise<RecipientImportMappingProfile> {
|
||||
return apiFetch<RecipientImportMappingProfile>(settings, "/api/v1/campaigns/recipient-import/mapping-profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -348,8 +386,8 @@ export async function createRecipientImportMappingProfile(
|
||||
export async function updateRecipientImportMappingProfile(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: RecipientImportMappingProfilePayload
|
||||
): Promise<RecipientImportMappingProfile> {
|
||||
payload: RecipientImportMappingProfilePayload)
|
||||
: Promise<RecipientImportMappingProfile> {
|
||||
return apiFetch<RecipientImportMappingProfile>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -367,8 +405,8 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
||||
export async function updateCampaignMetadata(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignUpdatePayload
|
||||
): Promise<CampaignListItem> {
|
||||
payload: CampaignUpdatePayload)
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -377,13 +415,13 @@ export async function updateCampaignMetadata(
|
||||
|
||||
export async function createNewCampaign(
|
||||
settings: ApiSettings,
|
||||
overrides: CampaignCreateMinimalPayload = {}
|
||||
): Promise<CampaignCreateResponse> {
|
||||
overrides: CampaignCreateMinimalPayload = {})
|
||||
: Promise<CampaignCreateResponse> {
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
const payload = {
|
||||
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 ?? "",
|
||||
current_flow: overrides.current_flow ?? "create",
|
||||
current_step: overrides.current_step ?? "basics"
|
||||
@@ -406,8 +444,8 @@ export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknow
|
||||
export async function getCampaignWorkspace(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignWorkspaceQuery = {}
|
||||
): Promise<CampaignWorkspaceResponse> {
|
||||
options: CampaignWorkspaceQuery = {})
|
||||
: Promise<CampaignWorkspaceResponse> {
|
||||
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));
|
||||
@@ -417,26 +455,42 @@ export async function getCampaignWorkspace(
|
||||
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(
|
||||
settings: ApiSettings,
|
||||
campaignId: string
|
||||
): Promise<CampaignVersionListItem[]> {
|
||||
campaignId: string)
|
||||
: Promise<CampaignVersionListItem[]> {
|
||||
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
|
||||
}
|
||||
|
||||
export async function getCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
export async function unlockCampaignVersionValidation(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
|
||||
method: "POST"
|
||||
});
|
||||
@@ -445,8 +499,8 @@ export async function unlockCampaignVersionValidation(
|
||||
export async function lockCampaignVersionTemporarily(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
|
||||
method: "POST"
|
||||
});
|
||||
@@ -455,8 +509,8 @@ export async function lockCampaignVersionTemporarily(
|
||||
export async function unlockCampaignVersionUserLock(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
|
||||
method: "POST"
|
||||
});
|
||||
@@ -465,8 +519,8 @@ export async function unlockCampaignVersionUserLock(
|
||||
export async function lockCampaignVersionPermanently(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
|
||||
method: "POST"
|
||||
});
|
||||
@@ -476,8 +530,8 @@ export async function updateCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
payload: CampaignVersionUpdatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -488,8 +542,8 @@ export async function forkCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload = {}
|
||||
): Promise<CampaignCreateResponse> {
|
||||
payload: CampaignVersionUpdatePayload = {})
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -500,8 +554,8 @@ export async function autosaveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignVersionUpdatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
payload: CampaignVersionUpdatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -513,8 +567,8 @@ export async function setCampaignVersionStep(
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
currentStep: string,
|
||||
currentFlow?: string | null
|
||||
): Promise<CampaignVersionDetail> {
|
||||
currentFlow?: string | null)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
|
||||
@@ -525,8 +579,8 @@ export async function validatePartial(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignPartialValidationPayload = {}
|
||||
): Promise<CampaignPartialValidationResponse> {
|
||||
payload: CampaignPartialValidationPayload = {})
|
||||
: Promise<CampaignPartialValidationResponse> {
|
||||
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -536,8 +590,8 @@ export async function validatePartial(
|
||||
export async function publishCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string
|
||||
): Promise<CampaignVersionDetail> {
|
||||
versionId: string)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
|
||||
method: "POST"
|
||||
});
|
||||
@@ -546,8 +600,8 @@ export async function publishCampaignVersion(
|
||||
export async function validateVersion(
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
checkFiles = false
|
||||
): Promise<Record<string, unknown>> {
|
||||
checkFiles = false)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ check_files: checkFiles })
|
||||
@@ -557,8 +611,8 @@ export async function validateVersion(
|
||||
export async function buildVersion(
|
||||
settings: ApiSettings,
|
||||
versionId: string,
|
||||
writeEml = true
|
||||
): Promise<Record<string, unknown>> {
|
||||
writeEml = true)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ write_eml: writeEml })
|
||||
@@ -570,8 +624,8 @@ export function previewCampaignAttachments(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignAttachmentPreviewPayload = {}
|
||||
): Promise<CampaignAttachmentPreviewResponse> {
|
||||
payload: CampaignAttachmentPreviewPayload = {})
|
||||
: Promise<CampaignAttachmentPreviewResponse> {
|
||||
return apiFetch<CampaignAttachmentPreviewResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
|
||||
@@ -582,8 +636,8 @@ export function previewCampaignAttachments(
|
||||
export async function getCampaignSummary(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<CampaignSummary> {
|
||||
versionId?: string)
|
||||
: Promise<CampaignSummary> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
||||
}
|
||||
@@ -591,12 +645,13 @@ export async function getCampaignSummary(
|
||||
export async function getCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery = {}
|
||||
): Promise<CampaignJobsResponse> {
|
||||
options: CampaignJobsQuery = {})
|
||||
: Promise<CampaignJobsResponse> {
|
||||
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);
|
||||
@@ -605,19 +660,39 @@ export async function getCampaignJobs(
|
||||
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(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string
|
||||
): Promise<CampaignJobDetailResponse> {
|
||||
jobId: string)
|
||||
: Promise<CampaignJobDetailResponse> {
|
||||
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
|
||||
}
|
||||
|
||||
export async function getCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<CampaignSummary> {
|
||||
versionId?: string)
|
||||
: Promise<CampaignSummary> {
|
||||
const params = new URLSearchParams({ include_jobs: "false" });
|
||||
if (versionId) params.set("version_id", versionId);
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
||||
@@ -626,8 +701,8 @@ export async function getCampaignReport(
|
||||
export async function downloadCampaignJobsCsv(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string
|
||||
): Promise<void> {
|
||||
versionId?: string)
|
||||
: Promise<void> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
await apiDownload(
|
||||
settings,
|
||||
@@ -639,8 +714,8 @@ export async function downloadCampaignJobsCsv(
|
||||
export async function emailCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignReportEmailPayload
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: CampaignReportEmailPayload)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -650,8 +725,8 @@ export async function emailCampaignReport(
|
||||
export async function retryCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: Record<string, unknown> = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -661,8 +736,8 @@ export async function retryCampaignJobs(
|
||||
export async function sendUnattemptedCampaignJobs(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: Record<string, unknown> = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -674,8 +749,8 @@ export async function resolveCampaignJobOutcome(
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
decision: "smtp_accepted" | "not_sent",
|
||||
note?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
note?: string)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, note: note || null })
|
||||
@@ -686,8 +761,8 @@ export async function updateCampaignReviewState(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignReviewStatePayload
|
||||
): Promise<CampaignVersionDetail> {
|
||||
payload: CampaignReviewStatePayload)
|
||||
: Promise<CampaignVersionDetail> {
|
||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -697,8 +772,8 @@ export async function updateCampaignReviewState(
|
||||
export async function queueCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignQueuePayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: CampaignQueuePayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -708,8 +783,8 @@ export async function queueCampaign(
|
||||
export async function sendCampaignNow(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignSendNowPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: CampaignSendNowPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -720,8 +795,8 @@ export async function sendCampaignNow(
|
||||
export async function mockSendCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignMockSendPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: CampaignMockSendPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
@@ -743,8 +818,8 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string):
|
||||
export async function appendSent(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignAppendSentPayload = {}
|
||||
): Promise<Record<string, unknown>> {
|
||||
payload: CampaignAppendSentPayload = {})
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
@@ -760,23 +835,23 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function updateCampaignOwner(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: { owner_user_id?: string | null; owner_group_id?: string | null }
|
||||
): Promise<CampaignListItem> {
|
||||
payload: {owner_user_id?: string | null;owner_group_id?: string | null;})
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function upsertCampaignShare(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" }
|
||||
): Promise<CampaignShare> {
|
||||
payload: {target_type: "user" | "group";target_id: string;permission: "read" | "write";})
|
||||
: Promise<CampaignShare> {
|
||||
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,28 +5,28 @@ import { StatusBadge } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
const personalContacts = [
|
||||
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
|
||||
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
|
||||
];
|
||||
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
|
||||
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
|
||||
|
||||
|
||||
const groupContacts = [
|
||||
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
|
||||
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
|
||||
];
|
||||
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
|
||||
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
|
||||
|
||||
|
||||
const tenantContacts = [
|
||||
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
|
||||
{ name: "Data Protection", email: "privacy@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: "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 [
|
||||
{ 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: "email", header: "Email", 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: "tags", header: "Tags", 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: "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: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
||||
{ 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: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
||||
{ 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() {
|
||||
@@ -36,55 +36,55 @@ export default function AddressBookPage() {
|
||||
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Address Book</PageTitle>
|
||||
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
|
||||
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
|
||||
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Add contact</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</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="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.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="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="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="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 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">
|
||||
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
|
||||
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
|
||||
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
|
||||
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
|
||||
<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="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Planned address actions">
|
||||
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
|
||||
<div className="placeholder-stack">
|
||||
<span>Choose addresses from personal, group or tenant source</span>
|
||||
<span>Remember addresses used in campaigns after opt-in</span>
|
||||
<span>Share selected contacts with a group</span>
|
||||
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
|
||||
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
|
||||
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
|
||||
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
|
||||
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
|
||||
</div>
|
||||
</Card>
|
||||
</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
|
||||
id="address-book-contacts"
|
||||
rows={allContacts}
|
||||
columns={contactColumns()}
|
||||
getRowKey={(contact) => contact.source + "-" + contact.email}
|
||||
emptyText="No contacts found."
|
||||
className="compact-table-wrap module-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
|
||||
className="compact-table-wrap module-table" />
|
||||
|
||||
</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 (
|
||||
<div className="address-book-scope-card">
|
||||
<div>
|
||||
@@ -92,6 +92,6 @@ function AddressBookScope({ title, description, status }: { title: string; descr
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "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 { Button } 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 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 { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
type PathChooserState = { index: number };
|
||||
type IndividualDisableState = { index: number; usageCount: number };
|
||||
type PathChooserState = {index: 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 filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||
@@ -47,8 +48,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "files",
|
||||
unsavedTitle: "Unsaved attachment settings",
|
||||
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_attachment_settings.a6045f67",
|
||||
unsavedMessage: "i18n:govoplan-campaign.attachment_settings_have_unsaved_changes_save_th.b9b415bb"
|
||||
});
|
||||
const attachments = asRecord(displayDraft.attachments);
|
||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||
@@ -78,9 +79,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void listManagedFileSpaces(settings)
|
||||
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||
void listManagedFileSpaces(settings).
|
||||
then((response) => {if (!cancelled) setFileSpaces(response.spaces);}).
|
||||
catch(() => {if (!cancelled) setFileSpaces([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
@@ -153,9 +154,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
}
|
||||
|
||||
function setZipEnabled(enabled: boolean) {
|
||||
const archives = enabled && zipConfig.archives.length === 0
|
||||
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
|
||||
: zipConfig.archives;
|
||||
const archives = enabled && zipConfig.archives.length === 0 ?
|
||||
[createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] :
|
||||
zipConfig.archives;
|
||||
patchZipCollection({ enabled, archives });
|
||||
}
|
||||
|
||||
@@ -195,9 +196,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
const resetRule = (value: unknown) => {
|
||||
const rule = asRecord(value);
|
||||
const ruleZip = asRecord(rule.zip);
|
||||
return String(ruleZip.archive_id ?? "") === removed.id
|
||||
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
|
||||
: rule;
|
||||
return String(ruleZip.archive_id ?? "") === removed.id ?
|
||||
{ ...rule, zip: { ...ruleZip, archive_id: "inherit" } } :
|
||||
rule;
|
||||
};
|
||||
setDraft((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="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Attachments</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
|
||||
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-attachment-sources`}
|
||||
rows={basePaths}
|
||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||
getRowKey={(basePath) => basePath.id}
|
||||
emptyText="No attachment sources configured."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
|
||||
className="attachment-sources-table-wrap attachment-sources-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
||||
className="attachment-sources-table-wrap attachment-sources-table" />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="ZIP attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||
<div className="attachment-zip-master-toggle">
|
||||
<ToggleSwitch
|
||||
label="Enable ZIP attachments"
|
||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||
checked={zipConfig.enabled}
|
||||
disabled={locked}
|
||||
onChange={setZipEnabled}
|
||||
/>
|
||||
onChange={setZipEnabled} />
|
||||
|
||||
</div>
|
||||
<div className="admin-table-surface attachment-zip-table-surface">
|
||||
<DataGrid
|
||||
@@ -288,48 +289,48 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
removeArchive: removeZipArchive
|
||||
})}
|
||||
getRowKey={(archive) => archive.id}
|
||||
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
|
||||
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
|
||||
className="attachment-zip-table-wrap"
|
||||
/>
|
||||
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="i18n:govoplan-campaign.add_first_zip_attachment.f64cc332" /> : undefined}
|
||||
className="attachment-zip-table-wrap" />
|
||||
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
{zipArchiveNameValidation.message && (
|
||||
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) &&
|
||||
<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 &&
|
||||
<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 title="Global Attachments" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||
<AttachmentRulesDataGrid
|
||||
id={`campaign-${campaignId}-global-attachments`}
|
||||
rules={globalRules}
|
||||
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}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={managedFilesAvailable}
|
||||
previewContext={attachmentPreviewContext}
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||
/>
|
||||
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||
|
||||
</Card>
|
||||
|
||||
<Card title="Statistics" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
||||
<dl className="detail-list">
|
||||
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
|
||||
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
|
||||
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
|
||||
<div><dt>Upload support</dt><dd>{managedFilesAvailable ? "Connected through Files" : "Manual paths"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</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>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</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>
|
||||
<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."
|
||||
: filesModuleInstalled
|
||||
? "Managed file browsing is unavailable. Use manual paths and patterns until the Files module exposes the file explorer capability."
|
||||
: "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}</p>
|
||||
<p className="muted small-note">{managedFilesAvailable ?
|
||||
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
|
||||
filesModuleInstalled ?
|
||||
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
|
||||
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
|
||||
</Card>
|
||||
|
||||
</>
|
||||
@@ -337,13 +338,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
<TemplateExpressionEditorDialog
|
||||
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
|
||||
title="Edit ZIP archive filename"
|
||||
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
|
||||
title="i18n:govoplan-campaign.edit_zip_archive_filename.5a2c95e4"
|
||||
value={zipNameEditorIndex !== null ? zipConfig.archives[zipNameEditorIndex]?.name ?? "" : ""}
|
||||
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
|
||||
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
|
||||
placeholder="attachments.zip"
|
||||
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
|
||||
saveLabel="Save filename"
|
||||
description="i18n:govoplan-campaign.use_a_fixed_filename_or_combine_text_with_recipi.43a091fd"
|
||||
saveLabel="i18n:govoplan-campaign.save_filename.2dbd2273"
|
||||
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
|
||||
onCancel={() => setZipNameEditorIndex(null)}
|
||||
onSave={(name) => {
|
||||
@@ -357,13 +358,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
|
||||
patch(["fields"], [
|
||||
...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
|
||||
open
|
||||
settings={settings}
|
||||
@@ -378,30 +379,30 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
patchBasePath(pathChooser.index, {
|
||||
source: selection.source,
|
||||
path: selection.folderPath || ".",
|
||||
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
|
||||
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
|
||||
: current.name
|
||||
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}` : ""}` :
|
||||
current.name
|
||||
});
|
||||
setPathChooser(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
}} />
|
||||
|
||||
}
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(individualDisable)}
|
||||
title="Disable individual attachments for this source?"
|
||||
message={individualDisable
|
||||
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
|
||||
: ""}
|
||||
confirmLabel="Remove individual attachments"
|
||||
cancelLabel="Cancel"
|
||||
title="i18n:govoplan-campaign.disable_individual_attachments_for_this_source.185f9a95"
|
||||
message={individualDisable ? i18nMessage("i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7", { value0:
|
||||
individualDisable.usageCount, value1: individualDisable.usageCount === 1 ? "i18n:govoplan-campaign.entry_uses.5f269cab" : "i18n:govoplan-campaign.entries_use.e9169679" }) :
|
||||
""}
|
||||
confirmLabel="i18n:govoplan-campaign.remove_individual_attachments.9e5f0ad3"
|
||||
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
||||
tone="danger"
|
||||
onConfirm={confirmIndividualDisable}
|
||||
onCancel={() => setIndividualDisable(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onCancel={() => setIndividualDisable(null)} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type ZipArchiveColumnContext = {
|
||||
@@ -420,72 +421,72 @@ type ZipArchiveColumnContext = {
|
||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||
return [
|
||||
{
|
||||
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||
render: (archive, index) => (
|
||||
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||
render: (archive, index) =>
|
||||
<button
|
||||
type="button"
|
||||
className="attachment-zip-name-button"
|
||||
disabled={disabled}
|
||||
aria-invalid={invalidNameIndexes.has(index) || undefined}
|
||||
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
|
||||
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
|
||||
onClick={() => onEditName(index)}
|
||||
>
|
||||
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.edit_zip_archive_filename_value.deb41dfc", { value0: archive.name || index + 1 })}
|
||||
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)}>
|
||||
|
||||
<span className="attachment-zip-name-value">{archive.name || "i18n:govoplan-campaign.unnamed_zip_attachment.54f515bb"}</span>
|
||||
<Pencil size={15} aria-hidden="true" />
|
||||
</button>
|
||||
),
|
||||
</button>,
|
||||
|
||||
value: (archive) => archive.name
|
||||
},
|
||||
{
|
||||
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
||||
id: "standard", header: "i18n:govoplan-campaign.standard.2dfa6607", width: 150, sortable: true, filterable: true,
|
||||
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="i18n:govoplan-campaign.standard.2dfa6607" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
|
||||
value: (archive) => archive.standard ? "standard" : "optional"
|
||||
},
|
||||
{
|
||||
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
|
||||
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||
id: "password_enabled", header: "i18n:govoplan-campaign.password.8be3c943", width: 165, sortable: true, filterable: true,
|
||||
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="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"
|
||||
},
|
||||
{
|
||||
id: "password_field", header: "Password field", 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 }))] },
|
||||
render: (archive, index) => (
|
||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||
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) =>
|
||||
<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>)}
|
||||
</select>
|
||||
),
|
||||
</select>,
|
||||
|
||||
value: (archive) => archive.password_field
|
||||
},
|
||||
{
|
||||
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
|
||||
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
|
||||
render: (archive, index) => (
|
||||
id: "password_scope", header: "i18n:govoplan-campaign.value_scope.5e1e16b2", width: 190, sortable: true, filterable: true,
|
||||
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) =>
|
||||
<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="global">Campaign / global</option>
|
||||
</select>
|
||||
),
|
||||
<option value="local">i18n:govoplan-campaign.recipient_local.418a289f</option>
|
||||
<option value="global">i18n:govoplan-campaign.campaign_global.ba944948</option>
|
||||
</select>,
|
||||
|
||||
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
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addArchive(index)}
|
||||
onRemove={() => removeArchive(index)}
|
||||
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
|
||||
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
|
||||
addLabel="Add ZIP attachment below"
|
||||
removeLabel="Remove ZIP attachment"
|
||||
moveUpLabel="Move ZIP attachment up"
|
||||
moveDownLabel="Move ZIP attachment down"
|
||||
/>
|
||||
}
|
||||
];
|
||||
addLabel="i18n:govoplan-campaign.add_zip_attachment_below.776dabc1"
|
||||
removeLabel="i18n:govoplan-campaign.remove_zip_attachment.d2bdf662"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_zip_attachment_up.63446ac0"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_zip_attachment_down.4ead4805" />
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
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 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 globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))])
|
||||
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
||||
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]).
|
||||
filter((name) => fieldDefinitions.get(name)?.type !== "password");
|
||||
return [
|
||||
...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 {
|
||||
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);
|
||||
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 {
|
||||
@@ -545,14 +546,14 @@ function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNa
|
||||
}
|
||||
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
|
||||
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
|
||||
const duplicateNames = [...byName.entries()]
|
||||
.filter(([, indexes]) => indexes.length > 1)
|
||||
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
|
||||
.filter(Boolean);
|
||||
const duplicateNames = [...byName.entries()].
|
||||
filter(([, indexes]) => indexes.length > 1).
|
||||
map(([, indexes]) => archives[indexes[0]]?.name.trim()).
|
||||
filter(Boolean);
|
||||
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(", ")}`);
|
||||
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 {
|
||||
@@ -580,15 +581,15 @@ type AttachmentSourceColumnContext = {
|
||||
|
||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||
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",
|
||||
header: "Path",
|
||||
header: "i18n:govoplan-campaign.path.519e3913",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (basePath, index) => (
|
||||
render: (basePath, index) =>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
@@ -596,7 +597,7 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
|
||||
disabled={locked}
|
||||
readOnly={managedFilesAvailable}
|
||||
tabIndex={managedFilesAvailable ? -1 : undefined}
|
||||
placeholder="attachments"
|
||||
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
||||
onChange={(event) => {
|
||||
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||
}}
|
||||
@@ -606,21 +607,21 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
|
||||
event.preventDefault();
|
||||
setPathChooser({ index });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
|
||||
</div>
|
||||
),
|
||||
}} />
|
||||
|
||||
{managedFilesAvailable && <Button onClick={() => setPathChooser({ index })} disabled={locked}>i18n:govoplan-campaign.choose_folder.1838a415</Button>}
|
||||
</div>,
|
||||
|
||||
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: "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: "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: "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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_basePath, index) => (
|
||||
render: (_basePath, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
removeDisabled={basePaths.length <= 1}
|
||||
@@ -628,22 +629,22 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
|
||||
onRemove={() => removeBasePath(index)}
|
||||
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
|
||||
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
|
||||
addLabel="Add attachment source below"
|
||||
removeLabel="Remove attachment source"
|
||||
moveUpLabel="Move attachment source up"
|
||||
moveDownLabel="Move attachment source down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
addLabel="i18n:govoplan-campaign.add_attachment_source_below.635c6a61"
|
||||
removeLabel="i18n:govoplan-campaign.remove_attachment_source.95855c67"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_attachment_source_up.956ec756"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_attachment_source_down.cbf9ebd8" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FilesFileSpace[]): string {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
const matchingSpace = parsedSource
|
||||
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
||||
: undefined;
|
||||
const rootLabel = matchingSpace?.label
|
||||
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
||||
const matchingSpace = parsedSource ?
|
||||
spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) :
|
||||
undefined;
|
||||
const rootLabel = matchingSpace?.label || (
|
||||
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, "");
|
||||
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
||||
return `${relativePath || "."}/`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
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 version = data.currentVersion;
|
||||
|
||||
@@ -15,21 +15,21 @@ export default function CampaignAuditPage({ settings, campaignId }: { settings:
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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} />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading audit data…">
|
||||
<Card title="Recent audit events">
|
||||
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
||||
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import FieldValueInput from "./components/FieldValueInput";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
|
||||
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||
export default function CampaignFieldsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const fieldValueKeys = useRef<string[]>([]);
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "campaign-fields",
|
||||
unsavedTitle: "Unsaved fields",
|
||||
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_fields.c0913c13",
|
||||
unsavedMessage: "i18n:govoplan-campaign.campaign_fields_have_unsaved_changes_save_them_b.c7e992f1",
|
||||
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
|
||||
onLoaded: (_loadedVersion, loadedDraft) => {
|
||||
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
|
||||
@@ -142,7 +142,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
const fieldProblem = describeFieldNameProblem(fields);
|
||||
if (fieldProblem) {
|
||||
setLocalError(fieldProblem);
|
||||
setSaveState("Save blocked");
|
||||
setSaveState("i18n:govoplan-campaign.save_blocked.c09570a4");
|
||||
return false;
|
||||
}
|
||||
return saveDraft("manual");
|
||||
@@ -153,39 +153,39 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Fields</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-fields`}
|
||||
rows={fields}
|
||||
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
|
||||
getRowKey={(_field, index) => `field-row-${index}`}
|
||||
emptyText="No campaign fields configured yet."
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
|
||||
className="field-editor-table-wrap field-editor-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_campaign_fields_configured_yet.6772f948"
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_field.9fef7186" />}
|
||||
className="field-editor-table-wrap field-editor-table" />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type FieldColumnContext = {
|
||||
@@ -203,32 +203,32 @@ type FieldColumnContext = {
|
||||
|
||||
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
|
||||
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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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: "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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_field, index) => (
|
||||
render: (_field, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={locked}
|
||||
onAddBelow={() => addField(index)}
|
||||
onRemove={() => deleteField(index)}
|
||||
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
|
||||
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
|
||||
addLabel="Add field below"
|
||||
removeLabel="Remove field"
|
||||
moveUpLabel="Move field up"
|
||||
moveDownLabel="Move field down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
addLabel="i18n:govoplan-campaign.add_field_below.c087fdd7"
|
||||
removeLabel="i18n:govoplan-campaign.remove_field.f4e03605"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_field_up.d98ba824"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_field_down.8f27ccb2" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
|
||||
@@ -279,7 +279,7 @@ function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState:
|
||||
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||
const names = fields.map((field) => field.name.trim());
|
||||
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>();
|
||||
@@ -290,7 +290,7 @@ function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||
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 version = data.currentVersion;
|
||||
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="page-heading split workspace-heading">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 { createNewCampaign, listCampaigns } from "../../api/campaigns";
|
||||
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||
import type { CampaignListItem } from "../../types";
|
||||
|
||||
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
|
||||
const navigate = useNavigate();
|
||||
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
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);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listCampaigns(settings);
|
||||
setCampaigns(data);
|
||||
let nextWatermark = forcedSince ?? null;
|
||||
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()));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -49,41 +58,42 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
setCampaignDeltaWatermark(null);
|
||||
load(null);
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const columns: DataGridColumn<CampaignListItem>[] = [
|
||||
{
|
||||
id: "campaign",
|
||||
header: "Campaign",
|
||||
header: "i18n:govoplan-campaign.campaign.69390e16",
|
||||
width: "minmax(260px, 1.8fr)",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (campaign) => (
|
||||
render: (campaign) =>
|
||||
<div>
|
||||
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
|
||||
{campaign.name || campaign.external_id || campaign.id}
|
||||
</Link>
|
||||
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
|
||||
</div>
|
||||
),
|
||||
</div>,
|
||||
|
||||
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
header: "i18n:govoplan-campaign.status.bae7d5be",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "archived", label: "Archived" }
|
||||
],
|
||||
{ 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"} />,
|
||||
@@ -91,7 +101,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
},
|
||||
{
|
||||
id: "current_version",
|
||||
header: "Current version",
|
||||
header: "i18n:govoplan-campaign.current_version.7be72582",
|
||||
width: 170,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
@@ -101,7 +111,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "Updated",
|
||||
header: "i18n:govoplan-campaign.updated.f2f8570d",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
@@ -112,51 +122,51 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (campaign) => (
|
||||
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}`}
|
||||
>
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
|
||||
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
}];
|
||||
|
||||
|
||||
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>}
|
||||
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>All campaigns</PageTitle>
|
||||
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
|
||||
<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 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}>
|
||||
{creating ? "Creating…" : "New campaign"}
|
||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card title="Campaigns">
|
||||
<LoadingFrame loading={loading} label="Loading campaigns…">
|
||||
{campaigns.length === 0 ? (
|
||||
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
||||
{campaigns.length === 0 ?
|
||||
<div className="empty-state">
|
||||
<h2>No campaigns yet</h2>
|
||||
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
|
||||
<h2>i18n:govoplan-campaign.no_campaigns_yet.926409b3</h2>
|
||||
<p>i18n:govoplan-campaign.start_with_a_guided_campaign_draft_the_webui_wil.c8a675d0</p>
|
||||
<Button variant="primary" onClick={create} disabled={creating}>
|
||||
Create first campaign
|
||||
i18n:govoplan-campaign.create_first_campaign.9be974a4
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
</div> :
|
||||
|
||||
<div className="admin-table-surface campaigns-table-surface">
|
||||
<DataGrid
|
||||
id="campaigns"
|
||||
@@ -165,14 +175,14 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
|
||||
getRowKey={(campaign) => campaign.id}
|
||||
initialSort={{ columnId: "updated", direction: "desc" }}
|
||||
className="campaign-table-wrap"
|
||||
emptyText="No campaigns found."
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_campaigns_found.22d297f9" />
|
||||
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
@@ -187,3 +197,17 @@ function formatDateTime(value?: string): string {
|
||||
function formatLoadedAt(value: Date): string {
|
||||
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 ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 {
|
||||
lockCampaignVersionPermanently,
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
unlockCampaignVersionUserLock,
|
||||
updateCampaignMetadata,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem,
|
||||
} from "../../api/campaigns";
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import {
|
||||
asArray,
|
||||
@@ -31,15 +31,15 @@ import {
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
isVersionReadyForDelivery,
|
||||
summaryValue,
|
||||
} from "./utils/campaignView";
|
||||
summaryValue } from
|
||||
"./utils/campaignView";
|
||||
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
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 campaign = data.campaign;
|
||||
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 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(() => {
|
||||
if (!campaign || identityDirty) return;
|
||||
setIdentity({
|
||||
external_id: campaign.external_id ?? "",
|
||||
name: campaign.name ?? "",
|
||||
status: campaign.status ?? "",
|
||||
description: campaign.description ?? "",
|
||||
description: campaign.description ?? ""
|
||||
});
|
||||
}, [campaign, identityDirty]);
|
||||
|
||||
@@ -67,8 +82,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
setMessage("");
|
||||
}
|
||||
|
||||
async function saveIdentity() {
|
||||
if (!campaign || savingIdentity || !identityDirty) return;
|
||||
async function saveIdentity(): Promise<boolean> {
|
||||
if (!campaign || savingIdentity || !identityDirty) return false;
|
||||
setSavingIdentity(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
@@ -77,12 +92,14 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
external_id: identity.external_id,
|
||||
name: identity.name,
|
||||
status: identity.status,
|
||||
description: identity.description,
|
||||
description: identity.description
|
||||
});
|
||||
setIdentityDirty(false);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setSavingIdentity(false);
|
||||
}
|
||||
@@ -97,13 +114,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
try {
|
||||
if (pending.action === "temporary") {
|
||||
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") {
|
||||
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 {
|
||||
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);
|
||||
await reload();
|
||||
@@ -118,70 +135,70 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
|
||||
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
|
||||
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
|
||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
|
||||
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
|
||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<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 ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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">
|
||||
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
|
||||
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
|
||||
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
||||
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||
</div>
|
||||
|
||||
<div className="metric-grid campaign-overview-metrics">
|
||||
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
|
||||
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
|
||||
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
|
||||
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
|
||||
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
||||
<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="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
|
||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
||||
</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}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={`Open curent version`}
|
||||
title={`Open curent version`}
|
||||
>
|
||||
Open
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<div className="summary-grid overview-summary-grid">
|
||||
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="Warnings" 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="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Campaign identity" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
|
||||
<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)} />
|
||||
</FormField>
|
||||
<FormField label="Mode">
|
||||
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
|
||||
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
|
||||
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Name">
|
||||
<FormField label="i18n:govoplan-campaign.name.709a2322">
|
||||
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Version history" collapsible>
|
||||
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
|
||||
<div className="admin-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
@@ -189,10 +206,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
|
||||
getRowKey={(version) => version.id}
|
||||
initialSort={{ columnId: "version", direction: "desc" }}
|
||||
emptyText="No versions found."
|
||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||
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>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
@@ -205,10 +222,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
|
||||
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
|
||||
busy={lockBusy}
|
||||
onCancel={() => setPendingLockAction(null)}
|
||||
onConfirm={() => void applyLockAction()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onConfirm={() => void applyLockAction()} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
@@ -253,10 +270,10 @@ function campaignVersionMetrics(version: CampaignVersionDetail | null): Campaign
|
||||
templateHealthValue: `${score}/3`,
|
||||
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
|
||||
templateHealthDetail: [
|
||||
subjectOk ? "Subject OK" : "Subject missing",
|
||||
bodyOk ? "Body OK" : "Body missing",
|
||||
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
|
||||
].join(" · ")
|
||||
subjectOk ? "i18n:govoplan-campaign.subject_ok.3dbdc3ed" : "i18n:govoplan-campaign.subject_missing.f545bf65",
|
||||
bodyOk ? "i18n:govoplan-campaign.body_ok.cdb33005" : "i18n:govoplan-campaign.body_missing.bd58622f",
|
||||
placeholdersOk ? "i18n:govoplan-campaign.placeholders_ok.3a07f3bc" : `${undefinedPlaceholders.length} undefined`].
|
||||
join(" · ")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,15 +287,15 @@ function textValue(value: unknown, fallback = ""): string {
|
||||
|
||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||
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: "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: "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: "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: "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: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
|
||||
{ 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: "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: "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: "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: "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: "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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 260,
|
||||
sticky: "end",
|
||||
render: (version) => {
|
||||
@@ -288,88 +305,88 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
<Link
|
||||
to={`send?version=${version.id}`}
|
||||
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
|
||||
aria-label={`Open version ${version.version_number}`}
|
||||
title={`Open version ${version.version_number}`}
|
||||
>
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
|
||||
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</Link>
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
|
||||
{isCurrent && (isTemporaryUserLockedVersion(version) ?
|
||||
<>
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
|
||||
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
|
||||
</>
|
||||
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
|
||||
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</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 ?
|
||||
<Button
|
||||
className="admin-icon-button"
|
||||
onClick={() => setPendingLockAction({ version, action: "temporary" })}
|
||||
aria-label={`Temporarily lock version ${version.version_number}`}
|
||||
title="Temporarily lock version"
|
||||
>
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
|
||||
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
|
||||
|
||||
<LockKeyhole aria-hidden="true" />
|
||||
</Button>
|
||||
) : null)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
</Button> :
|
||||
null)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
|
||||
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
|
||||
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
|
||||
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
|
||||
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
|
||||
if (version.locked_at) return "Temporarily locked";
|
||||
return "Editable";
|
||||
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||
if (isFinalLockedVersion(version)) return "i18n:govoplan-campaign.permanent_delivery_lock.07932206";
|
||||
if (canUnlockValidationVersion(version)) return "i18n:govoplan-campaign.temporary_validation_lock.fdc5545d";
|
||||
if (version.locked_at) return "i18n:govoplan-campaign.temporarily_locked.1716dc95";
|
||||
return "i18n:govoplan-campaign.editable.b91faec0";
|
||||
}
|
||||
|
||||
function validationLabel(version: CampaignVersionListItem): string {
|
||||
const validation = version.validation_summary ?? {};
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
|
||||
if (validation.ok === false) return "Needs attention";
|
||||
if (validation.ok === true) return "Passed, unavailable while user-locked";
|
||||
return "Not validated";
|
||||
if (validation.ok === true && isVersionReadyForDelivery(version)) return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
if (validation.ok === false) return "i18n:govoplan-campaign.needs_attention.a126722e";
|
||||
if (validation.ok === true) return "i18n:govoplan-campaign.passed_unavailable_while_user_locked.d6771ff4";
|
||||
return "i18n:govoplan-campaign.not_validated.11bb4178";
|
||||
}
|
||||
|
||||
function buildLabel(version: CampaignVersionListItem): string {
|
||||
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 {
|
||||
if (pending?.action === "temporary") return "Temporarily lock version?";
|
||||
if (pending?.action === "unlock") return "Unlock version?";
|
||||
if (pending?.action === "permanent") return "Lock version permanently?";
|
||||
return "Confirm lock action";
|
||||
if (pending?.action === "temporary") return "i18n:govoplan-campaign.temporarily_lock_version.8ccc5708";
|
||||
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock_version.ebd2fd9a";
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_version_permanently.a8625753";
|
||||
return "i18n:govoplan-campaign.confirm_lock_action.617986ee";
|
||||
}
|
||||
|
||||
function lockDialogMessage(pending: PendingLockAction): string {
|
||||
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") {
|
||||
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") {
|
||||
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 {
|
||||
if (pending?.action === "temporary") return "Lock temporarily";
|
||||
if (pending?.action === "unlock") return "Unlock";
|
||||
if (pending?.action === "permanent") return "Lock permanently";
|
||||
return "Confirm";
|
||||
if (pending?.action === "temporary") return "i18n:govoplan-campaign.lock_temporarily.3f91d346";
|
||||
if (pending?.action === "unlock") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
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 (
|
||||
<div className="summary-tile">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
downloadCampaignJobsCsv,
|
||||
emailCampaignReport,
|
||||
getCampaignJobDetail,
|
||||
getCampaignJobs,
|
||||
getCampaignJobsDelta,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendUnattemptedCampaignJobs,
|
||||
type CampaignJobDetailResponse,
|
||||
type CampaignJobsResponse,
|
||||
} from "../../api/campaigns";
|
||||
type CampaignJobsResponse } from
|
||||
"../../api/campaigns";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
@@ -20,9 +20,10 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
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 { 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[] = [
|
||||
"not_queued",
|
||||
@@ -34,33 +35,35 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled",
|
||||
].map((value) => ({ value, label: humanize(value) }));
|
||||
"cancelled"].
|
||||
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 { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>({
|
||||
jobs: [],
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total: 0,
|
||||
total_unfiltered: 0,
|
||||
pages: 0,
|
||||
counts: {},
|
||||
filtered_counts: {},
|
||||
review: {},
|
||||
});
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
||||
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sendStatus, setSendStatus] = useState("");
|
||||
const [imapStatus, setImapStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
@@ -81,30 +84,75 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
|
||||
const jobsQueryKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
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;
|
||||
setJobsLoading(true);
|
||||
setActionError("");
|
||||
try {
|
||||
const response = await getCampaignJobs(settings, campaignId, {
|
||||
let nextWatermark = getDeltaWatermark(jobsQueryKey);
|
||||
let merged = jobsRef.current;
|
||||
let hasMore = false;
|
||||
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
|
||||
do {
|
||||
const response = await getCampaignJobsDelta(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize: 50,
|
||||
cursor: pageCursor,
|
||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||
imapStatus: imapStatus ? [imapStatus] : undefined,
|
||||
query: appliedQuery || undefined,
|
||||
since: nextWatermark
|
||||
});
|
||||
setJobs(response);
|
||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||
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) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
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() {
|
||||
await Promise.all([reload(), loadJobs()]);
|
||||
@@ -116,9 +164,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
try {
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
|
||||
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(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);
|
||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||
await reloadAll();
|
||||
@@ -135,9 +183,9 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
try {
|
||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
||||
setActionMessage(reconcile.decision === "smtp_accepted"
|
||||
? "The job was recorded as SMTP accepted and is protected from retry."
|
||||
: "The job was recorded as not sent. It is now an explicit retry candidate.");
|
||||
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
||||
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
||||
setReconcile(null);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
@@ -164,7 +212,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
setActionError("");
|
||||
try {
|
||||
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
|
||||
setActionMessage("Campaign job CSV downloaded.");
|
||||
setActionMessage("i18n:govoplan-campaign.campaign_job_csv_downloaded.08af6930");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -183,7 +231,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
version_id: version?.id,
|
||||
include_jobs: false,
|
||||
attach_jobs_csv: attachCsv,
|
||||
attach_report_json: attachJson,
|
||||
attach_report_json: attachJson
|
||||
});
|
||||
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
|
||||
setEmailOpen(false);
|
||||
@@ -195,17 +243,55 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
}
|
||||
|
||||
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: "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: "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") },
|
||||
{ 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") },
|
||||
{ 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") },
|
||||
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
|
||||
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) || 1 },
|
||||
{
|
||||
id: "recipient",
|
||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (row) =>
|
||||
<div className="recipient-outcome-cell">
|
||||
<strong>{String(row.recipient_email ?? "—")}</strong>
|
||||
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||
</div>,
|
||||
|
||||
value: (row) => String(row.recipient_email ?? "—")
|
||||
},
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ 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") },
|
||||
{ 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") },
|
||||
{ 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") },
|
||||
{ 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") },
|
||||
{ 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",
|
||||
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: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 250,
|
||||
sticky: "end",
|
||||
render: (row) => {
|
||||
@@ -213,145 +299,196 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
|
||||
const status = String(row.send_status ?? "");
|
||||
return (
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
|
||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [busyAction]);
|
||||
<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 (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
|
||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</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">
|
||||
<Card title="Delivery outcome">
|
||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="IMAP and execution plan">
|
||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||
<dl className="detail-list">
|
||||
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>IMAP failed</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>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
|
||||
<div><dt>Minimum remaining duration</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.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</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>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>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</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>
|
||||
</Card>
|
||||
<Card title="Explicit delivery actions">
|
||||
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
|
||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
|
||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</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)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Recipient delivery jobs">
|
||||
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
||||
<div className="page-heading split">
|
||||
<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);}}>
|
||||
<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>)}
|
||||
</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>
|
||||
<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>
|
||||
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
|
||||
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
|
||||
<DataGrid<Record<string, unknown>>
|
||||
id={`campaign-report-jobs-${campaignId}`}
|
||||
rows={jobs.jobs}
|
||||
columns={columns}
|
||||
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>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
|
||||
<span>Page {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.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
|
||||
<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}>i18n:govoplan-campaign.next.bc981983</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<Dialog
|
||||
open={emailOpen}
|
||||
title="Email campaign report"
|
||||
title="i18n:govoplan-campaign.email_campaign_report.61a2989d"
|
||||
onClose={() => setEmailOpen(false)}
|
||||
closeDisabled={busyAction === "email"}
|
||||
footer={(
|
||||
footer={
|
||||
<div className="button-row">
|
||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</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"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
<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} />
|
||||
</label>
|
||||
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
|
||||
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</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)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(detail)}
|
||||
title="Campaign job detail"
|
||||
title="i18n:govoplan-campaign.campaign_job_detail.81dc68e1"
|
||||
onClose={() => setDetail(null)}
|
||||
className="dialog-panel-wide"
|
||||
>
|
||||
{detail && (
|
||||
className="dialog-panel-wide">
|
||||
|
||||
{detail &&
|
||||
<div className="stacked-sections">
|
||||
<dl className="detail-list">
|
||||
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>Subject</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>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_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.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></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>
|
||||
<h3>SMTP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
|
||||
<h3>IMAP attempts</h3>
|
||||
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
|
||||
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(reconcile)}
|
||||
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
|
||||
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."
|
||||
: "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."}
|
||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record 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" ?
|
||||
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
|
||||
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
|
||||
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"}
|
||||
busy={busyAction === "reconcile"}
|
||||
onConfirm={() => void reconcileOutcome()}
|
||||
onCancel={() => setReconcile(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
onCancel={() => setReconcile(null)} />
|
||||
|
||||
</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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 SectionSidebar from "../../layout/SectionSidebar";
|
||||
import CampaignOverviewPage from "./CampaignOverviewPage";
|
||||
@@ -17,7 +18,6 @@ import SendWizard from "./wizard/SendWizard";
|
||||
import CampaignJsonView from "./CampaignJsonView";
|
||||
import CampaignReportPage from "./CampaignReportPage";
|
||||
import CampaignAuditPage from "./CampaignAuditPage";
|
||||
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
overview: "",
|
||||
@@ -44,7 +44,7 @@ export default function CampaignWorkspace({ settings, auth }: { settings: ApiSet
|
||||
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const { campaignId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||
const guardedNavigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const active = sectionFromPath(location.pathname);
|
||||
const urlVersionId = new URLSearchParams(location.search).get("version");
|
||||
@@ -73,7 +73,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
const query = params.toString();
|
||||
const target = query ? `${pathname}?${query}` : pathname;
|
||||
if (`${location.pathname}${location.search}` === target) return;
|
||||
requestNavigation(() => navigate(target));
|
||||
guardedNavigate(target);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } 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 { PageTitle } 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 CampaignAccessCard from "./components/CampaignAccessCard";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
@@ -44,10 +46,10 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
reload,
|
||||
setError,
|
||||
currentStep: isPolicyView ? "policies" : "global-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_campaign_policy_changes.181f3426" : "i18n:govoplan-campaign.unsaved_campaign_settings.0555d713",
|
||||
unsavedMessage: isPolicyView ?
|
||||
"i18n:govoplan-campaign.campaign_policies_have_unsaved_changes_save_them.7b07827f" :
|
||||
"i18n:govoplan-campaign.campaign_settings_have_unsaved_changes_save_them.c1c5f65f",
|
||||
extraPayload: () => ({ editor_state: editorState }),
|
||||
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
|
||||
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 canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
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) {
|
||||
if (locked) return;
|
||||
@@ -77,171 +79,185 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</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>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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…">
|
||||
{isPolicyView ? (
|
||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||
{isPolicyView ?
|
||||
<>
|
||||
{canReadRetentionPolicy && (
|
||||
{canReadRetentionPolicy &&
|
||||
<RetentionPolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
scopeId={campaignId}
|
||||
title="Campaign retention policy"
|
||||
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
|
||||
title="i18n:govoplan-campaign.campaign_retention_policy.fcc9897c"
|
||||
description="i18n:govoplan-campaign.campaign_level_retention_limits_applied_after_sy.e1a5fd45"
|
||||
canWrite={canWriteRetentionPolicy}
|
||||
locked={locked}
|
||||
/>
|
||||
)}
|
||||
locked={locked} />
|
||||
|
||||
}
|
||||
|
||||
<div className="dashboard-grid below-grid">
|
||||
<Card title="Validation policy" collapsible>
|
||||
<PolicyTable>
|
||||
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
|
||||
<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
|
||||
label="Missing required attachment"
|
||||
note="Required attachment rule found no matching file."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Missing optional attachment"
|
||||
note="Optional attachment rule found no matching file."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Ambiguous attachment match"
|
||||
note="Attachment rule matched more files than expected."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Unsent attachment file"
|
||||
note="A watched attachment directory contains files that were not selected."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Missing email address"
|
||||
note="The effective recipient list has no To address."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Template error"
|
||||
note="A selected template body contains unresolved placeholders."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Ignore empty fields"
|
||||
note="Controls how missing placeholder values are rendered."
|
||||
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
|
||||
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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>
|
||||
</Card>
|
||||
|
||||
<Card title="Attachment policy" collapsible>
|
||||
<PolicyTable>
|
||||
<Card title="i18n:govoplan-campaign.attachment_policy.2fd471d4" collapsible>
|
||||
<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
|
||||
label="Default missing behavior"
|
||||
note="Used by attachment rules that do not override missing-file behavior."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Default ambiguous behavior"
|
||||
note="Used by attachment rules that do not override ambiguous-match behavior."
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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"))}
|
||||
/>
|
||||
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))} />
|
||||
|
||||
<PolicyRow
|
||||
label="Send without attachments"
|
||||
note="Campaign-wide fallback when no attachment is available."
|
||||
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
||||
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
|
||||
/>
|
||||
className="campaign-policy-row"
|
||||
labelClassName="campaign-policy-label"
|
||||
controlClassName="campaign-policy-control"
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
</> :
|
||||
|
||||
<>
|
||||
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||
|
||||
<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">
|
||||
<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="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="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>
|
||||
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
<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="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="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="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
</div>
|
||||
</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">
|
||||
<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="Remember newly used addresses" 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.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="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="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>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function PolicyTable({ children }: { children: ReactNode }) {
|
||||
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[] }) {
|
||||
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: {value: string;disabled?: boolean;onChange: (value: string) => void;options?: string[];}) {
|
||||
return (
|
||||
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
</select>);
|
||||
|
||||
}
|
||||
|
||||
function behaviorSummary(value: string): string {
|
||||
if (value === "block") return "Blocks the affected message.";
|
||||
if (value === "ask") return "Marks the message for review.";
|
||||
if (value === "drop") return "Excludes the affected message.";
|
||||
if (value === "continue") return "Continues without a review issue.";
|
||||
if (value === "warn") return "Keeps sending possible with a warning.";
|
||||
return "Uses the configured behavior.";
|
||||
if (value === "block") return "i18n:govoplan-campaign.blocks_the_affected_message.0157342a";
|
||||
if (value === "ask") return "i18n:govoplan-campaign.marks_the_message_for_review.d63beb24";
|
||||
if (value === "drop") return "i18n:govoplan-campaign.excludes_the_affected_message.73be963f";
|
||||
if (value === "continue") return "i18n:govoplan-campaign.continues_without_a_review_issue.99ab0684";
|
||||
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
||||
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
import {
|
||||
createMailServerProfile,
|
||||
getMailProfilePolicy,
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
testSmtpSettings,
|
||||
type MailProfilePolicy,
|
||||
type MailSecurity,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
@@ -67,10 +67,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
reload,
|
||||
setError,
|
||||
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
|
||||
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
|
||||
unsavedMessage: isPolicyView
|
||||
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
|
||||
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: isPolicyView ? "i18n:govoplan-campaign.unsaved_mail_policy_changes.c9327491" : "i18n:govoplan-campaign.unsaved_mail_settings.38e1536b",
|
||||
unsavedMessage: isPolicyView ?
|
||||
"i18n:govoplan-campaign.mail_policy_changes_have_unsaved_draft_changes_s.5aee7d4e" :
|
||||
"i18n:govoplan-campaign.mail_settings_have_unsaved_changes_save_them_bef.52644559",
|
||||
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
|
||||
});
|
||||
const server = asRecord(displayDraft.server);
|
||||
@@ -93,9 +93,9 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
|
||||
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
|
||||
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
|
||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
|
||||
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || usingMailProfile && smtpCredentialsInherited;
|
||||
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 imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
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"),
|
||||
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(() => {
|
||||
if (!mailModuleInstalled) {
|
||||
@@ -151,8 +151,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
|
||||
listMailServerProfiles(settings, false, campaignId),
|
||||
listMailServerProfiles(settings, true),
|
||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
|
||||
]);
|
||||
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)]
|
||||
);
|
||||
setMailProfiles(allowedProfiles);
|
||||
setPolicyProfiles(visibleProfiles);
|
||||
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
|
||||
@@ -185,8 +185,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
serverValue: Record<string, unknown>,
|
||||
credentialsValue: Record<string, unknown>,
|
||||
protocol: "smtp" | "imap",
|
||||
inherit: boolean
|
||||
) {
|
||||
inherit: boolean)
|
||||
{
|
||||
serverValue["inherit_" + protocol + "_credentials"] = inherit;
|
||||
if (inherit === false) return;
|
||||
|
||||
@@ -283,21 +283,21 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
function smtpServerPayload() {
|
||||
return mailSmtpSettingsPayload<MailSecurity>(
|
||||
{ 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() {
|
||||
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) },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
|
||||
return {
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
if (locked || inlineMailSettingsBlocked) return;
|
||||
setMailActionState("smtp");
|
||||
setLocalError("");
|
||||
try {
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
|
||||
? await testMailProfileSmtp(settings, selectedProfileId)
|
||||
: await testSmtpSettings(settings, rawSmtpPayload()));
|
||||
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited ?
|
||||
await testMailProfileSmtp(settings, selectedProfileId) :
|
||||
await testSmtpSettings(settings, rawSmtpPayload()));
|
||||
} catch (err) {
|
||||
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -343,16 +343,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
async function runImapTest() {
|
||||
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;
|
||||
}
|
||||
if (imapDisabled) return;
|
||||
setMailActionState("imap");
|
||||
setLocalError("");
|
||||
try {
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited
|
||||
? await testMailProfileImap(settings, selectedProfileId)
|
||||
: await testImapSettings(settings, rawImapPayload()));
|
||||
setImapTestResult(selectedProfileId && imapCredentialsInherited ?
|
||||
await testMailProfileImap(settings, selectedProfileId) :
|
||||
await testImapSettings(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
|
||||
} finally {
|
||||
@@ -362,16 +362,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
async function runFolderLookup() {
|
||||
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;
|
||||
}
|
||||
if (appendTargetFolderDisabled) return;
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited
|
||||
? await listMailProfileImapFolders(settings, selectedProfileId)
|
||||
: await listImapFolders(settings, rawImapPayload()));
|
||||
setFolderResult(selectedProfileId && imapCredentialsInherited ?
|
||||
await listMailProfileImapFolders(settings, selectedProfileId) :
|
||||
await listImapFolders(settings, rawImapPayload()));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
@@ -389,28 +389,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</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 ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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}>
|
||||
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>
|
||||
)}
|
||||
}
|
||||
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && (
|
||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor &&
|
||||
<MailProfilePolicyEditor
|
||||
settings={settings}
|
||||
scopeType="campaign"
|
||||
@@ -421,57 +421,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
ownerGroupId={data.campaign?.owner_group_id}
|
||||
canWrite={!locked}
|
||||
locked={locked}
|
||||
title="Campaign-local mail policy"
|
||||
description="Campaign-local mail limits applied after system, tenant and owner policies."
|
||||
onSaved={refreshMailProfiles}
|
||||
/>
|
||||
)}
|
||||
title="i18n:govoplan-campaign.campaign_local_mail_policy.94f59da0"
|
||||
description="i18n:govoplan-campaign.campaign_local_mail_limits_applied_after_system_.e7f9bb31"
|
||||
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
|
||||
title="Reusable mail profile"
|
||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||
actions={
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
|
||||
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</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 ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save_current_settings_as_profile.7578a50b"}</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
}>
|
||||
|
||||
<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)}>
|
||||
<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>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="New profile name">
|
||||
<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"} />
|
||||
<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 ? i18nMessage("i18n:govoplan-campaign.value_campaign_local_profile.70cd9d43", { value0: data.campaign.name }) : "i18n:govoplan-campaign.campaign_local_profile.c24cf2d1"} />
|
||||
</FormField>
|
||||
</div>
|
||||
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{!campaignProfilesAllowed && <p className="muted small-note">i18n:govoplan-campaign.campaign_local_mail_settings_are_blocked_by_the_.0b2510aa</p>}
|
||||
{selectedProfile &&
|
||||
<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 &&
|
||||
<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>}
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>
|
||||
)}
|
||||
}
|
||||
|
||||
{!isPolicyView && (
|
||||
<Card title="Mail server settings" collapsible>
|
||||
{inlinePolicyMessages.length > 0 && (
|
||||
{!isPolicyView &&
|
||||
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
||||
{inlinePolicyMessages.length > 0 &&
|
||||
<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>
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
}
|
||||
<MailServerSettingsPanel
|
||||
smtp={displayedSmtp}
|
||||
imap={displayedImap}
|
||||
@@ -497,8 +497,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
||||
}}
|
||||
smtpTestLabel={usingMailProfile ? "Test profile SMTP" : "Test SMTP login"}
|
||||
imapTestLabel={usingMailProfile ? "Test profile IMAP" : "Test IMAP login"}
|
||||
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
||||
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={runSmtpTest}
|
||||
onTestImap={runImapTest}
|
||||
@@ -508,15 +508,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
folderLookupResult={folderResult}
|
||||
onUseDetectedFolder={useDetectedSentFolder}
|
||||
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
||||
floatingResults
|
||||
/>
|
||||
floatingResults />
|
||||
|
||||
</Card>
|
||||
)}
|
||||
}
|
||||
|
||||
</>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,11 @@ import { importedRowsNeedAttachmentSource, materializeRecipientImport, type Reci
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { RecipientImportDialog } from "./RecipientDataPage";
|
||||
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";
|
||||
|
||||
|
||||
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 { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
@@ -40,8 +40,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "recipient-data",
|
||||
unsavedTitle: "Unsaved recipient data changes",
|
||||
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
|
||||
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
|
||||
});
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
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 nextDraft = needsAttachmentSource
|
||||
? {
|
||||
const nextDraft = needsAttachmentSource ?
|
||||
{
|
||||
...importedDraft,
|
||||
attachments: {
|
||||
...asRecord(importedDraft.attachments),
|
||||
base_paths: nextBasePaths,
|
||||
base_path: nextBasePaths[0]?.path || "."
|
||||
}
|
||||
}
|
||||
: importedDraft;
|
||||
} :
|
||||
importedDraft;
|
||||
setDraft(nextDraft);
|
||||
markDirty();
|
||||
setImportOpen(false);
|
||||
@@ -115,34 +115,34 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<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} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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>}>
|
||||
{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 && 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>
|
||||
)}
|
||||
{inlineEntries.length > 0 && (
|
||||
<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">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
|
||||
}
|
||||
{inlineEntries.length > 0 &&
|
||||
<div className="admin-table-surface recipient-data-table-surface">
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-recipient-data`}
|
||||
rows={inlineEntries}
|
||||
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
||||
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"
|
||||
pagination={{
|
||||
page: recipientDataPage,
|
||||
@@ -153,15 +153,15 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
setRecipientDataPageSize(pageSize);
|
||||
setRecipientDataPage(1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</Card>
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
{importOpen && (
|
||||
{importOpen &&
|
||||
<RecipientImportDialog
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
@@ -169,11 +169,11 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
||||
existingFields={fieldDefinitions}
|
||||
defaultAttachmentBasePath={defaultImportBasePath}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
onImport={applyRecipientImport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
onImport={applyRecipientImport} />
|
||||
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
type RecipientDataColumnContext = {
|
||||
@@ -194,30 +194,30 @@ function recipientDataColumns({ settings, campaignId, draft, locked, filesModule
|
||||
{ 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",
|
||||
header: "Recipient",
|
||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
||||
width: 260,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (entry) => (
|
||||
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
|
||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
|
||||
render: (entry) =>
|
||||
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
|
||||
<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>}
|
||||
</Link>
|
||||
),
|
||||
</Link>,
|
||||
|
||||
value: firstRecipientEmail
|
||||
},
|
||||
{
|
||||
id: "attachments",
|
||||
header: "Attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
width: 180,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
||||
return (
|
||||
<AttachmentRulesOverlay
|
||||
title={`Attachments for recipient ${index + 1}`}
|
||||
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
||||
rules={attachments}
|
||||
settings={settings}
|
||||
campaignId={campaignId}
|
||||
@@ -227,9 +227,9 @@ function recipientDataColumns({ settings, campaignId, draft, locked, filesModule
|
||||
zipConfig={zipConfig}
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={buildTemplatePreviewContext(draft, entry)}
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||
/>
|
||||
);
|
||||
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
||||
|
||||
|
||||
},
|
||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
||||
},
|
||||
@@ -249,14 +249,14 @@ function recipientDataColumns({ settings, campaignId, draft, locked, filesModule
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
|
||||
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
||||
|
||||
|
||||
},
|
||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
||||
}))
|
||||
];
|
||||
}))];
|
||||
|
||||
}
|
||||
|
||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 VersionLine from "./components/VersionLine";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
@@ -23,7 +23,7 @@ type TemplateBodyMode = "text" | "html" | "both";
|
||||
type BodyEditorMode = "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 [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
|
||||
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
|
||||
@@ -47,9 +47,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
reload,
|
||||
setError,
|
||||
currentStep: "template",
|
||||
unsavedTitle: "Unsaved template changes",
|
||||
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "Loaded",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_template_changes.4209cdec",
|
||||
unsavedMessage: "i18n:govoplan-campaign.the_template_has_unsaved_changes_save_them_befor.89f74fc1",
|
||||
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${formatDateTime(loadedVersion.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a",
|
||||
onLoaded: () => setPreviewIndex(0)
|
||||
});
|
||||
const template = asRecord(displayDraft.template);
|
||||
@@ -80,8 +80,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
const templateText = [
|
||||
getText(template, "subject"),
|
||||
templateBodyMode !== "html" ? getText(template, "text") : "",
|
||||
templateBodyMode !== "text" ? getText(template, "html") : ""
|
||||
].join("\n");
|
||||
templateBodyMode !== "text" ? getText(template, "html") : ""].
|
||||
join("\n");
|
||||
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
|
||||
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
|
||||
const undefinedPlaceholders = useMemo(
|
||||
@@ -122,17 +122,17 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||
})
|
||||
.then((response) => {
|
||||
}).
|
||||
then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
}).
|
||||
catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setAttachmentPreviewRules([]);
|
||||
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setAttachmentPreviewLoading(false);
|
||||
});
|
||||
}, 120);
|
||||
@@ -186,8 +186,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}
|
||||
]);
|
||||
}]
|
||||
);
|
||||
}
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
@@ -228,106 +228,123 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Template</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
|
||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Manage templates</Button>
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</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">
|
||||
<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">
|
||||
<FormField label="Subject">
|
||||
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
|
||||
<input
|
||||
ref={subjectRef}
|
||||
value={getText(template, "subject")}
|
||||
disabled={locked}
|
||||
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 label="Message body format">
|
||||
<div className="template-body-mode" role="tablist" aria-label="Message body format">
|
||||
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
|
||||
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
|
||||
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
|
||||
</div>
|
||||
</FormField>
|
||||
{templateBodyMode === "both" && (
|
||||
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
|
||||
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
|
||||
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
|
||||
</div>
|
||||
)}
|
||||
{visibleBodyEditor === "text" && (
|
||||
<FormField label="Plain text body">
|
||||
{templateBodyMode === "both" &&
|
||||
<SegmentedControl
|
||||
className="template-body-mode template-editor-mode"
|
||||
size="content"
|
||||
width="inline"
|
||||
ariaLabel="i18n:govoplan-campaign.body_editor.8615dd7e"
|
||||
value={visibleBodyEditor}
|
||||
onChange={(mode) => {setActiveBodyEditor(mode);setActiveEditor(mode);}}
|
||||
options={[
|
||||
{ id: "text", label: "i18n:govoplan-campaign.plain_text.9580fcbc" },
|
||||
{ id: "html", label: "i18n:govoplan-campaign.html.9f738ce8" }
|
||||
]}
|
||||
/>
|
||||
}
|
||||
{visibleBodyEditor === "text" &&
|
||||
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0">
|
||||
<textarea
|
||||
ref={textRef}
|
||||
rows={16}
|
||||
value={getText(template, "text")}
|
||||
disabled={locked}
|
||||
onFocus={() => {setActiveBodyEditor("text");setActiveEditor("text");}}
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)}
|
||||
/>
|
||||
onChange={(event) => patchTemplateText("text", event.target.value)} />
|
||||
|
||||
</FormField>
|
||||
)}
|
||||
{visibleBodyEditor === "html" && (
|
||||
<FormField label="HTML body">
|
||||
}
|
||||
{visibleBodyEditor === "html" &&
|
||||
<FormField label="i18n:govoplan-campaign.html_body.77b5ba37">
|
||||
<textarea
|
||||
ref={htmlRef}
|
||||
rows={16}
|
||||
value={getText(template, "html")}
|
||||
disabled={locked}
|
||||
onFocus={() => {setActiveBodyEditor("html");setActiveEditor("html");}}
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)}
|
||||
/>
|
||||
onChange={(event) => patchTemplateText("html", event.target.value)} />
|
||||
|
||||
</FormField>
|
||||
)}
|
||||
}
|
||||
<div className="button-row template-editor-actions">
|
||||
<Button disabled>Load from library</Button>
|
||||
<Button disabled>Save to library</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="template-side-stack">
|
||||
<Card title="Fields">
|
||||
{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>
|
||||
)}
|
||||
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
|
||||
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
|
||||
<Card title="i18n:govoplan-campaign.fields.e8b68527">
|
||||
{invalidNamespacePlaceholders.length > 0 &&
|
||||
<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">i18n:govoplan-campaign.no_template_placeholders_detected_yet.56bba9d1</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
|
||||
namespace="global"
|
||||
fields={globalFieldOptions}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values defined."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_defined.3888623d"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
<h3 className="section-mini-heading">Local fields</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>
|
||||
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.local_fields.c81bb4de</h3>
|
||||
<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
|
||||
namespace="local"
|
||||
fields={localFieldOptions}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields defined."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_defined.8c2ea4b2"
|
||||
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} />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -336,16 +353,16 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
</>
|
||||
</LoadingFrame>
|
||||
|
||||
{previewOpen && (
|
||||
{previewOpen &&
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="Template preview"
|
||||
title="i18n:govoplan-campaign.template_preview.ea0aa8b2"
|
||||
bodyMode={visibleBodyEditor}
|
||||
subject={previewSubject}
|
||||
text={templateBodyMode === "html" ? null : previewText}
|
||||
html={templateBodyMode === "text" ? null : previewHtml}
|
||||
metaItems={templatePreviewMetaItems(previewContext)}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
||||
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."}
|
||||
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 ? "i18n:govoplan-campaign.no_recipient_preview_is_available.9e93a97d" : "i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb"}
|
||||
attachments={previewAttachments}
|
||||
navigation={{
|
||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||
@@ -355,65 +372,70 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
|
||||
onLast: () => setPreviewIndex(previewEntries.length - 1)
|
||||
}}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
/>
|
||||
)}
|
||||
onClose={() => setPreviewOpen(false)} />
|
||||
|
||||
}
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="template"
|
||||
removeLabel="Remove from template"
|
||||
removeLabel="i18n:govoplan-campaign.remove_from_template.5fb2827b"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removePlaceholder}
|
||||
onReplace={replacePlaceholder}
|
||||
onAddField={addUndefinedField}
|
||||
localFields={localFieldOptions}
|
||||
globalFields={globalFieldOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
globalFields={globalFieldOptions} />
|
||||
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function mapResolvedAttachmentsToPreviewBoxes(
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string,
|
||||
draft: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
draft: Record<string, unknown>)
|
||||
: CampaignMessagePreviewAttachment[] {
|
||||
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) {
|
||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
|
||||
}
|
||||
return rules.flatMap((rule) => {
|
||||
const zipProtection = zipProtectionForRule(rule, draft);
|
||||
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.required ? "required" : "optional",
|
||||
rule.pattern
|
||||
].filter(Boolean);
|
||||
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
|
||||
rule.pattern].
|
||||
filter(Boolean);
|
||||
const detail = detailParts.join(" · ");
|
||||
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
|
||||
if (rule.matches.length > 0) {
|
||||
return rule.matches.map((match) => ({
|
||||
filename: match.filename || match.display_path,
|
||||
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,
|
||||
sizeBytes: match.size_bytes,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: 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 || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
|
||||
return [{
|
||||
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
|
||||
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
|
||||
label: rule.label,
|
||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
|
||||
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,
|
||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}];
|
||||
@@ -422,22 +444,22 @@ function mapResolvedAttachmentsToPreviewBoxes(
|
||||
|
||||
function templatePreviewMetaItems(context: Record<string, string>) {
|
||||
return [
|
||||
{ label: "From", value: context["local:from"] || null },
|
||||
{ label: "To", value: context["local:all_to"] || null },
|
||||
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
|
||||
{ label: "Cc", value: context["local:all_cc"] || null },
|
||||
{ label: "Bcc", value: context["local:all_bcc"] || null }
|
||||
];
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: context["local:from"] || null },
|
||||
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: context["local:all_to"] || null },
|
||||
{ label: "i18n:govoplan-campaign.reply_to.c1733667", value: context["local:all_reply_to"] || null },
|
||||
{ label: "i18n:govoplan-campaign.cc.1fd6a880", value: context["local:all_cc"] || 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 };
|
||||
const zipConfig = asRecord(asRecord(draft.attachments).zip);
|
||||
const archives = asArray(zipConfig.archives).map(asRecord);
|
||||
const archive = archives.find((item) => {
|
||||
const id = getText(item, "id");
|
||||
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];
|
||||
if (!archive) return { protected: false, note: null };
|
||||
const legacyMode = getText(archive, "password_mode");
|
||||
@@ -445,13 +467,18 @@ function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = getText(archive, "password_field");
|
||||
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
|
||||
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES";
|
||||
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
|
||||
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
|
||||
const method = getText(archive, "method", "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
|
||||
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 {
|
||||
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 {
|
||||
@@ -460,7 +487,7 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
if (name) return name;
|
||||
if (email) return email;
|
||||
return `Recipient ${index + 1}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.recipient_value.d0233fb6", { value0: index + 1 });
|
||||
}
|
||||
|
||||
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
||||
|
||||
@@ -8,7 +8,7 @@ import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
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 { renderTemplatePreviewText } from "../utils/templatePlaceholders";
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function AttachmentRulesOverlay({
|
||||
campaignId,
|
||||
disabled = false,
|
||||
buttonLabel,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||
basePaths = [],
|
||||
zipConfig = { enabled: false, archives: [] },
|
||||
filesModuleInstalled = false,
|
||||
@@ -91,13 +91,13 @@ export default function AttachmentRulesOverlay({
|
||||
className="attachment-rules-modal"
|
||||
bodyClassName="attachment-rules-body"
|
||||
onClose={cancelOverlay}
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={cancelOverlay}>Cancel</Button>
|
||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
|
||||
<Button onClick={cancelOverlay}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
<AttachmentRulesTable
|
||||
rules={draftRules}
|
||||
settings={settings}
|
||||
@@ -109,20 +109,20 @@ export default function AttachmentRulesOverlay({
|
||||
filesModuleInstalled={filesModuleInstalled}
|
||||
previewContext={previewContext}
|
||||
activeChooserRuleIndex={null}
|
||||
onChange={setDraftRules}
|
||||
/>
|
||||
onChange={setDraftRules} />
|
||||
|
||||
</Dialog>,
|
||||
document.body
|
||||
) : null;
|
||||
|
||||
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}
|
||||
</Button>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
|
||||
@@ -141,8 +141,8 @@ export function AttachmentRulesTable({
|
||||
<div className="attachment-rules-main">
|
||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function AttachmentRulesDataGrid({
|
||||
@@ -150,7 +150,7 @@ export function AttachmentRulesDataGrid({
|
||||
settings,
|
||||
campaignId,
|
||||
disabled = false,
|
||||
emptyText = "No attachment files or matching rules configured yet.",
|
||||
emptyText = "i18n:govoplan-campaign.no_attachment_files_or_matching_rules_configured.c6948b49",
|
||||
basePaths = [],
|
||||
id = "attachment-rules",
|
||||
activeChooserRuleIndex = null,
|
||||
@@ -196,10 +196,10 @@ export function AttachmentRulesDataGrid({
|
||||
const currentPath = getText(rule, "base_dir");
|
||||
const currentBasePathId = getText(rule, "base_path_id");
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
|
||||
const basePath = basePaths.find((item) => item.id === currentBasePathId)
|
||||
?? basePaths.find((item) => item.path === currentPath)
|
||||
?? (!explicitlyReferenced ? basePaths[0] : undefined)
|
||||
?? null;
|
||||
const basePath = basePaths.find((item) => item.id === currentBasePathId) ??
|
||||
basePaths.find((item) => item.path === currentPath) ?? (
|
||||
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
||||
null;
|
||||
if (!basePath) return;
|
||||
setFileChooser({ ruleIndex, basePath });
|
||||
}
|
||||
@@ -225,12 +225,12 @@ export function AttachmentRulesDataGrid({
|
||||
rows={rules}
|
||||
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)}
|
||||
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
|
||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
|
||||
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="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
||||
className="attachment-rules-table-wrap attachment-rules-table"
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||
/>
|
||||
{fileChooser && ManagedFileChooser && (
|
||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined} />
|
||||
|
||||
{fileChooser && ManagedFileChooser &&
|
||||
<ManagedFileChooser
|
||||
open
|
||||
settings={settings}
|
||||
@@ -244,11 +244,11 @@ export function AttachmentRulesDataGrid({
|
||||
previewContext={previewContext}
|
||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
||||
onClose={() => setFileChooser(null)}
|
||||
onSelectAttachment={selectAttachment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
onSelectAttachment={selectAttachment} />
|
||||
|
||||
}
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
type AttachmentRuleColumnContext = {
|
||||
@@ -267,10 +267,10 @@ type AttachmentRuleColumnContext = {
|
||||
|
||||
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
|
||||
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",
|
||||
header: "Base path",
|
||||
header: "i18n:govoplan-campaign.base_path.6a4867ca",
|
||||
width: 250,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
@@ -280,9 +280,9 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
const currentBasePathValue = getText(rule, "base_dir");
|
||||
const currentBasePathId = getText(rule, "base_path_id");
|
||||
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
|
||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
|
||||
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
|
||||
?? (!explicitlyReferenced ? basePaths[0] : undefined);
|
||||
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ??
|
||||
basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? (
|
||||
!explicitlyReferenced ? basePaths[0] : undefined);
|
||||
return (
|
||||
<select
|
||||
value={selectedBasePath?.id ?? ""}
|
||||
@@ -290,25 +290,25 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
onChange={(event) => {
|
||||
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
|
||||
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
|
||||
}}
|
||||
>
|
||||
{!selectedBasePath && (
|
||||
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
|
||||
)}
|
||||
}}>
|
||||
|
||||
{!selectedBasePath &&
|
||||
<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>)}
|
||||
</select>
|
||||
);
|
||||
</select>);
|
||||
|
||||
},
|
||||
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
|
||||
},
|
||||
{
|
||||
id: "file_filter",
|
||||
header: "File / pattern",
|
||||
header: "i18n:govoplan-campaign.file_pattern.86320b07",
|
||||
width: "minmax(260px, 1fr)",
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (rule, index) => (
|
||||
render: (rule, index) =>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
className="chooser-display-input"
|
||||
@@ -316,7 +316,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
disabled={disabled || basePaths.length === 0}
|
||||
readOnly={filesModuleInstalled}
|
||||
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) => {
|
||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||
}}
|
||||
@@ -326,26 +326,58 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
event.preventDefault();
|
||||
openFileChooser(index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
|
||||
</div>
|
||||
),
|
||||
}} />
|
||||
|
||||
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>i18n:govoplan-campaign.choose.78b7c9f6</Button>}
|
||||
</div>,
|
||||
|
||||
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: "ZIP archive",
|
||||
header: "i18n:govoplan-campaign.zip_archive.5a2430dd",
|
||||
width: 240,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: {
|
||||
options: [
|
||||
{ value: "exclude", label: "Exclude from ZIP" },
|
||||
{ value: "inherit", label: "Campaign standard" },
|
||||
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
|
||||
]
|
||||
{ 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 }))]
|
||||
|
||||
},
|
||||
render: (rule: AttachmentRule, index: number) => {
|
||||
const selection = attachmentRuleZipSelection(rule);
|
||||
@@ -353,36 +385,36 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
||||
<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>
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.zip_archive_for_value.3dfaf812", { value0: getText(rule, "label") || `attachment ${index + 1}` })}
|
||||
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}>
|
||||
|
||||
<option value="exclude">i18n:govoplan-campaign.exclude_from_zip.a6422da1</option>
|
||||
<option value="inherit">i18n:govoplan-campaign.campaign_standard.c88ce44c</option>
|
||||
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
</select>);
|
||||
|
||||
},
|
||||
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
|
||||
} 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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 180,
|
||||
sticky: "end",
|
||||
render: (_rule, index) => (
|
||||
render: (_rule, index) =>
|
||||
<DataGridRowActions
|
||||
disabled={disabled}
|
||||
onAddBelow={() => addRule(index)}
|
||||
onRemove={() => removeRule(index)}
|
||||
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
|
||||
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
|
||||
addLabel="Add attachment below"
|
||||
removeLabel="Remove attachment"
|
||||
moveUpLabel="Move attachment up"
|
||||
moveDownLabel="Move attachment down"
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
addLabel="i18n:govoplan-campaign.add_attachment_below.c7b66ffd"
|
||||
removeLabel="i18n:govoplan-campaign.remove_attachment.0fcc6594"
|
||||
moveUpLabel="i18n:govoplan-campaign.move_attachment_up.28614804"
|
||||
moveDownLabel="i18n:govoplan-campaign.move_attachment_down.a4d6604f" />
|
||||
|
||||
|
||||
}];
|
||||
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
updateCampaignOwner,
|
||||
upsertCampaignShare,
|
||||
type CampaignShare,
|
||||
type CampaignShareTargets
|
||||
} from "../../../api/campaigns";
|
||||
type CampaignShareTargets } from
|
||||
"../../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||
import { Dialog } 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";
|
||||
|
||||
export default function CampaignAccessCard({
|
||||
@@ -23,12 +23,12 @@ export default function CampaignAccessCard({
|
||||
campaign,
|
||||
onChanged,
|
||||
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 [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||
@@ -41,20 +41,33 @@ export default function CampaignAccessCard({
|
||||
const [shareTargetId, setShareTargetId] = useState("");
|
||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
||||
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() {
|
||||
try {
|
||||
const [nextTargets, nextShares] = await Promise.all([
|
||||
getCampaignShareTargets(settings, campaign.id),
|
||||
getCampaignShares(settings, campaign.id)
|
||||
]);
|
||||
getCampaignShares(settings, campaign.id)]
|
||||
);
|
||||
setTargets(nextTargets);
|
||||
setShares(nextShares.filter((item) => !item.revoked_at));
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.startsWith("403 ")) setAvailable(false);
|
||||
else onError(message);
|
||||
if (message.startsWith("403 ")) setAvailable(false);else
|
||||
onError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,37 +81,58 @@ export default function CampaignAccessCard({
|
||||
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
|
||||
const targetMap = useMemo(() => new Map([
|
||||
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
|
||||
]), [targets]);
|
||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
|
||||
), [targets]);
|
||||
|
||||
const ownerLabel = campaign.owner_group_id
|
||||
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
|
||||
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
|
||||
const ownerLabel = campaign.owner_group_id ?
|
||||
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
|
||||
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
|
||||
|
||||
async function saveOwner() {
|
||||
if (!ownerId) return;
|
||||
function closeOwnerDialog() {
|
||||
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);
|
||||
try {
|
||||
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
|
||||
? { owner_user_id: ownerId, owner_group_id: null }
|
||||
: { owner_user_id: null, owner_group_id: ownerId });
|
||||
await updateCampaignOwner(settings, campaign.id, ownerType === "user" ?
|
||||
{ owner_user_id: ownerId, owner_group_id: null } :
|
||||
{ owner_user_id: null, owner_group_id: ownerId });
|
||||
setOwnerOpen(false);
|
||||
await onChanged();
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function saveShare() {
|
||||
if (!shareTargetId) return;
|
||||
async function saveShare(): Promise<boolean> {
|
||||
if (!shareTargetId) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
|
||||
setShareOpen(false);
|
||||
setShareTargetId("");
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
return true;
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));return false;} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
async function revoke() {
|
||||
@@ -108,14 +142,14 @@ export default function CampaignAccessCard({
|
||||
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
|
||||
setRevokeTarget(null);
|
||||
await load();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
} catch (err) {onError(err instanceof Error ? err.message : String(err));} finally
|
||||
{setBusy(false);}
|
||||
}
|
||||
|
||||
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||
{
|
||||
id: "target",
|
||||
header: "Shared with",
|
||||
header: "i18n:govoplan-campaign.shared_with.6203f449",
|
||||
width: "minmax(220px, 1fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
@@ -125,35 +159,36 @@ export default function CampaignAccessCard({
|
||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
||||
render: (row) => {
|
||||
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: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
|
||||
], [targetMap]);
|
||||
{ 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: "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]);
|
||||
|
||||
if (available === false) return null;
|
||||
|
||||
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>}>
|
||||
<p><strong>Owner:</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>
|
||||
<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."} />
|
||||
<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>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</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 ? "i18n:govoplan-campaign.loading_campaign_access.056299e3" : "i18n:govoplan-campaign.this_campaign_has_no_explicit_shares.978012d1"} />
|
||||
</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></>}>
|
||||
<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="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>
|
||||
<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="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="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 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></>}>
|
||||
<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="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="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>
|
||||
<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="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="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="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>
|
||||
|
||||
<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()} />
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
@@ -9,16 +9,16 @@ import {
|
||||
unlockCampaignVersionUserLock,
|
||||
unlockCampaignVersionValidation,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem,
|
||||
} from "../../../api/campaigns";
|
||||
type CampaignVersionListItem } from
|
||||
"../../../api/campaigns";
|
||||
import {
|
||||
canUnlockValidationVersion,
|
||||
formatDateTime,
|
||||
isFinalLockedVersion,
|
||||
isHistoricalCampaignVersion,
|
||||
isPermanentUserLockedVersion,
|
||||
isTemporaryUserLockedVersion,
|
||||
} from "../utils/campaignView";
|
||||
isTemporaryUserLockedVersion } from
|
||||
"../utils/campaignView";
|
||||
|
||||
type LockedVersionNoticeProps = {
|
||||
settings: ApiSettings;
|
||||
@@ -48,7 +48,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
validationLock,
|
||||
temporaryUserLock,
|
||||
permanentUserLock,
|
||||
finalLock,
|
||||
finalLock
|
||||
});
|
||||
|
||||
async function runAction(action: Exclude<ConfirmAction, null>) {
|
||||
@@ -59,13 +59,13 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
try {
|
||||
if (action === "unlock-validation") {
|
||||
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") {
|
||||
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 {
|
||||
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();
|
||||
} catch (err) {
|
||||
@@ -83,7 +83,7 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
try {
|
||||
const result = await forkCampaignVersion(settings, campaignId, version.id, {
|
||||
current_flow: "manual",
|
||||
current_step: version.current_step ?? null,
|
||||
current_step: version.current_step ?? null
|
||||
});
|
||||
setLocalMessage(`Created editable version #${result.version.version_number}.`);
|
||||
await reload();
|
||||
@@ -105,26 +105,26 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
{localError && <span className="locked-version-error"> {localError}</span>}
|
||||
</div>
|
||||
<div className="button-row compact-actions locked-version-actions">
|
||||
{validationLock && (
|
||||
{validationLock &&
|
||||
<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>
|
||||
)}
|
||||
{temporaryUserLock && (
|
||||
}
|
||||
{temporaryUserLock &&
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
|
||||
{busy ? "Working…" : "Unlock"}
|
||||
{busy ? "i18n:govoplan-campaign.working.13b7bfca" : "i18n:govoplan-campaign.unlock.1526a17e"}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
|
||||
Lock permanently
|
||||
i18n:govoplan-campaign.lock_permanently.cc0ce9e7
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{canCreateEditableCopy && (
|
||||
}
|
||||
{canCreateEditableCopy &&
|
||||
<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>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -139,10 +139,10 @@ export default function LockedVersionNotice({ settings, campaignId, version, cur
|
||||
const action = confirmAction;
|
||||
setConfirmAction(null);
|
||||
if (action) void runAction(action);
|
||||
}}
|
||||
/>
|
||||
</DismissibleAlert>
|
||||
);
|
||||
}} />
|
||||
|
||||
</DismissibleAlert>);
|
||||
|
||||
}
|
||||
|
||||
type LockFlags = {
|
||||
@@ -155,81 +155,81 @@ type LockFlags = {
|
||||
|
||||
function lockPresentation(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
flags: LockFlags,
|
||||
): { kind: string; title: string; description: string; info: string } {
|
||||
flags: LockFlags)
|
||||
: {kind: string;title: string;description: string;info: string;} {
|
||||
if (flags.historicalVersion) {
|
||||
return {
|
||||
kind: "historical",
|
||||
title: "Historical version.",
|
||||
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
|
||||
info: `Version #${version?.version_number ?? "—"}.`,
|
||||
title: "i18n:govoplan-campaign.historical_version.adac5dba",
|
||||
description: "i18n:govoplan-campaign.this_version_is_review_only_only_the_campaign_s_.9e68f6a0",
|
||||
info: `Version #${version?.version_number ?? "—"}.`
|
||||
};
|
||||
}
|
||||
if (flags.temporaryUserLock) {
|
||||
return {
|
||||
kind: "temporary-user",
|
||||
title: "Temporarily locked version.",
|
||||
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
|
||||
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.temporarily_locked_version.696f85e3",
|
||||
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)}.` : ""
|
||||
};
|
||||
}
|
||||
if (flags.permanentUserLock) {
|
||||
return {
|
||||
kind: "permanent-user",
|
||||
title: "Permanently locked version.",
|
||||
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
|
||||
info: version?.user_locked_at || version?.published_at
|
||||
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
|
||||
: "",
|
||||
title: "i18n:govoplan-campaign.permanently_locked_version.248c2eeb",
|
||||
description: "i18n:govoplan-campaign.this_user_requested_snapshot_cannot_be_unlocked_.9e91210c",
|
||||
info: version?.user_locked_at || version?.published_at ?
|
||||
`Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.` :
|
||||
""
|
||||
};
|
||||
}
|
||||
if (flags.validationLock) {
|
||||
return {
|
||||
kind: "validation",
|
||||
title: "Temporarily locked after validation.",
|
||||
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
|
||||
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.temporarily_locked_after_validation.c558ce56",
|
||||
description: "i18n:govoplan-campaign.this_protects_the_validated_build_input_unlockin.ed890772",
|
||||
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
if (flags.finalLock) {
|
||||
return {
|
||||
kind: "delivery",
|
||||
title: "Permanently locked delivery snapshot.",
|
||||
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.permanently_locked_delivery_snapshot.c3670daa",
|
||||
description: "i18n:govoplan-campaign.delivery_has_started_or_the_version_is_in_a_fina.571e86ed",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "other",
|
||||
title: "Locked version.",
|
||||
description: "This version is read-only. Create an editable copy to continue working.",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
|
||||
title: "i18n:govoplan-campaign.locked_version.e755b179",
|
||||
description: "i18n:govoplan-campaign.this_version_is_read_only_create_an_editable_cop.9e0afd29",
|
||||
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : ""
|
||||
};
|
||||
}
|
||||
|
||||
function confirmDialogTitle(action: ConfirmAction): string {
|
||||
if (action === "unlock-validation") return "Unlock validation?";
|
||||
if (action === "unlock-user") return "Unlock temporary lock?";
|
||||
if (action === "permanent") return "Lock permanently?";
|
||||
return "Confirm action";
|
||||
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.e3066247";
|
||||
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468";
|
||||
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.24d5b74d";
|
||||
return "i18n:govoplan-campaign.confirm_action.50649c5c";
|
||||
}
|
||||
|
||||
function confirmDialogMessage(action: ConfirmAction): string {
|
||||
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") {
|
||||
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") {
|
||||
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 {
|
||||
if (action === "unlock-validation") return "Unlock validation";
|
||||
if (action === "unlock-user") return "Unlock";
|
||||
if (action === "permanent") return "Lock permanently";
|
||||
return "Confirm";
|
||||
if (action === "unlock-validation") return "i18n:govoplan-campaign.unlock_validation.f01952b6";
|
||||
if (action === "unlock-user") return "i18n:govoplan-campaign.unlock.1526a17e";
|
||||
if (action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
||||
};
|
||||
|
||||
export default function CampaignMessagePreviewOverlay({
|
||||
title = "Message preview",
|
||||
title = "i18n:govoplan-campaign.message_preview.58de1450",
|
||||
subject,
|
||||
bodyMode = "text",
|
||||
text,
|
||||
@@ -51,12 +51,12 @@ export default function CampaignMessagePreviewOverlay({
|
||||
metaItems = [],
|
||||
attachments = [],
|
||||
raw,
|
||||
rawLabel = "Raw MIME",
|
||||
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
||||
navigation,
|
||||
closeLabel = "Close",
|
||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||
onClose
|
||||
}: 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 }));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -95,23 +95,23 @@ export default function CampaignMessagePreviewOverlay({
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
{(recipientLabel || recipientNote || navigation) && (
|
||||
{(recipientLabel || recipientNote || navigation) &&
|
||||
<div className="template-preview-toolbar">
|
||||
<div>
|
||||
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||||
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||||
</div>
|
||||
{navigation && (
|
||||
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
|
||||
<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.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
|
||||
{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="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="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>
|
||||
<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.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.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="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<MessageDisplayPanel
|
||||
title={shownSubject}
|
||||
@@ -121,20 +121,20 @@ export default function CampaignMessagePreviewOverlay({
|
||||
preferredBodyMode={bodyMode}
|
||||
deriveTextFromHtml={false}
|
||||
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">
|
||||
<summary>{rawLabel}</summary>
|
||||
<pre className="mock-message-raw">{raw}</pre>
|
||||
</details>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
removePlaceholderFromText,
|
||||
replacePlaceholderInText,
|
||||
type TemplateNamespace,
|
||||
type UndefinedPlaceholder
|
||||
} from "../utils/templatePlaceholders";
|
||||
type UndefinedPlaceholder } from
|
||||
"../utils/templatePlaceholders";
|
||||
import {
|
||||
TemplateFieldChipList,
|
||||
UndefinedPlaceholderDecisionDialog,
|
||||
UndefinedPlaceholderList,
|
||||
type TemplateFieldOption
|
||||
} from "./TemplatePlaceholderControls";
|
||||
type TemplateFieldOption } from
|
||||
"./TemplatePlaceholderControls";
|
||||
|
||||
export default function TemplateExpressionEditorDialog({
|
||||
open,
|
||||
@@ -26,27 +26,27 @@ export default function TemplateExpressionEditorDialog({
|
||||
globalFields,
|
||||
placeholder,
|
||||
description,
|
||||
saveLabel = "Save",
|
||||
saveLabel = "i18n:govoplan-campaign.save.efc007a3",
|
||||
validate,
|
||||
onCancel,
|
||||
onSave,
|
||||
onAddField,
|
||||
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 [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@@ -113,22 +113,22 @@ export default function TemplateExpressionEditorDialog({
|
||||
<Dialog
|
||||
open={open && !undefinedDialog}
|
||||
title={title}
|
||||
closeLabel="Cancel filename changes"
|
||||
closeLabel="i18n:govoplan-campaign.cancel_filename_changes.b558598e"
|
||||
className="template-expression-dialog"
|
||||
headerClassName="template-expression-dialog-header"
|
||||
bodyClassName="template-expression-dialog-body"
|
||||
footerClassName="template-expression-dialog-footer"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
}>
|
||||
|
||||
{description && <p className="muted template-expression-description">{description}</p>}
|
||||
<label className="template-expression-value-field">
|
||||
<span>Archive filename</span>
|
||||
<span>i18n:govoplan-campaign.archive_filename.47becf3d</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draftValue}
|
||||
@@ -140,46 +140,46 @@ export default function TemplateExpressionEditorDialog({
|
||||
event.preventDefault();
|
||||
requestSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}} />
|
||||
|
||||
</label>
|
||||
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
|
||||
|
||||
<h3 className="section-mini-heading">Recipient fields</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>
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.recipient_fields.fdbcd95b</h3>
|
||||
<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
|
||||
namespace="local"
|
||||
fields={localFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No recipient fields are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_recipient_fields_are_available.5b7943ad"
|
||||
onInsert={insertPlaceholder} />
|
||||
|
||||
<h3 className="section-mini-heading">Campaign fields</h3>
|
||||
|
||||
<h3 className="section-mini-heading">i18n:govoplan-campaign.campaign_fields.969e7d80</h3>
|
||||
<TemplateFieldChipList
|
||||
namespace="global"
|
||||
fields={globalFields}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values are available."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
empty="i18n:govoplan-campaign.no_campaign_fields_or_global_values_are_availabl.b2e4089d"
|
||||
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} />
|
||||
</Dialog>
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="archive filename"
|
||||
removeLabel="Remove from filename"
|
||||
contextLabel="i18n:govoplan-campaign.archive_filename.de958cbd"
|
||||
removeLabel="i18n:govoplan-campaign.remove_from_filename.560f7f42"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removeUndefined}
|
||||
onReplace={replaceUndefined}
|
||||
onAddField={addUndefined}
|
||||
localFields={localFields}
|
||||
globalFields={globalFields}
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
|
||||
/>
|
||||
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")} />
|
||||
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } 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";
|
||||
|
||||
export type TemplateFieldOption = {
|
||||
@@ -16,14 +16,14 @@ export function TemplateFieldChipList({
|
||||
empty,
|
||||
disabled = false,
|
||||
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>;
|
||||
return (
|
||||
<div className="field-chip-list">
|
||||
@@ -35,46 +35,46 @@ export function TemplateFieldChipList({
|
||||
className={`field-chip field-chip-button ${used ? "used" : ""}`}
|
||||
key={`${namespace}:${field.name}`}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
</button>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function UndefinedPlaceholderList({
|
||||
items,
|
||||
empty = "No undefined placeholders detected.",
|
||||
empty = "i18n:govoplan-campaign.no_undefined_placeholders_detected.1cbdf41b",
|
||||
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>;
|
||||
return (
|
||||
<div className="field-chip-list">
|
||||
{items.map((item) => (
|
||||
{items.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
className="field-chip field-chip-button undefined"
|
||||
key={`${item.raw}:${item.reason}`}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
onClick={() => onSelect(item)}>
|
||||
|
||||
{item.display}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function UndefinedPlaceholderDecisionDialog({
|
||||
field,
|
||||
contextLabel = "template",
|
||||
removeLabel = "Remove placeholder",
|
||||
removeLabel = "i18n:govoplan-campaign.remove_placeholder.3f575c72",
|
||||
localFields = [],
|
||||
globalFields = [],
|
||||
onCancel,
|
||||
@@ -82,23 +82,23 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
onReplace,
|
||||
onAddField,
|
||||
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(
|
||||
() => [
|
||||
...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]
|
||||
);
|
||||
const [replacement, setReplacement] = useState("");
|
||||
@@ -121,42 +121,42 @@ export function UndefinedPlaceholderDecisionDialog({
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(field)}
|
||||
title="Undefined field reference"
|
||||
closeLabel="Return to editing"
|
||||
title="i18n:govoplan-campaign.undefined_field_reference.4ff8e266"
|
||||
closeLabel="i18n:govoplan-campaign.return_to_editing.37d4a5f0"
|
||||
className="template-action-dialog"
|
||||
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>
|
||||
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
|
||||
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
{field && (
|
||||
{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}>i18n:govoplan-campaign.add_field.039c6315</Button>
|
||||
</> :
|
||||
undefined}>
|
||||
|
||||
{field &&
|
||||
<>
|
||||
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
|
||||
{field.reason === "invalid-namespace" && (
|
||||
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{onReplace && replacementOptions.length > 0 && (
|
||||
<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" &&
|
||||
<DismissibleAlert tone="warning">i18n:govoplan-campaign.use_the_namespace.a4c2669d <code>global:</code> or <code>local:</code>.</DismissibleAlert>
|
||||
}
|
||||
{field.reason === "missing-field" &&
|
||||
<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 &&
|
||||
<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)}>
|
||||
{replacementOptions.map((option) => (
|
||||
{replacementOptions.map((option) =>
|
||||
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
|
||||
{option.namespace}:{option.label || option.name}
|
||||
</option>
|
||||
))}
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
</>
|
||||
}
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
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 { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
||||
import { formatDateTime } from "../utils/campaignView";
|
||||
|
||||
type VersionLineProps = {
|
||||
@@ -13,8 +13,7 @@ type VersionLineProps = {
|
||||
|
||||
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { requestNavigation } = useCampaignUnsavedChanges();
|
||||
const navigate = useGuardedNavigate();
|
||||
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 first = currentIndex > 0 ? sorted[0] : null;
|
||||
@@ -25,7 +24,7 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
|
||||
|
||||
function openVersion(target: CampaignVersionListItem) {
|
||||
const targetUrl = versionTarget(location.pathname, location.search, target.id);
|
||||
requestNavigation(() => navigate(targetUrl));
|
||||
navigate(targetUrl);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -33,32 +32,32 @@ export default function VersionLine({ version, versions = [], status, loadedAt }
|
||||
<VersionArrow
|
||||
icon="first"
|
||||
target={first}
|
||||
fallbackLabel="Already at first version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.already_at_first_version.b90fad64"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<VersionArrow
|
||||
icon="previous"
|
||||
target={previous}
|
||||
fallbackLabel="No older version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
<span>Version {version ? `#${version.version_number}` : "—"}</span>
|
||||
fallbackLabel="i18n:govoplan-campaign.no_older_version.4355dee8"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<span>i18n:govoplan-campaign.version.2da600bf {version ? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: version.version_number }) : "—"}</span>
|
||||
<VersionArrow
|
||||
icon="next"
|
||||
target={next}
|
||||
fallbackLabel="No newer version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.no_newer_version.b210a41c"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<VersionArrow
|
||||
icon="latest"
|
||||
target={latest}
|
||||
fallbackLabel="Already at latest version"
|
||||
onOpen={openVersion}
|
||||
/>
|
||||
fallbackLabel="i18n:govoplan-campaign.already_at_latest_version.7533bd9d"
|
||||
onOpen={openVersion} />
|
||||
|
||||
<span className="version-line-separator">·</span>
|
||||
<span>{suffix}</span>
|
||||
</p>
|
||||
);
|
||||
</p>);
|
||||
|
||||
}
|
||||
|
||||
type VersionArrowProps = {
|
||||
@@ -76,8 +75,8 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
|
||||
return (
|
||||
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
|
||||
<Icon aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
</span>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,11 +85,11 @@ function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps
|
||||
className="version-arrow"
|
||||
onClick={() => onOpen(target)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
>
|
||||
aria-label={actionLabel}>
|
||||
|
||||
<Icon aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
</button>);
|
||||
|
||||
}
|
||||
|
||||
function getVersionIcon(icon: VersionArrowProps["icon"]) {
|
||||
@@ -110,13 +109,13 @@ function getVersionActionLabel(icon: VersionArrowProps["icon"], target: Campaign
|
||||
const versionLabel = target ? `version #${target.version_number}` : "version";
|
||||
switch (icon) {
|
||||
case "first":
|
||||
return `Open first ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_first_value.ea8f5c5b", { value0: versionLabel });
|
||||
case "previous":
|
||||
return `Open previous ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_previous_value.e8d4794b", { value0: versionLabel });
|
||||
case "next":
|
||||
return `Open next ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_next_value.1315037e", { value0: versionLabel });
|
||||
case "latest":
|
||||
return `Open latest ${versionLabel}`;
|
||||
return i18nMessage("i18n:govoplan-campaign.open_latest_value.40bdb320", { value0: versionLabel });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function resolveStep(value: StepValue): string | null | undefined {
|
||||
}
|
||||
|
||||
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({
|
||||
@@ -70,7 +70,7 @@ export function useCampaignDraftEditor({
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
|
||||
const [localError, setLocalError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,7 +97,7 @@ export function useCampaignDraftEditor({
|
||||
|
||||
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
@@ -119,7 +119,7 @@ export function useCampaignDraftEditor({
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(text);
|
||||
setSaveState("Save failed");
|
||||
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
||||
return false;
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||
|
||||
@@ -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 { useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
getCampaignWorkspace
|
||||
getCampaignWorkspaceDelta,
|
||||
type CampaignWorkspaceDeltaResponse
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||
|
||||
@@ -34,6 +36,21 @@ export function useCampaignWorkspaceData(
|
||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 () => {
|
||||
if (!campaignId) return;
|
||||
@@ -41,26 +58,39 @@ export function useCampaignWorkspaceData(
|
||||
setError("");
|
||||
try {
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
const response = await getCampaignWorkspace(settings, campaignId, {
|
||||
let nextWatermark = getDeltaWatermark(queryKey);
|
||||
let merged: CampaignWorkspaceData = dataRef.current;
|
||||
let hasMore = false;
|
||||
do {
|
||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||
versionId: selectedVersionId,
|
||||
includeCurrentVersion,
|
||||
includeSummary,
|
||||
includeVersions: shouldLoadVersions,
|
||||
since: nextWatermark,
|
||||
});
|
||||
|
||||
setData({
|
||||
campaign: response.campaign,
|
||||
versions: response.versions,
|
||||
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) {
|
||||
dataRef.current = initialData;
|
||||
setData(initialData);
|
||||
resetDeltaWatermark(queryKey);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
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(() => {
|
||||
reload();
|
||||
@@ -68,3 +98,34 @@ export function useCampaignWorkspaceData(
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
allow_campaign_profiles?: boolean | null;
|
||||
@@ -17,11 +17,11 @@ export function campaignMailSettingsPolicyState({
|
||||
effectivePolicy,
|
||||
selectedProfileId,
|
||||
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 usingMailProfile = Boolean(selectedProfileId);
|
||||
const inlineMailSettingsBlocked = !campaignProfilesAllowed && !usingMailProfile;
|
||||
|
||||
@@ -112,7 +112,7 @@ export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentS
|
||||
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
||||
const [, ownerType, ...ownerIdParts] = value.split(":");
|
||||
const ownerId = ownerIdParts.join(":").trim();
|
||||
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
||||
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
|
||||
return { ownerType, ownerId };
|
||||
}
|
||||
|
||||
@@ -135,12 +135,12 @@ export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||
};
|
||||
|
||||
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||
{ label: "Campaign attachments", path: "attachments" },
|
||||
{ label: "Campaign root", path: "." },
|
||||
{ label: "Shared group files", path: "group/shared" },
|
||||
{ label: "Tenant templates", path: "tenant/templates" },
|
||||
{ label: "Personal upload area", path: "user/uploads" }
|
||||
];
|
||||
{ label: "i18n:govoplan-campaign.campaign_attachments.926bcbe6", path: "attachments" },
|
||||
{ label: "i18n:govoplan-campaign.campaign_root.7eccd949", path: "." },
|
||||
{ label: "i18n:govoplan-campaign.shared_group_files.fffa6e27", path: "group/shared" },
|
||||
{ label: "i18n:govoplan-campaign.tenant_templates.ac6653bf", path: "tenant/templates" },
|
||||
{ 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 {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
||||
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => normalizeAttachmentRules(entry.attachments)).
|
||||
filter((rule) => attachmentRuleUsesBasePath(rule, basePath)).
|
||||
length;
|
||||
}
|
||||
|
||||
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 {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => asArray(entry.attachments))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => asArray(entry.attachments)).
|
||||
length;
|
||||
}
|
||||
|
||||
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
|
||||
@@ -178,8 +178,8 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
|
||||
|
||||
export function buildImportTableFromRows(
|
||||
sourceRows: string[][],
|
||||
options: { headerRows: number; delimiter?: "," | ";" | "\t" }
|
||||
): RecipientImportTable {
|
||||
options: {headerRows: number;delimiter?: "," | ";" | "\t";})
|
||||
: RecipientImportTable {
|
||||
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 headerRows = rows.slice(0, normalizedHeaderRows);
|
||||
@@ -269,8 +269,8 @@ export function recipientImportHeaderFingerprints(headers: string[]): Pick<Recip
|
||||
|
||||
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
return profiles
|
||||
.map((profile): RecipientMappingProfileMatch | null => {
|
||||
return profiles.
|
||||
map((profile): RecipientMappingProfileMatch | null => {
|
||||
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
|
||||
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
|
||||
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
|
||||
@@ -287,9 +287,9 @@ export function matchRecipientMappingProfiles(table: RecipientImportTable, profi
|
||||
missingHeaders: diff.missingHeaders,
|
||||
extraHeaders: diff.extraHeaders
|
||||
};
|
||||
})
|
||||
.filter((match): match is RecipientMappingProfileMatch => match !== null)
|
||||
.sort((left, right) => {
|
||||
}).
|
||||
filter((match): match is RecipientMappingProfileMatch => match !== null).
|
||||
sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
|
||||
});
|
||||
@@ -298,8 +298,8 @@ export function matchRecipientMappingProfiles(table: RecipientImportTable, profi
|
||||
export function applyRecipientMappingProfile(
|
||||
table: RecipientImportTable,
|
||||
profile: RecipientMappingProfile,
|
||||
existingFields: ImportFieldDefinition[]
|
||||
): RecipientColumnMapping[] {
|
||||
existingFields: ImportFieldDefinition[])
|
||||
: RecipientColumnMapping[] {
|
||||
const defaults = defaultColumnMappings(table.headers, existingFields);
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
|
||||
@@ -327,8 +327,8 @@ export function applyRecipientMappingProfile(
|
||||
export function buildRecipientImportPreview(
|
||||
table: RecipientImportTable,
|
||||
mappings: RecipientColumnMapping[],
|
||||
options: RecipientImportPreviewOptions
|
||||
): RecipientImportPreview {
|
||||
options: RecipientImportPreviewOptions)
|
||||
: RecipientImportPreview {
|
||||
const existingFields = options.existingFields;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
@@ -337,8 +337,8 @@ export function buildRecipientImportPreview(
|
||||
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
|
||||
const valueSeparators = options.valueSeparators || ",;|";
|
||||
|
||||
const rows = table.dataRows
|
||||
.map((cells, index): ImportedRecipientRow | null => {
|
||||
const rows = table.dataRows.
|
||||
map((cells, index): ImportedRecipientRow | null => {
|
||||
if (cells.every((cell) => !cell.trim())) return null;
|
||||
const rowNumber = table.firstDataRowNumber + index;
|
||||
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
|
||||
@@ -403,7 +403,7 @@ export function buildRecipientImportPreview(
|
||||
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
|
||||
}
|
||||
}
|
||||
if (addresses.to.length === 0) issues.push("Missing To address");
|
||||
if (addresses.to.length === 0) issues.push("i18n:govoplan-campaign.missing_to_address.d4ff53b4");
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
@@ -416,8 +416,8 @@ export function buildRecipientImportPreview(
|
||||
patterns,
|
||||
issues: [...new Set(issues)]
|
||||
};
|
||||
})
|
||||
.filter((row): row is ImportedRecipientRow => row !== null);
|
||||
}).
|
||||
filter((row): row is ImportedRecipientRow => row !== null);
|
||||
|
||||
return {
|
||||
table,
|
||||
@@ -432,14 +432,14 @@ export function buildRecipientImportPreview(
|
||||
export function materializeRecipientImport(
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions
|
||||
): Record<string, unknown> {
|
||||
options: MaterializeImportOptions)
|
||||
: Record<string, unknown> {
|
||||
const currentEntries = asRecord(draft.entries);
|
||||
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
|
||||
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||
const importedEntries = preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const importedEntries = preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
|
||||
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
|
||||
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
|
||||
@@ -537,9 +537,9 @@ function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: Impo
|
||||
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
|
||||
const existing = asArray(fieldsValue).map(asRecord);
|
||||
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
|
||||
const additions = fieldNamesToCreate
|
||||
.filter((name) => !existingNames.has(name))
|
||||
.map((name) => ({
|
||||
const additions = fieldNamesToCreate.
|
||||
filter((name) => !existingNames.has(name)).
|
||||
map((name) => ({
|
||||
name,
|
||||
label: humanizeFieldName(name),
|
||||
type: "string",
|
||||
@@ -594,9 +594,9 @@ export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", qu
|
||||
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
|
||||
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
|
||||
return candidates
|
||||
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 }))
|
||||
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
return candidates.
|
||||
map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })).
|
||||
sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
}
|
||||
|
||||
function headerForColumn(headerRows: string[][], columnIndex: number): string {
|
||||
@@ -636,7 +636,7 @@ function stableHash(value: string): string {
|
||||
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 profileCounts = countHeaders(profileHeaders);
|
||||
let matchedHeaders = 0;
|
||||
@@ -664,10 +664,10 @@ function countHeaders(headers: string[]): Map<string, number> {
|
||||
|
||||
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
|
||||
const currentCounts = countHeaders(currentHeaders);
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length)
|
||||
.filter((mapping) => mapping.kind !== "ignore")
|
||||
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "")
|
||||
.filter((header) => header && !currentCounts.has(header));
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length).
|
||||
filter((mapping) => mapping.kind !== "ignore").
|
||||
map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "").
|
||||
filter((header) => header && !currentCounts.has(header));
|
||||
}
|
||||
|
||||
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
|
||||
@@ -688,8 +688,8 @@ function normalizeProfileMapping(
|
||||
columnIndex: number,
|
||||
header: string,
|
||||
existingFields: ImportFieldDefinition[],
|
||||
fallback: RecipientColumnMapping | undefined
|
||||
): RecipientColumnMapping {
|
||||
fallback: RecipientColumnMapping | undefined)
|
||||
: RecipientColumnMapping {
|
||||
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
|
||||
if (!mapping) return fallbackMapping;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
@@ -707,25 +707,25 @@ function normalizeProfileMapping(
|
||||
}
|
||||
|
||||
function isPatternColumn(key: string): boolean {
|
||||
return key === "pattern"
|
||||
|| key === "patterns"
|
||||
|| key === "file"
|
||||
|| key === "files"
|
||||
|| key === "file_pattern"
|
||||
|| key === "file_patterns"
|
||||
|| key === "attachment"
|
||||
|| key === "attachments"
|
||||
|| key === "attachment_pattern"
|
||||
|| key === "attachment_patterns"
|
||||
|| /^pattern_\d+$/.test(key)
|
||||
|| /^file_\d+$/.test(key)
|
||||
|| /^attachment_\d+$/.test(key);
|
||||
return key === "pattern" ||
|
||||
key === "patterns" ||
|
||||
key === "file" ||
|
||||
key === "files" ||
|
||||
key === "file_pattern" ||
|
||||
key === "file_patterns" ||
|
||||
key === "attachment" ||
|
||||
key === "attachments" ||
|
||||
key === "attachment_pattern" ||
|
||||
key === "attachment_patterns" ||
|
||||
/^pattern_\d+$/.test(key) ||
|
||||
/^file_\d+$/.test(key) ||
|
||||
/^attachment_\d+$/.test(key);
|
||||
}
|
||||
|
||||
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
|
||||
return splitCell(value, separators)
|
||||
.map((item) => parseAddress(item))
|
||||
.filter((address) => Boolean(address.email));
|
||||
return splitCell(value, separators).
|
||||
map((item) => parseAddress(item)).
|
||||
filter((address) => Boolean(address.email));
|
||||
}
|
||||
|
||||
function parseAddress(value: string): ImportedAddress {
|
||||
|
||||
@@ -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 { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
@@ -54,8 +54,8 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
}
|
||||
|
||||
export function userLockState(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null
|
||||
): "temporary" | "permanent" | null {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null)
|
||||
: "temporary" | "permanent" | null {
|
||||
if (!version) return null;
|
||||
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
|
||||
return version.user_lock_state;
|
||||
@@ -89,8 +89,8 @@ export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVe
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"
|
||||
].includes((version.workflow_state ?? "").toLowerCase());
|
||||
"cancelled"].
|
||||
includes((version.workflow_state ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
@@ -109,15 +109,15 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
||||
|
||||
export function isHistoricalCampaignVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
return Boolean(version && currentVersionId && version.id !== currentVersionId);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
if (!version) return false;
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||
@@ -126,30 +126,30 @@ export function isAuditLockedVersion(
|
||||
|
||||
export function versionLockReason(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
currentVersionId?: string | null)
|
||||
: string {
|
||||
if (!version) return "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b";
|
||||
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)) {
|
||||
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)) {
|
||||
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)) {
|
||||
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 (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
|
||||
return "Editable working version.";
|
||||
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 i18nMessage("i18n:govoplan-campaign.temporarily_locked_at_value.3ce9192e", { value0: formatDateTime(version.locked_at) });
|
||||
return "i18n:govoplan-campaign.editable_working_version.379f898a";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -199,8 +199,8 @@ export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
stamp: string)
|
||||
: {externalId: string;name: string;description: string;rawJson: Record<string, unknown>;} {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapabili
|
||||
export type ImportFileLinkResolution = {
|
||||
basePath: AttachmentBasePath;
|
||||
patterns: RenderedImportPattern[];
|
||||
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
|
||||
matchedPatterns: {pattern: RenderedImportPattern;matches: FilesManagedFile[];}[];
|
||||
unmatchedPatterns: RenderedImportPattern[];
|
||||
files: FilesManagedFile[];
|
||||
linkedFiles: FilesManagedFile[];
|
||||
@@ -26,11 +26,11 @@ export async function resolveImportedAttachmentLinks(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview
|
||||
): Promise<ImportFileLinkResolution> {
|
||||
preview: RecipientImportPreview)
|
||||
: Promise<ImportFileLinkResolution> {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
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);
|
||||
@@ -51,14 +51,14 @@ export async function resolveImportedAttachmentLinks(
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})
|
||||
]);
|
||||
})]
|
||||
);
|
||||
|
||||
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
|
||||
const fileById = new Map<string, FilesManagedFile>();
|
||||
const matchedPatterns = patterns.map((pattern, index) => {
|
||||
const matches = (resolved.patterns[index]?.matches ?? [])
|
||||
.map((file) => visibleById.get(file.id) ?? file);
|
||||
const matches = (resolved.patterns[index]?.matches ?? []).
|
||||
map((file) => visibleById.get(file.id) ?? file);
|
||||
matches.forEach((file) => fileById.set(file.id, file));
|
||||
return { pattern, matches };
|
||||
});
|
||||
@@ -86,9 +86,9 @@ export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCap
|
||||
|
||||
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
|
||||
const byKey = new Map<string, RenderedImportPattern>();
|
||||
preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.forEach((row) => {
|
||||
preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
|
||||
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal file
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal 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 ?? ""));
|
||||
}
|
||||
@@ -14,17 +14,17 @@ import { useCampaignDraftEditor } from "../hooks/useCampaignDraftEditor";
|
||||
import { AttachmentsStep, BasicsStep, FieldsStep, RecipientsStep, ReviewStep, SenderStep, SendStep, TemplateStep } from "./steps/CreateWizardSteps";
|
||||
|
||||
const steps: WizardStep[] = [
|
||||
{ id: "basics", label: "Basics", description: "Name and scenario" },
|
||||
{ id: "sender", label: "Sender", description: "Mail account and headers" },
|
||||
{ id: "fields", label: "Fields", description: "Define campaign data" },
|
||||
{ id: "recipients", label: "Recipients", description: "Import and map source data" },
|
||||
{ id: "template", label: "Template", description: "Subject and body" },
|
||||
{ id: "attachments", label: "Attachments", description: "Rules and ZIP options" },
|
||||
{ id: "review", label: "Review", description: "Validate before build" },
|
||||
{ id: "send", label: "Send", description: "Test and queue" }
|
||||
];
|
||||
{ id: "basics", label: "i18n:govoplan-campaign.basics.5fcebeef", description: "i18n:govoplan-campaign.name_and_scenario.f2bc5241" },
|
||||
{ id: "sender", label: "i18n:govoplan-campaign.sender.17b874d2", description: "i18n:govoplan-campaign.mail_account_and_headers.9e269243" },
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527", description: "i18n:govoplan-campaign.define_campaign_data.7045c0a4" },
|
||||
{ id: "recipients", label: "i18n:govoplan-campaign.recipients.78cbf8eb", description: "i18n:govoplan-campaign.import_and_map_source_data.b875d471" },
|
||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06", description: "i18n:govoplan-campaign.subject_and_body.915da39b" },
|
||||
{ id: "attachments", label: "i18n:govoplan-campaign.attachments.6771ade6", description: "i18n:govoplan-campaign.rules_and_zip_options.e3656774" },
|
||||
{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", description: "i18n:govoplan-campaign.validate_before_build.27723ad7" },
|
||||
{ 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 [localError, setLocalError] = useState("");
|
||||
const [validationMessage, setValidationMessage] = useState("");
|
||||
@@ -41,8 +41,8 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
setError: setLocalError,
|
||||
currentFlow: "create",
|
||||
currentStep: () => activeStep,
|
||||
unsavedTitle: "Unsaved wizard changes",
|
||||
unsavedMessage: "This campaign wizard has unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_wizard_changes.ba907144",
|
||||
unsavedMessage: "i18n:govoplan-campaign.this_campaign_wizard_has_unsaved_changes_save_th.7e4be033",
|
||||
onLoaded: (loadedVersion) => {
|
||||
if (loadedVersion.current_step && steps.some((step) => step.id === loadedVersion.current_step)) {
|
||||
setActiveStep(loadedVersion.current_step);
|
||||
@@ -68,7 +68,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
|
||||
async function validateCurrentStep() {
|
||||
if (!version || !draft) return;
|
||||
setValidationMessage("Validating…");
|
||||
setValidationMessage("i18n:govoplan-campaign.validating.c07434c9");
|
||||
try {
|
||||
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.`);
|
||||
@@ -84,9 +84,9 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="wizard-body standalone-wizard-body">
|
||||
<div className="wizard-heading">
|
||||
<div>
|
||||
<PageTitle>Create campaign</PageTitle>
|
||||
<PageTitle>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
|
||||
</div>
|
||||
<div className="save-state">Locked</div>
|
||||
<div className="save-state">i18n:govoplan-campaign.locked.a798882f</div>
|
||||
</div>
|
||||
<Card>
|
||||
<LockedVersionNotice
|
||||
@@ -95,16 +95,16 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
version={data.currentVersion}
|
||||
currentVersionId={data.campaign?.current_version_id}
|
||||
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">
|
||||
<Link to="../.."><Button>Back to overview</Button></Link>
|
||||
<Link to="../.."><Button>i18n:govoplan-campaign.back_to_overview.ec986cba</Button></Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -114,7 +114,7 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
<div className="wizard-body">
|
||||
<div className="wizard-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Create campaign</PageTitle>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.create_campaign.59812bbc</PageTitle>
|
||||
</div>
|
||||
<div className="save-state">{saveState}</div>
|
||||
</div>
|
||||
@@ -131,13 +131,13 @@ export default function CreateWizard({ settings, campaignId }: { settings: ApiSe
|
||||
{draft && activeStep === "send" && <SendStep draft={draft} patch={patch} />}
|
||||
</Card>
|
||||
<div className="wizard-footer">
|
||||
<Button onClick={previousStep}>Back</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "Save now" : "Saved"}</Button>
|
||||
<Button onClick={validateCurrentStep}>Validate step</Button>
|
||||
<Button variant="primary" onClick={nextStep}>Continue</Button>
|
||||
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
|
||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
|
||||
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -3,20 +3,20 @@ import { MetricCard } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function ReviewWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Review Wizard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading workspace-heading">
|
||||
<h1>i18n:govoplan-campaign.review_wizard.3d8bf0aa</h1>
|
||||
</div>
|
||||
<div className="metric-grid">
|
||||
<MetricCard label="Needs review" value="—" tone="warning" />
|
||||
<MetricCard label="Missing attachments" value="—" tone="warning" />
|
||||
<MetricCard label="Ambiguous matches" value="—" tone="info" />
|
||||
<MetricCard label="Blocked" value="—" tone="danger" />
|
||||
<MetricCard label="i18n:govoplan-campaign.needs_review.33a506cf" value="—" tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.missing_attachments.729ad125" value="—" tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.ambiguous_matches.dc658a9c" value="—" tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.blocked.99613c74" value="—" tone="danger" />
|
||||
</div>
|
||||
<Card title="Resolution workflow">
|
||||
<p className="muted">This wizard will guide users through issues one class at a time.</p>
|
||||
<Button variant="primary">Start review</Button>
|
||||
<Card title="i18n:govoplan-campaign.resolution_workflow.708d6c0b">
|
||||
<p className="muted">i18n:govoplan-campaign.this_wizard_will_guide_users_through_issues_one_.5cb212c3</p>
|
||||
<Button variant="primary">i18n:govoplan-campaign.start_review.d0bc5cdf</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@ import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default function SendWizard() {
|
||||
return (
|
||||
<div className="content-pad">
|
||||
<div className="page-heading">
|
||||
<h1>Send Wizard</h1>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading workspace-heading">
|
||||
<h1>i18n:govoplan-campaign.send_wizard.3c137422</h1>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Test send">
|
||||
<p className="muted">Send one generated message to a test address.</p>
|
||||
<Button>Open test-send dialog</Button>
|
||||
<Card title="i18n:govoplan-campaign.test_send.03dfff38">
|
||||
<p className="muted">i18n:govoplan-campaign.send_one_generated_message_to_a_test_address.bc0a4e47</p>
|
||||
<Button>i18n:govoplan-campaign.open_test_send_dialog.661db713</Button>
|
||||
</Card>
|
||||
<Card title="Queue estimate">
|
||||
<p className="muted">Estimated duration will be based on ready jobs and rate limits.</p>
|
||||
<Button variant="primary">Queue dry run</Button>
|
||||
<Card title="i18n:govoplan-campaign.queue_estimate.5480288a">
|
||||
<p className="muted">i18n:govoplan-campaign.estimated_duration_will_be_based_on_ready_jobs_a.15b63283</p>
|
||||
<Button variant="primary">i18n:govoplan-campaign.queue_dry_run.800e1606</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -21,24 +21,24 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
|
||||
const campaign = asRecord(draft.campaign);
|
||||
return (
|
||||
<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)} />
|
||||
</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)} />
|
||||
</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)}>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="test">Test</option>
|
||||
<option value="send">Send</option>
|
||||
<option value="draft">i18n:govoplan-campaign.draft.23d33e22</option>
|
||||
<option value="test">i18n:govoplan-campaign.test.640ab2ba</option>
|
||||
<option value="send">i18n:govoplan-campaign.send.9bc2575c</option>
|
||||
</select>
|
||||
</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)} />
|
||||
</FormField>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function SenderStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -55,86 +55,86 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<FormField label="Default From address">
|
||||
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
||||
<EmailAddressInput
|
||||
value={from}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add From"
|
||||
emptyText="No global From address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_from.a095bb50"
|
||||
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="Global recipients">
|
||||
<FormField label="i18n:govoplan-campaign.global_recipients.d9aaa427">
|
||||
<EmailAddressInput
|
||||
value={globalTo}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add recipient"
|
||||
emptyText="No global recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_recipient.a989d1f1"
|
||||
emptyText="i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "to"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="CC">
|
||||
<FormField label="i18n:govoplan-campaign.cc.c5a976de">
|
||||
<EmailAddressInput
|
||||
value={globalCc}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add CC"
|
||||
emptyText="No global CC recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_cc.bcb39ea3"
|
||||
emptyText="i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "cc"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="BCC">
|
||||
<FormField label="i18n:govoplan-campaign.bcc.4c0145a3">
|
||||
<EmailAddressInput
|
||||
value={globalBcc}
|
||||
suggestions={suggestions}
|
||||
allowMultiple
|
||||
addLabel="Add BCC"
|
||||
emptyText="No global BCC recipients configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)}
|
||||
/>
|
||||
addLabel="i18n:govoplan-campaign.add_bcc.ae9feacc"
|
||||
emptyText="i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "bcc"], addresses)} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="Reply-To">
|
||||
<FormField label="i18n:govoplan-campaign.reply_to.c1733667">
|
||||
<EmailAddressInput
|
||||
value={globalReplyTo.slice(0, 1)}
|
||||
suggestions={suggestions}
|
||||
allowMultiple={false}
|
||||
showAddButton={false}
|
||||
addLabel={globalReplyTo.length ? "Replace" : "Add Reply-To"}
|
||||
emptyText="No Reply-To address configured."
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))}
|
||||
/>
|
||||
addLabel={globalReplyTo.length ? "i18n:govoplan-campaign.replace.a7cf7b25" : "i18n:govoplan-campaign.add_reply_to.84b195f4"}
|
||||
emptyText="i18n:govoplan-campaign.no_reply_to_address_configured.0665c1d9"
|
||||
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses.slice(0, 1))} />
|
||||
|
||||
</FormField>
|
||||
<FormField label="SMTP host"><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>
|
||||
<ToggleSwitch label="Append successful messages to Sent via IMAP" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||
</div>
|
||||
);
|
||||
<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="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="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>);
|
||||
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Campaign fields</h2>
|
||||
<p>Define reusable fields for templates, attachment rules, ZIP passwords and recipient data.</p>
|
||||
<h2>i18n:govoplan-campaign.campaign_fields.969e7d80</h2>
|
||||
<p>i18n:govoplan-campaign.define_reusable_fields_for_templates_attachment_.6424f892</p>
|
||||
</div>
|
||||
<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 (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Recipients</h2>
|
||||
<p>Store inline recipients or source/mapping configuration. A table editor will replace this JSON editor in the recipient section pass.</p>
|
||||
<h2>i18n:govoplan-campaign.recipients.78cbf8eb</h2>
|
||||
<p>i18n:govoplan-campaign.store_inline_recipients_or_source_mapping_config.77bbe98f</p>
|
||||
</div>
|
||||
<JsonEditor value={draft.entries ?? { inline: [] }} onValid={(value) => patchRoot("entries", value)} />
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function TemplateStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -142,16 +142,16 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Template</h2>
|
||||
<p>Compose the subject and body. Merge fields can later be inserted from the field picker.</p>
|
||||
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
|
||||
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<FormField label="Subject"><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="HTML body"><textarea rows={8} value={getText(template, "html")} onChange={(event) => patch(["template", "html"], 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="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="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>);
|
||||
|
||||
}
|
||||
|
||||
export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -159,36 +159,36 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Attachments</h2>
|
||||
<p>Configure campaign-wide attachment behavior and global matching rules.</p>
|
||||
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
|
||||
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
|
||||
</div>
|
||||
<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="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="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>
|
||||
<ToggleSwitch label="Allow individual attachments" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||
<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="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="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="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||
</div>
|
||||
<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);
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Review setup</h2>
|
||||
<p>Validate the campaign definition before building message drafts.</p>
|
||||
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
|
||||
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
|
||||
</div>
|
||||
<div className="metric-grid inside">
|
||||
<MetricCard label="Errors" 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="Built" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
||||
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||
</div>
|
||||
<Button variant="primary" onClick={onValidate}>Validate campaign</Button>
|
||||
</div>
|
||||
);
|
||||
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
export function SendStep({ draft, patch }: WizardStepProps) {
|
||||
@@ -197,19 +197,19 @@ export function SendStep({ draft, patch }: WizardStepProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="step-intro">
|
||||
<h2>Send preparation</h2>
|
||||
<p>Configure rate limits and prepare the final send workflow.</p>
|
||||
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
|
||||
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
|
||||
</div>
|
||||
<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="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.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="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>
|
||||
<p className="muted">Test send and queue actions remain in the Send Wizard for now.</p>
|
||||
</div>
|
||||
);
|
||||
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
|
||||
</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 [error, setError] = useState("");
|
||||
|
||||
@@ -228,8 +228,8 @@ function JsonEditor({ value, onValid }: { value: unknown; onValid: (value: unkno
|
||||
return (
|
||||
<div className="json-edit-block">
|
||||
<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>}
|
||||
{Array.isArray(value) && value.length > 0 && <p className="form-help">Preview: {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||
</div>
|
||||
);
|
||||
{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">i18n:govoplan-campaign.preview.4bf30626 {stringifyPreview(asArray(value)[0], 140)}</p>}
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 { Card } from "@govoplan/core-webui";
|
||||
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 { MetricCard } 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";
|
||||
|
||||
type OperatorRow = {
|
||||
@@ -25,29 +24,40 @@ type OperatorRow = {
|
||||
needsAttention: number;
|
||||
};
|
||||
|
||||
export default function OperatorQueuePage({ settings }: { settings: ApiSettings }) {
|
||||
const navigate = useNavigate();
|
||||
export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const [rows, setRows] = useState<OperatorRow[]>([]);
|
||||
const campaignsRef = useRef<CampaignListItem[]>([]);
|
||||
const summariesRef = useRef<Record<string, CampaignSummary | null>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = 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() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const campaigns = await listCampaigns(settings);
|
||||
const summaries = await Promise.all(campaigns.map(async (campaign) => {
|
||||
try {
|
||||
return await getCampaignSummary(settings, campaign.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
setRows(campaigns.map((campaign, index) => toRow(campaign, summaries[index] ?? null)));
|
||||
const campaigns = await loadCampaignsDelta();
|
||||
const summaries = await loadCampaignSummariesDelta(campaigns);
|
||||
setRows(campaigns.map((campaign) => toRow(campaign, summaries[campaign.id] ?? null)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -61,11 +71,12 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = action === "retry"
|
||||
? await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true })
|
||||
: await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
const response = action === "retry" ?
|
||||
await retryCampaignJobs(settings, row.campaign.id, { enqueue_celery: true }) :
|
||||
await sendUnattemptedCampaignJobs(settings, row.campaign.id, { enqueue_celery: true });
|
||||
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();
|
||||
} catch (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) => ({
|
||||
failed: acc.failed + row.failed,
|
||||
outcomeUnknown: acc.outcomeUnknown + row.outcomeUnknown,
|
||||
notAttempted: acc.notAttempted + row.notAttempted,
|
||||
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 });
|
||||
|
||||
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: "status", header: "Status", 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: "failed", header: "Failed", 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: "unattempted", header: "Unattempted", 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: "updated", header: "Updated", width: 180, sortable: true, filterType: "date", value: (row) => formatDateTime(row.campaign.updated_at), sortValue: (row) => row.campaign.updated_at ?? "" },
|
||||
{ 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: "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: "i18n:govoplan-campaign.attention.74e0b9c8", width: 120, align: "right", sortable: true, filterType: "integer", value: (row) => row.needsAttention },
|
||||
{ id: "failed", header: "i18n:govoplan-campaign.failed.09fef5d8", width: 100, align: "right", sortable: true, filterType: "integer", value: (row) => row.failed },
|
||||
{ id: "unknown", header: "i18n:govoplan-campaign.unknown.bc7819b3", width: 110, align: "right", sortable: true, filterType: "integer", value: (row) => row.outcomeUnknown },
|
||||
{ id: "unattempted", header: "i18n:govoplan-campaign.unattempted.e7411dd6", width: 130, align: "right", sortable: true, filterType: "integer", value: (row) => row.notAttempted },
|
||||
{ id: "queued", header: "i18n:govoplan-campaign.queued_active.b08bef73", width: 135, align: "right", sortable: true, filterType: "integer", value: (row) => row.queuedOrActive },
|
||||
{ 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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 270,
|
||||
sticky: "end",
|
||||
render: (row) => (
|
||||
render: (row) =>
|
||||
<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 />
|
||||
</Button>
|
||||
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button>
|
||||
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</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)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
], [busy, navigate]);
|
||||
|
||||
}],
|
||||
[busy, navigate]);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Operator queue</PageTitle>
|
||||
<p>Queue state, retry candidates and reconciliation entry points across accessible campaigns.</p>
|
||||
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
||||
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -124,30 +195,30 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
|
||||
<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={[
|
||||
{ label: "Failed", value: totals.failed, tone: "danger" },
|
||||
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
|
||||
]} />
|
||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: totals.failed, tone: "danger" },
|
||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: totals.outcomeUnknown, tone: "warning" },
|
||||
{ label: "i18n:govoplan-campaign.unattempted.e7411dd6", value: totals.notAttempted, tone: "info" },
|
||||
{ label: "i18n:govoplan-campaign.queued_active.b08bef73", value: totals.queuedOrActive, tone: "neutral" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: totals.imapFailed, tone: "warning" }]
|
||||
} />
|
||||
</section>
|
||||
|
||||
<LoadingFrame loading={loading} label="Loading operator queue…">
|
||||
<Card title="Campaign queues">
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
|
||||
<Card title="i18n:govoplan-campaign.campaign_queues.657785f4">
|
||||
<DataGrid
|
||||
id="operator-queue-campaigns"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
getRowKey={(row) => row.campaign.id}
|
||||
emptyText="No accessible campaigns."
|
||||
className="data-table compact-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_accessible_campaigns.9e080190"
|
||||
className="data-table compact-table" />
|
||||
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
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),
|
||||
imapFailed: numberValue(cards.imap_failed),
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
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 ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageTitle } 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 DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
|
||||
@@ -27,91 +28,96 @@ type TemplateDetailSection = "overview" | "content" | "fields" | "preview" | "us
|
||||
const templateRecords: TemplateRecord[] = [
|
||||
{
|
||||
id: "monthly-statement",
|
||||
name: "Monthly statement",
|
||||
description: "Reusable subject and body for monthly statement mailings.",
|
||||
type: "Plain text",
|
||||
name: "i18n:govoplan-campaign.monthly_statement.74029609",
|
||||
description: "i18n:govoplan-campaign.reusable_subject_and_body_for_monthly_statement_.9754240d",
|
||||
type: "plain_text",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "period", "amount"],
|
||||
updatedAt: "2026-06-08 16:42",
|
||||
usedBy: "2 campaigns",
|
||||
subject: "Your statement for {{period}}",
|
||||
body: "Hello {{recipient_name}},\n\nplease find your statement for {{period}} attached.",
|
||||
usedBy: "i18n:govoplan-campaign.2_campaigns.35b84804",
|
||||
subject: "i18n:govoplan-campaign.subject_monthly_statement",
|
||||
body: "i18n:govoplan-campaign.hello_value_please_find_your_statement_for_value.48d94596",
|
||||
versions: 4
|
||||
},
|
||||
{
|
||||
id: "deadline-reminder",
|
||||
name: "Deadline reminder",
|
||||
description: "Short reminder template with one deadline field.",
|
||||
type: "Plain text",
|
||||
name: "i18n:govoplan-campaign.deadline_reminder.84977a7f",
|
||||
description: "i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110",
|
||||
type: "plain_text",
|
||||
status: "draft",
|
||||
fields: ["recipient_name", "deadline"],
|
||||
updatedAt: "2026-06-07 11:18",
|
||||
usedBy: "Not used yet",
|
||||
subject: "Reminder: {{deadline}}",
|
||||
body: "Hello {{recipient_name}},\n\nthis is a reminder that the deadline is {{deadline}}.",
|
||||
usedBy: "i18n:govoplan-campaign.not_used_yet.962b7bbd",
|
||||
subject: "i18n:govoplan-campaign.subject_deadline_reminder",
|
||||
body: "i18n:govoplan-campaign.hello_value_this_is_a_reminder_that_the_deadline.c4fbdbd0",
|
||||
versions: 1
|
||||
},
|
||||
{
|
||||
id: "attachment-notice",
|
||||
name: "Attachment notice",
|
||||
description: "Generic note for campaigns where every recipient receives a file.",
|
||||
type: "HTML-ready",
|
||||
name: "i18n:govoplan-campaign.attachment_notice.b73a59fe",
|
||||
description: "i18n:govoplan-campaign.generic_note_for_campaigns_where_every_recipient.f21254fa",
|
||||
type: "html_ready",
|
||||
status: "ready",
|
||||
fields: ["recipient_name", "file_label", "contact_email"],
|
||||
updatedAt: "2026-06-05 09:05",
|
||||
usedBy: "1 campaign",
|
||||
subject: "Documents for {{recipient_name}}",
|
||||
body: "Hello {{recipient_name}},\n\nyour {{file_label}} is attached. Please contact {{contact_email}} if anything is missing.",
|
||||
usedBy: "i18n:govoplan-campaign.1_campaign.ccd70074",
|
||||
subject: "i18n:govoplan-campaign.subject_documents_for_recipient",
|
||||
body: "i18n:govoplan-campaign.hello_value_your_value_is_attached_please_contac.da32415e",
|
||||
versions: 3
|
||||
}
|
||||
];
|
||||
}];
|
||||
|
||||
|
||||
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: [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "content", label: "Content" },
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "preview", label: "Preview" },
|
||||
{ id: "usage", label: "Usage" },
|
||||
{ id: "versions", label: "Versions" }
|
||||
]
|
||||
}
|
||||
];
|
||||
{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b" },
|
||||
{ id: "content", label: "i18n:govoplan-campaign.content.4f9be057" },
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
|
||||
{ id: "usage", label: "i18n:govoplan-campaign.usage.0bb18642" },
|
||||
{ id: "versions", label: "i18n:govoplan-campaign.versions.a239107e" }]
|
||||
|
||||
}];
|
||||
|
||||
|
||||
function templateLibraryColumns(openTemplate: (templateId: string) => void): DataGridColumn<TemplateRecord>[] {
|
||||
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: "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: "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: "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: "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: "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: "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: "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: "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: "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",
|
||||
header: "Actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 70,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (template) => (
|
||||
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}>
|
||||
render: (template) =>
|
||||
<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 />
|
||||
</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 [
|
||||
{ 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: "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: "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: "actions", header: "Actions", width: 130, sticky: "end", render: () => <Button disabled>Configure</Button> }
|
||||
];
|
||||
{ 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: "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: "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: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
|
||||
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
@@ -139,8 +145,8 @@ export default function TemplatesPage() {
|
||||
<p>{selectedTemplate.description}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Duplicate</Button>
|
||||
<Button variant="primary" disabled>Use in campaign</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,133 +158,144 @@ export default function TemplatesPage() {
|
||||
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>Templates</PageTitle>
|
||||
<p>Reusable message templates. Open a template to edit content, fields, preview and usage.</p>
|
||||
<PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
|
||||
<p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Import</Button>
|
||||
<Button variant="primary" disabled>Export</Button>
|
||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div className="module-card-heading">
|
||||
<h2>All templates</h2>
|
||||
<span>Last loaded: local demo data</span>
|
||||
<h2>i18n:govoplan-campaign.all_templates.0bba114c</h2>
|
||||
<span>i18n:govoplan-campaign.last_loaded_local_demo_data.62ee2f5d</span>
|
||||
</div>
|
||||
}
|
||||
actions={<Button disabled>Refresh</Button>}
|
||||
>
|
||||
actions={<Button disabled>i18n:govoplan-campaign.refresh.56e3badc</Button>}>
|
||||
|
||||
<DataGrid
|
||||
id="template-library"
|
||||
rows={templateRecords}
|
||||
columns={templateLibraryColumns(openTemplate)}
|
||||
getRowKey={(template) => template.id}
|
||||
emptyText="No templates found."
|
||||
className="compact-table-wrap module-table-wrap module-entry-table"
|
||||
/>
|
||||
emptyText="i18n:govoplan-campaign.no_templates_found.326abcdd"
|
||||
className="compact-table-wrap module-table-wrap module-entry-table" />
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateOverview({ template }: { template: TemplateRecord }) {
|
||||
function TemplateOverview({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<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">
|
||||
<div><dt>Status</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>Type</dt><dd>{template.type}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>Versions</dt><dd>{template.versions}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="Compatibility notes">
|
||||
<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>
|
||||
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
|
||||
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
|
||||
<div className="placeholder-stack">
|
||||
<span>Subject placeholders are detected</span>
|
||||
<span>Body placeholders are compared with campaign fields</span>
|
||||
<span>A mapping wizard can be added later</span>
|
||||
<span>i18n:govoplan-campaign.subject_placeholders_are_detected.96663276</span>
|
||||
<span>i18n:govoplan-campaign.body_placeholders_are_compared_with_campaign_fie.bbd1d67f</span>
|
||||
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
|
||||
</div>
|
||||
</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 (
|
||||
<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">
|
||||
<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 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"><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="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>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateFields({ template }: { template: TemplateRecord }) {
|
||||
function TemplateFields({ template }: {template: TemplateRecord;}) {
|
||||
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
|
||||
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()}
|
||||
getRowKey={(field) => field.id}
|
||||
emptyText="No fields detected."
|
||||
className="compact-table-wrap module-table-wrap module-table"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
emptyText="i18n:govoplan-campaign.no_fields_detected.0cb30100"
|
||||
className="compact-table-wrap module-table-wrap module-table" />
|
||||
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplatePreview({ template }: { template: TemplateRecord }) {
|
||||
const preview = template.body
|
||||
.replace(/\{\{recipient_name\}\}/g, "Jane Example")
|
||||
.replace(/\{\{period\}\}/g, "May 2026")
|
||||
.replace(/\{\{amount\}\}/g, "123.45 EUR")
|
||||
.replace(/\{\{deadline\}\}/g, "30 June 2026")
|
||||
.replace(/\{\{file_label\}\}/g, "statement")
|
||||
.replace(/\{\{contact_email\}\}/g, "support@example.org");
|
||||
function TemplatePreview({ template }: {template: TemplateRecord;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const subject = translateText(template.subject);
|
||||
const body = translateText(template.body);
|
||||
const sampleRecipientName = translateText("i18n:govoplan-campaign.sample_recipient_name");
|
||||
const samplePeriod = translateText("i18n:govoplan-campaign.sample_period");
|
||||
const sampleAmount = translateText("i18n:govoplan-campaign.sample_amount");
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateUsage({ template }: { template: TemplateRecord }) {
|
||||
function TemplateUsage({ template }: {template: TemplateRecord;}) {
|
||||
return (
|
||||
<Card title="Usage">
|
||||
<p className="muted">This view will list campaigns using the template once the backend template model is available.</p>
|
||||
<Card title="i18n:govoplan-campaign.usage.0bb18642">
|
||||
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
|
||||
<dl className="detail-list compact-detail-list">
|
||||
<div><dt>Currently used by</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.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</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>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
function TemplateVersions({ template }: { template: TemplateRecord }) {
|
||||
function TemplateVersions({ template }: {template: TemplateRecord;}) {
|
||||
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">
|
||||
<span>{template.versions} local versions in the planned model</span>
|
||||
<span>Sent campaigns should keep a fixed template snapshot</span>
|
||||
<span>Draft campaigns can update to a newer template version later</span>
|
||||
<span>{template.versions} i18n:govoplan-campaign.local_versions_in_the_planned_model.c1bf26cb</span>
|
||||
<span>i18n:govoplan-campaign.sent_campaigns_should_keep_a_fixed_template_snap.167d56c2</span>
|
||||
<span>i18n:govoplan-campaign.draft_campaigns_can_update_to_a_newer_template_v.0f854608</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
</Card>);
|
||||
|
||||
}
|
||||
|
||||
2232
webui/src/i18n/generatedTranslations.ts
Normal file
2232
webui/src/i18n/generatedTranslations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
import "./styles/campaign-workspace.css";
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/campaigns";
|
||||
|
||||
@@ -3,57 +3,57 @@ import ModuleSubnav, { type ModuleSubnavGroup } from "./ModuleSubnav";
|
||||
|
||||
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: [
|
||||
{ id: "fields", label: "Fields" },
|
||||
{ id: "files", label: "Attachments" },
|
||||
{ id: "recipients", label: "Sender & Recipients" },
|
||||
{ id: "recipient-data", label: "Recipient data" },
|
||||
{ id: "template", label: "Template" }
|
||||
]
|
||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
|
||||
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
|
||||
{ id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
|
||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "SETTINGS",
|
||||
title: "i18n:govoplan-campaign.settings.c4b92432",
|
||||
items: [
|
||||
{ id: "mail-settings", label: "Mail settings" },
|
||||
{ id: "global-settings", label: "Campaign settings" }
|
||||
]
|
||||
{ id: "mail-settings", label: "i18n:govoplan-campaign.mail_settings.19e07f55" },
|
||||
{ id: "global-settings", label: "i18n:govoplan-campaign.campaign_settings.efffec26" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "POLICIES",
|
||||
title: "i18n:govoplan-campaign.policies.f03ff937",
|
||||
items: [
|
||||
{ id: "mail-policy", label: "Mail policy" },
|
||||
{ id: "policies", label: "Campaign policies" }
|
||||
]
|
||||
{ id: "mail-policy", label: "i18n:govoplan-campaign.mail_policy.3eb5d32a" },
|
||||
{ id: "policies", label: "i18n:govoplan-campaign.campaign_policies.0b5de1f5" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "SEND",
|
||||
title: "i18n:govoplan-campaign.send.a2469c47",
|
||||
items: [
|
||||
{ id: "review", label: "Review & Send" }
|
||||
]
|
||||
{ id: "review", label: "i18n:govoplan-campaign.review_send.1627617d" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "REPORT",
|
||||
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
||||
items: [
|
||||
{ id: "report", label: "Report" },
|
||||
{ id: "audit", label: "Audit log" }
|
||||
]
|
||||
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
||||
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
||||
|
||||
},
|
||||
{
|
||||
title: "ADVANCED",
|
||||
items: [{ id: "json", label: "JSON", subtle: true }]
|
||||
}
|
||||
];
|
||||
title: "i18n:govoplan-campaign.advanced.6052c880",
|
||||
items: [{ id: "json", label: "i18n:govoplan-campaign.json.031a4e76", subtle: true }]
|
||||
}];
|
||||
|
||||
|
||||
export default function SectionSidebar({
|
||||
active,
|
||||
onSelect
|
||||
}: {
|
||||
active: CampaignWorkspaceSection;
|
||||
onSelect: (section: CampaignWorkspaceSection) => void;
|
||||
}) {
|
||||
|
||||
|
||||
|
||||
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
|
||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { createElement, lazy, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { getCampaign } from "./api/campaigns";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/campaign-workspace.css";
|
||||
|
||||
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
|
||||
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
||||
@@ -11,37 +13,42 @@ const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
const campaignRead = ["campaigns:campaign:read"];
|
||||
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 = {
|
||||
id: "campaigns",
|
||||
label: "Campaigns",
|
||||
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
translations,
|
||||
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",
|
||||
label: "Operator Queue",
|
||||
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
||||
iconName: "activity",
|
||||
anyOf: operatorScopes,
|
||||
order: 30
|
||||
},
|
||||
{ to: "/reports", label: "Reports", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
{ to: "/address-book", label: "Address Book", iconName: "users", order: 80 },
|
||||
{ to: "/templates", label: "Templates", iconName: "form", order: 90 }
|
||||
],
|
||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "reports", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
|
||||
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "form", order: 90 }],
|
||||
|
||||
routes: [
|
||||
{ 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: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||
{ 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 tenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
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() {
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "content-pad" },
|
||||
{ className: "content-pad workspace-data-page" },
|
||||
createElement(
|
||||
"div",
|
||||
{ className: "page-heading" },
|
||||
createElement("h1", null, "Reports"),
|
||||
createElement("p", null, "This module is prepared but not implemented yet.")
|
||||
{ className: "page-heading workspace-heading" },
|
||||
createElement("h1", null, "i18n:govoplan-campaign.reports"),
|
||||
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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
/* 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.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
|
||||
.small-note { font-size: 12px; }
|
||||
@@ -177,16 +165,6 @@
|
||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||
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 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -238,7 +216,6 @@
|
||||
.additional-global-values-table td:nth-child(1) { width: 260px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.mail-server-settings-grid { grid-template-columns: 1fr; }
|
||||
.toggle-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -328,19 +305,9 @@
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.campaigns-page {
|
||||
padding: 28px 34px;
|
||||
}
|
||||
.campaigns-page > .alert {
|
||||
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 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -352,10 +319,6 @@
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
|
||||
}
|
||||
.campaigns-page .card-header::after {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
.campaigns-page .card-body {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -426,17 +389,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.campaigns-page {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Module entry/detail pages (Templates, Files). */
|
||||
.module-entry-page {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Module entry/detail pages. */
|
||||
.module-card-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@@ -609,33 +562,7 @@
|
||||
|
||||
/* Template editor refinements. */
|
||||
.template-body-mode {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
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 {
|
||||
margin-top: -2px;
|
||||
@@ -785,12 +712,6 @@
|
||||
.recipient-add-row .email-address-input {
|
||||
max-width: 720px;
|
||||
}
|
||||
.unsaved-changes-dialog {
|
||||
max-width: 520px;
|
||||
}
|
||||
.unsaved-changes-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.campaign-header-grid,
|
||||
@@ -1052,37 +973,6 @@
|
||||
.recipient-data-table td:nth-child(3) { min-width: 192px; }
|
||||
.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 {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1674,6 +1564,77 @@
|
||||
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 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -1739,6 +1700,15 @@
|
||||
.review-flow-execution-summary {
|
||||
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. */
|
||||
@@ -1980,122 +1950,71 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Administration foundation ------------------------------------------------ */
|
||||
.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 {
|
||||
.recipient-outcome-cell {
|
||||
display: grid;
|
||||
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;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-permission-group legend {
|
||||
padding: 0 6px;
|
||||
font-weight: 700;
|
||||
.recipient-outcome-cell strong,
|
||||
.recipient-outcome-cell span {
|
||||
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 {
|
||||
@@ -2127,45 +2046,6 @@
|
||||
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 {
|
||||
width: min(1100px, calc(100vw - 32px));
|
||||
}
|
||||
@@ -2394,40 +2274,10 @@
|
||||
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 {
|
||||
margin: 0;
|
||||
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 {
|
||||
max-height: min(68vh, 720px);
|
||||
}
|
||||
@@ -2499,21 +2349,6 @@
|
||||
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-footer {
|
||||
background: var(--surface);
|
||||
@@ -2523,27 +2358,6 @@
|
||||
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-row {
|
||||
grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user