Portal: wire remaining hardcoded strings to i18n (#6953)

# Description of Changes

- Audited every portal page/component for hardcoded UI strings not
routed through `t()`
- Wired the remaining ones to i18n (~80 new `portal.*` keys in
`en-US/translation.toml`):
- Infrastructure status/label maps (deploy, api-key, cert, key-mode,
attestation, audit, model, region, environment) + API-key permissions
- Procurement "Key documents" modal, editor-admin deploy targets, users
seats label, pipeline output-folder placeholder
- Follows the existing house pattern: label maps store i18n keys,
resolved via `t(MAP[value])` at the render site
- Documents CSV export now reuses the on-screen column keys, and fixes a
latent bug where the exported status leaked the raw key instead of the
translated label
- No UI-copy change: en-US values are identical to the previously
hardcoded strings; other locales fall back to en-US as before

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
Anthony Stirling
2026-07-10 08:54:02 +00:00
committed by GitHub
parent 783a51950f
commit d17c3f4fec
15 changed files with 258 additions and 95 deletions
@@ -6958,6 +6958,12 @@ sectionsAriaLabel = "Infrastructure sections"
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
title = "Infrastructure"
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
[portal.infrastructure.apiKeyPermission]
admin = "Admin"
read = "Read"
write = "Write"
[portal.infrastructure.apiKeys]
createKey = "Create key"
heading = "API keys"
@@ -6978,6 +6984,11 @@ usageToday = "Usage today"
description = "Create a scoped key to start calling the Stirling API."
title = "No API keys yet"
[portal.infrastructure.attestationLabel]
attested = "Attested"
inScope = "In scope"
notApplicable = "N/A"
[portal.infrastructure.audit]
filterAriaLabel = "Filter audit events by category"
heading = "Audit logs"
@@ -7034,6 +7045,24 @@ elevation = "Elevation"
processing = "Processing"
totalEvents = "Total events · 24h"
[portal.infrastructure.auditCatLabel]
auth = "Auth"
config = "Config"
elevation = "Elevation"
processing = "Processing"
security = "Security"
[portal.infrastructure.auditStatusLabel]
danger = "Error"
info = "Info"
success = "Success"
warning = "Warning"
[portal.infrastructure.certLabel]
certified = "Certified"
inProgress = "In progress"
notStarted = "Not started"
[portal.infrastructure.createKey]
cancel = "Cancel"
createKey = "Create key"
@@ -7050,6 +7079,12 @@ subtitleCreated = "Copy this secret now — it won't be shown again."
title = "Create API key"
titleCreated = "Key created"
[portal.infrastructure.deployLabel]
live = "Live"
queued = "Queued"
rolledBack = "Rolled back"
rolling = "Rolling out"
[portal.infrastructure.deployments]
msValue = "{{value}} ms"
throughputValue = "{{value}}/min"
@@ -7085,6 +7120,16 @@ subheading = "Live health for every deployed Stirling region — latency, load,
description = "Deployed regions appear here once your workspace is provisioned."
title = "No regions deployed"
[portal.infrastructure.keyLabel]
active = "Active"
revoked = "Revoked"
rotateSoon = "Rotate soon"
[portal.infrastructure.modelLabel]
active = "Active"
degraded = "Degraded"
disabled = "Disabled"
[portal.infrastructure.models]
heading = "Models"
msValue = "{{value}} ms"
@@ -7112,6 +7157,10 @@ status = "Status"
type = "Type"
version = "Version"
[portal.infrastructure.models.cost]
perCall = "{{price}}/call"
perThousand = "{{price}}/1k"
[portal.infrastructure.models.metrics]
activeModels = "Active models"
avgLatency = "Avg latency"
@@ -7135,6 +7184,17 @@ modelForAria = "Model for {{operation}}"
operation = "Operation"
routedTo = "Routed to"
[portal.infrastructure.modelTypeLabel]
classification = "Classification"
extraction = "Extraction"
llm = "LLM"
ocr = "OCR"
[portal.infrastructure.regionLabel]
degraded = "Degraded"
down = "Down"
healthy = "Healthy"
[portal.infrastructure.security.access.byok]
description = "Supply a key from your own KMS. Stirling encrypts with it but can still read."
label = "Bring your own key (BYOK)"
@@ -7809,6 +7869,56 @@ blurb = "Evaluate Stirling against your documents and workflows."
gatingAction = "Build your quote"
label = "Trial"
[portal.procurement.keyDocs]
oneTimeFee = " · one-time {{amount}}"
subtitle = "Everything for each stage of your rollout, in one place."
title = "Key documents"
[portal.procurement.keyDocs.docs.baa]
name = "Business Associate Agreement"
sub = "HIPAA · available on request"
[portal.procurement.keyDocs.docs.bankTransfer]
name = "Bank transfer instructions"
sub = "Wire details for your AP team"
[portal.procurement.keyDocs.docs.coi]
name = "Certificate of Insurance"
sub = "Cyber + E&O · current policy"
[portal.procurement.keyDocs.docs.formalQuote]
name = "Formal quote"
sub = "Built to your volume, term, and service level"
[portal.procurement.keyDocs.docs.msa]
name = "Master Services Agreement"
sub = "One signature - MSA, order form, EULA, and DPA combined"
[portal.procurement.keyDocs.docs.purchaseOrder]
name = "Purchase order"
sub = "Issuing a PO? Upload it and we invoice against it"
[portal.procurement.keyDocs.docs.securityReview]
name = "Custom security review"
sub = "We complete your questionnaire and join your review call"
[portal.procurement.keyDocs.docs.soc2]
name = "SOC 2 Type II report"
sub = "Audited · NDA-gated"
[portal.procurement.keyDocs.docs.w9]
name = "IRS Form W-9"
sub = "Stirling PDF Inc."
[portal.procurement.keyDocs.groups]
evaluation = "Supporting your evaluation"
yourDeal = "Your deal"
[portal.procurement.keyDocs.status]
action = "Action needed"
available = "Download"
request = "Request"
[portal.procurement.license]
copied = "Copied"
copy = "Copy key"
@@ -8114,6 +8224,10 @@ summary = "Owns a team — manages its members' resources and shared configs."
2 = "Portal access via the default policy"
3 = "Everything Member can do"
[portal.users.seats]
limited = "{{used}} / {{limit}}"
unlimited = "{{used}} · Unlimited"
[portal.welcome]
ariaLabel = "Welcome to Stirling PDF"
badge = "Open-source"
@@ -111,10 +111,10 @@ export function RecentActivity() {
<div className="portal-activity__subject">{event.target}</div>
<div className="portal-activity__detail-row">
<span className="portal-activity__detail">
{AUDIT_CAT_LABEL[event.category]} · {event.actor}
{t(AUDIT_CAT_LABEL[event.category])} · {event.actor}
</span>
<StatusBadge tone={AUDIT_TONE[event.status]} size="sm">
{AUDIT_STATUS_LABEL[event.status]}
{t(AUDIT_STATUS_LABEL[event.status])}
</StatusBadge>
</div>
</div>
@@ -22,7 +22,9 @@ export function DocumentOverview({ doc }: { doc: ReviewDocument }) {
<StatTile
label={t("portal.documents.overview.action")}
value={
doc.product === "Editor" || !doc.action ? "Editor" : doc.action
doc.product === "Editor" || !doc.action
? t("portal.documents.table.editorAction")
: doc.action
}
/>
<StatTile
@@ -15,6 +15,7 @@ import {
type EditorInstance,
} from "@portal/api/editorDeploy";
// Deployment target names are product/brand terms, not localised.
const TARGET_LABEL: Record<EditorInstance["target"], string> = {
cloud: "Cloud",
docker: "Docker",
@@ -25,7 +25,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
</span>
<span className="portal-infra__key-head-right">
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{KEY_LABEL[apiKey.status]}
{t(KEY_LABEL[apiKey.status])}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
@@ -72,7 +72,10 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
<dd className="portal-infra__chips">
{apiKey.permissions.map((p) => (
<Chip key={p} size="sm">
{p}
{t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
</Chip>
))}
</dd>
@@ -68,7 +68,7 @@ export function AuditTab() {
render: (e) => (
<div className="portal-infra__event">
<StatusBadge tone={AUDIT_CAT_TONE[e.category]} size="sm">
{AUDIT_CAT_LABEL[e.category]}
{t(AUDIT_CAT_LABEL[e.category])}
</StatusBadge>
<span>{e.action}</span>
</div>
@@ -89,7 +89,7 @@ export function AuditTab() {
header: t("portal.infrastructure.audit.columns.status"),
render: (e) => (
<StatusBadge tone={AUDIT_TONE[e.status]} size="sm">
{AUDIT_STATUS_LABEL[e.status]}
{t(AUDIT_STATUS_LABEL[e.status])}
</StatusBadge>
),
},
@@ -126,7 +126,10 @@ export function CreateKeyModal({
{PERMISSION_OPTS.map((p) => (
<Checkbox
key={p}
label={p}
label={t(
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
p,
)}
checked={perms.includes(p)}
onChange={() => togglePerm(p)}
/>
@@ -22,8 +22,8 @@ import {
DEPLOY_LABEL,
DEPLOY_TONE,
pct,
REGION_LABEL,
REGION_TONE,
titleCase,
} from "@portal/components/infrastructure/infraFormat";
export function DeploymentsTab() {
@@ -79,7 +79,7 @@ export function DeploymentsTab() {
size="sm"
pulse={r.status === "healthy"}
>
{titleCase(r.status)}
{t(REGION_LABEL[r.status])}
</StatusBadge>
),
},
@@ -164,7 +164,7 @@ export function DeploymentsTab() {
header: t("portal.infrastructure.deployments.deployColumns.status"),
render: (d) => (
<StatusBadge tone={DEPLOY_TONE[d.status]} size="sm">
{DEPLOY_LABEL[d.status]}
{t(DEPLOY_LABEL[d.status])}
</StatusBadge>
),
},
@@ -57,7 +57,7 @@ export function ModelsTab() {
header: t("portal.infrastructure.models.columns.type"),
render: (m) => (
<Chip accent={MODEL_TYPE_TONE[m.type]} size="sm">
{MODEL_TYPE_LABEL[m.type]}
{t(MODEL_TYPE_LABEL[m.type])}
</Chip>
),
},
@@ -70,7 +70,7 @@ export function ModelsTab() {
size="sm"
pulse={m.status === "active"}
>
{MODEL_LABEL[m.status]}
{t(MODEL_LABEL[m.status])}
</StatusBadge>
),
},
@@ -101,7 +101,7 @@ export function ModelsTab() {
align: "right",
render: (m) => (
<span className="portal-infra__mono">
{modelCost(m.cost, m.costUnit)}
{modelCost(t, m.cost, m.costUnit)}
</span>
),
},
@@ -259,7 +259,7 @@ export function SecurityTab() {
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{c.name}</span>
<StatusBadge tone={CERT_TONE[c.status]} size="sm">
{CERT_LABEL[c.status]}
{t(CERT_LABEL[c.status])}
</StatusBadge>
</div>
<p className="portal-infra__cert-detail">{c.detail}</p>
@@ -283,7 +283,7 @@ export function SecurityTab() {
<div className="portal-infra__cert-head">
<span className="portal-infra__cell-strong">{a.name}</span>
<StatusBadge tone={ATTESTATION_TONE[a.status]} size="sm">
{ATTESTATION_LABEL[a.status]}
{t(ATTESTATION_LABEL[a.status])}
</StatusBadge>
</div>
<Chip size="sm">{a.framework}</Chip>
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { ChipAccent, StatusTone } from "@app/ui";
import type {
ApiKeyStatus,
@@ -30,6 +31,13 @@ export const REGION_TONE: Record<RegionStatus, StatusTone> = {
down: "danger",
};
// Label maps hold i18n keys, resolved via t(MAP[value]) at the render sites.
export const REGION_LABEL: Record<RegionStatus, string> = {
healthy: "portal.infrastructure.regionLabel.healthy",
degraded: "portal.infrastructure.regionLabel.degraded",
down: "portal.infrastructure.regionLabel.down",
};
export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
live: "success",
rolling: "info",
@@ -38,10 +46,10 @@ export const DEPLOY_TONE: Record<DeploymentStatus, StatusTone> = {
};
export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
live: "Live",
rolling: "Rolling out",
"rolled-back": "Rolled back",
queued: "Queued",
live: "portal.infrastructure.deployLabel.live",
rolling: "portal.infrastructure.deployLabel.rolling",
"rolled-back": "portal.infrastructure.deployLabel.rolledBack",
queued: "portal.infrastructure.deployLabel.queued",
};
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
@@ -51,9 +59,9 @@ export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
};
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
active: "Active",
revoked: "Revoked",
"rotate-soon": "Rotate soon",
active: "portal.infrastructure.keyLabel.active",
revoked: "portal.infrastructure.keyLabel.revoked",
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
};
export const CERT_TONE: Record<CertStatus, StatusTone> = {
@@ -63,11 +71,12 @@ export const CERT_TONE: Record<CertStatus, StatusTone> = {
};
export const CERT_LABEL: Record<CertStatus, string> = {
certified: "Certified",
"in-progress": "In progress",
"not-started": "Not started",
certified: "portal.infrastructure.certLabel.certified",
"in-progress": "portal.infrastructure.certLabel.inProgress",
"not-started": "portal.infrastructure.certLabel.notStarted",
};
// Brand/acronym key-management modes are not localised.
export const KEY_MODE_LABEL: Record<KeyMode, string> = {
managed: "Stirling-managed",
byok: "BYOK",
@@ -82,9 +91,9 @@ export const KEY_MODE_TONE: Record<KeyMode, StatusTone> = {
};
export const ATTESTATION_LABEL: Record<AttestationStatus, string> = {
attested: "Attested",
"in-scope": "In scope",
"not-applicable": "N/A",
attested: "portal.infrastructure.attestationLabel.attested",
"in-scope": "portal.infrastructure.attestationLabel.inScope",
"not-applicable": "portal.infrastructure.attestationLabel.notApplicable",
};
export const ATTESTATION_TONE: Record<AttestationStatus, StatusTone> = {
@@ -102,18 +111,18 @@ export const AUDIT_TONE: Record<AuditStatus, StatusTone> = {
// Human labels for the status badge: danger/warning are tones, not outcomes (read "Error").
export const AUDIT_STATUS_LABEL: Record<AuditStatus, string> = {
success: "Success",
warning: "Warning",
danger: "Error",
info: "Info",
success: "portal.infrastructure.auditStatusLabel.success",
warning: "portal.infrastructure.auditStatusLabel.warning",
danger: "portal.infrastructure.auditStatusLabel.danger",
info: "portal.infrastructure.auditStatusLabel.info",
};
export const AUDIT_CAT_LABEL: Record<AuditCategory, string> = {
auth: "Auth",
config: "Config",
elevation: "Elevation",
processing: "Processing",
security: "Security",
auth: "portal.infrastructure.auditCatLabel.auth",
config: "portal.infrastructure.auditCatLabel.config",
elevation: "portal.infrastructure.auditCatLabel.elevation",
processing: "portal.infrastructure.auditCatLabel.processing",
security: "portal.infrastructure.auditCatLabel.security",
};
export const AUDIT_CAT_TONE: Record<AuditCategory, StatusTone> = {
@@ -131,16 +140,16 @@ export const MODEL_TONE: Record<ModelStatus, StatusTone> = {
};
export const MODEL_LABEL: Record<ModelStatus, string> = {
active: "Active",
degraded: "Degraded",
disabled: "Disabled",
active: "portal.infrastructure.modelLabel.active",
degraded: "portal.infrastructure.modelLabel.degraded",
disabled: "portal.infrastructure.modelLabel.disabled",
};
export const MODEL_TYPE_LABEL: Record<ModelType, string> = {
extraction: "Extraction",
classification: "Classification",
ocr: "OCR",
llm: "LLM",
extraction: "portal.infrastructure.modelTypeLabel.extraction",
classification: "portal.infrastructure.modelTypeLabel.classification",
ocr: "portal.infrastructure.modelTypeLabel.ocr",
llm: "portal.infrastructure.modelTypeLabel.llm",
};
export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
@@ -150,6 +159,7 @@ export const MODEL_TYPE_TONE: Record<ModelType, ChipAccent> = {
llm: "warning",
};
// Provider names are proper nouns, not localised.
export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
stirling: "Stirling",
openai: "OpenAI",
@@ -158,8 +168,14 @@ export const MODEL_PROVIDER_LABEL: Record<ModelProvider, string> = {
};
/** Render a model's cost with the unit it's billed against. */
export function modelCost(cost: number, unit: ModelCostUnit): string {
if (cost === 0) return "Included";
export function modelCost(
t: TFunction,
cost: number,
unit: ModelCostUnit,
): string {
if (cost === 0) return t("portal.infrastructure.models.metrics.included");
const price = `$${cost.toFixed(unit === "per-call" ? 3 : 2)}`;
return unit === "per-call" ? `${price}/call` : `${price}/1k`;
return unit === "per-call"
? t("portal.infrastructure.models.cost.perCall", { price })
: t("portal.infrastructure.models.cost.perThousand", { price });
}
@@ -77,70 +77,76 @@ function SideModal({
// ── Key documents ────────────────────────────────────────────────────────────
type DocStatus = "available" | "action" | "request";
interface DocRow {
name: string;
sub: string;
nameKey: string;
subKey: string;
status: DocStatus;
fee?: number;
}
const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
// Static demo catalogue for the pilot; the copy lives in the locale under
// portal.procurement.keyDocs and is resolved via t() at render time.
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
{
group: "Your deal",
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
docs: [
{
name: "Formal quote",
sub: "Built to your volume, term, and service level",
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
status: "available",
},
{
name: "Master Services Agreement",
sub: "One signature — MSA, order form, EULA, and DPA combined",
nameKey: "portal.procurement.keyDocs.docs.msa.name",
subKey: "portal.procurement.keyDocs.docs.msa.sub",
status: "action",
},
{
name: "Bank transfer instructions",
sub: "Wire details for your AP team",
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
status: "available",
},
{
name: "Purchase order",
sub: "Issuing a PO? Upload it and we invoice against it",
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
status: "request",
},
],
},
{
group: "Supporting your evaluation",
groupKey: "portal.procurement.keyDocs.groups.evaluation",
docs: [
{
name: "SOC 2 Type II report",
sub: "Audited · NDA-gated",
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
status: "available",
},
{
name: "Custom security review",
sub: "We complete your questionnaire and join your review call",
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
status: "request",
fee: 5000,
},
{
name: "Business Associate Agreement",
sub: "HIPAA · available on request",
nameKey: "portal.procurement.keyDocs.docs.baa.name",
subKey: "portal.procurement.keyDocs.docs.baa.sub",
status: "request",
fee: 2500,
},
{ name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
{
name: "Certificate of Insurance",
sub: "Cyber + E&O · current policy",
nameKey: "portal.procurement.keyDocs.docs.w9.name",
subKey: "portal.procurement.keyDocs.docs.w9.sub",
status: "available",
},
{
nameKey: "portal.procurement.keyDocs.docs.coi.name",
subKey: "portal.procurement.keyDocs.docs.coi.sub",
status: "available",
},
],
},
];
const STATUS_LABEL: Record<DocStatus, string> = {
available: "Download",
action: "Action needed",
request: "Request",
available: "portal.procurement.keyDocs.status.available",
action: "portal.procurement.keyDocs.status.action",
request: "portal.procurement.keyDocs.status.request",
};
export function KeyDocumentsModal({
@@ -150,31 +156,36 @@ export function KeyDocumentsModal({
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<SideModal
open={open}
onClose={onClose}
title="Key documents"
subtitle="Everything for each stage of your rollout, in one place."
title={t("portal.procurement.keyDocs.title")}
subtitle={t("portal.procurement.keyDocs.subtitle")}
>
{STAGE_DOCS.map((g) => (
<div key={g.group} className="portal-docs__group">
<div className="portal-docs__group-title">{g.group}</div>
<div key={g.groupKey} className="portal-docs__group">
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
<ul className="portal-docs__list">
{g.docs.map((d) => (
<li key={d.name} className="portal-docs__row">
<li key={d.nameKey} className="portal-docs__row">
<div className="portal-docs__row-text">
<span className="portal-docs__row-name">{d.name}</span>
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
<span className="portal-docs__row-sub">
{d.sub}
{d.fee ? ` · one-time $${d.fee.toLocaleString()}` : ""}
{t(d.subKey)}
{d.fee
? t("portal.procurement.keyDocs.oneTimeFee", {
amount: `$${d.fee.toLocaleString()}`,
})
: ""}
</span>
</div>
<span
className="portal-docs__row-action"
data-status={d.status}
>
{STATUS_LABEL[d.status]}
{t(STATUS_LABEL[d.status])}
</span>
</li>
))}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import "@portal/views/Procurement.css";
/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
@@ -58,6 +59,7 @@ export function ProcurementModal({
subtitle?: string;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const trapRef = useFocusTrap(open);
useEffect(() => {
@@ -86,7 +88,7 @@ export function ProcurementModal({
type="button"
className="portal-procmodal__close"
onClick={onClose}
aria-label="Close"
aria-label={t("portal.procurement.modal.close")}
>
</button>
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import type { AvatarTone, ChipAccent } from "@app/ui";
import type { Member, RoleId } from "@portal/api/users";
import { ROLE_TONE } from "@portal/api/users";
@@ -23,8 +24,14 @@ export function chipAccentForRole(role: RoleId): ChipAccent {
}
/** Seats used / limit as display copy; null limit → "Unlimited". */
export function seatsLabel(used: number, limit: number | null): string {
return limit === null ? `${used} · Unlimited` : `${used} / ${limit}`;
export function seatsLabel(
t: TFunction,
used: number,
limit: number | null,
): string {
return limit === null
? t("portal.users.seats.unlimited", { used })
: t("portal.users.seats.limited", { used, limit });
}
/**
+14 -10
View File
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
@@ -30,20 +31,23 @@ function csvCell(value: string): string {
}
/** Flatten the visible columns to CSV, matching the on-screen table. */
function toCsv(docs: ReviewDocument[]): string {
function toCsv(docs: ReviewDocument[], t: TFunction): string {
const header = [
"Document",
"Product",
"Pipeline / Action",
"User",
"Status",
"Time",
t("portal.documents.table.columns.document"),
t("portal.documents.table.columns.product"),
t("portal.documents.table.columns.action"),
t("portal.documents.table.columns.user"),
t("portal.documents.table.columns.status"),
t("portal.documents.table.columns.time"),
];
const lines = [header.map(csvCell).join(",")];
for (const d of docs) {
const action = d.product === "Editor" || !d.action ? "Editor" : d.action;
const action =
d.product === "Editor" || !d.action
? t("portal.documents.table.editorAction")
: d.action;
const status =
DOCUMENT_STATUS_LABEL[d.status] +
t(DOCUMENT_STATUS_LABEL[d.status]) +
(d.status === "in-review" && d.reviewer ? ` · ${d.reviewer}` : "");
lines.push(
[d.name, d.product, action, d.user || "", status, d.time]
@@ -66,7 +70,7 @@ export function Documents() {
const documents = state.data?.documents ?? [];
function exportCsv() {
const blob = new Blob([toCsv(documents)], {
const blob = new Blob([toCsv(documents, t)], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);