diff --git a/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement b/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement
new file mode 100644
index 0000000000..e4fc2e5813
--- /dev/null
+++ b/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement
@@ -0,0 +1,3 @@
+Form Translations Custom Tool: The scope tabs now state how many placeholders are still open instead of a
+combined placeholder-times-language figure, and every language column header shows how many entries are
+still empty in that language.
diff --git a/cms-ui/apps/ct-form-translations/public/i18n/de.json b/cms-ui/apps/ct-form-translations/public/i18n/de.json
index 66404ac7f8..f0998ee1d9 100644
--- a/cms-ui/apps/ct-form-translations/public/i18n/de.json
+++ b/cms-ui/apps/ct-form-translations/public/i18n/de.json
@@ -10,6 +10,9 @@
"filter_type_all": "Alle",
"filter_type_incomplete": "Unvollständig",
"placeholder_display_count": "{{shown}} von {{total}} Platzhalter",
+ "scope_tab_open": "{{open}} von {{total}} offen",
+ "scope_tab_open_hint": "Bei {{open}} von {{total}} Platzhaltern fehlt mindestens eine Sprache",
+ "language_open_cells": "{{count}} unvollständige Zellen in dieser Sprache",
"legend_saved": "gespeichert",
"legend_dirty": "geändert",
"legend_empty": "leer",
diff --git a/cms-ui/apps/ct-form-translations/public/i18n/en.json b/cms-ui/apps/ct-form-translations/public/i18n/en.json
index 52e6d69083..babef8ea5b 100644
--- a/cms-ui/apps/ct-form-translations/public/i18n/en.json
+++ b/cms-ui/apps/ct-form-translations/public/i18n/en.json
@@ -10,6 +10,9 @@
"filter_type_all": "All",
"filter_type_incomplete": "Incomplete",
"placeholder_display_count": "{{shown}} of {{total}} placeholders",
+ "scope_tab_open": "{{open}} of {{total}} open",
+ "scope_tab_open_hint": "{{open}} of {{total}} placeholders are missing at least one language",
+ "language_open_cells": "{{count}} incomplete cells in this language",
"legend_saved": "saved",
"legend_dirty": "changed",
"legend_empty": "empty",
diff --git a/cms-ui/apps/ct-form-translations/src/app/app.component.html b/cms-ui/apps/ct-form-translations/src/app/app.component.html
index 4ce3519e99..4020be61c2 100644
--- a/cms-ui/apps/ct-form-translations/src/app/app.component.html
+++ b/cms-ui/apps/ct-form-translations/src/app/app.component.html
@@ -50,6 +50,7 @@
[languages]="languages()"
[saved]="savedTranslations()[activeScopeId()]"
[draft]="draft()"
+ [missingByLanguage]="activeCompletion().missingByLanguage"
(cellEdit)="onCellEdit($event)">
diff --git a/cms-ui/apps/ct-form-translations/src/app/app.component.ts b/cms-ui/apps/ct-form-translations/src/app/app.component.ts
index 93d9b3287c..6cb54664ea 100644
--- a/cms-ui/apps/ct-form-translations/src/app/app.component.ts
+++ b/cms-ui/apps/ct-form-translations/src/app/app.component.ts
@@ -26,6 +26,19 @@ import { GCMSRestClientRequestError } from '@gentics/cms-rest-client';
type LoadStatus = 'idle' | 'loading' | 'loaded' | 'no-session' | 'error';
+/**
+ * How far a scope has come. Local to this component - the tab strip and the
+ * table each take only the plain numbers they need.
+ */
+interface ScopeCompletion {
+ /** Placeholders in the scope. */
+ placeholders: number;
+ /** Placeholders where at least one active language is empty. */
+ open: number;
+ /** Empty placeholders per language code. */
+ missingByLanguage: Record;
+}
+
@Component({
selector: 'gtx-app',
templateUrl: './app.component.html',
@@ -136,30 +149,33 @@ export class AppComponent implements OnInit {
return counter;
});
+ /** Completion of the scope on screen, draft included - feeds the column headers. */
+ public readonly activeCompletion = computed(() => {
+ return computeCompletion(
+ merge(this.activeTranslations(), this.draft()),
+ this.languages(),
+ );
+ });
+
public readonly scopeTabs = computed(() => {
- const langs = this.languages().length;
+ const languages = this.languages();
const active = this.activeScopeId();
const translations = this.savedTranslations();
const dirtyCount = this.dirtyCount();
return Object.values(this.scopes()).map((scope) => {
const isActive = scope.id === active;
- const translationData: FormTranslations = (isActive)
+ /* Only the active scope can hold draft values - the draft is
+ cleared whenever the scope changes. */
+ const data: FormTranslations = isActive
? merge(translations[scope.id], this.draft())
- : translations[scope.id];
- const totalKeys = Object.keys(translationData).length;
-
- let translatedCount = 0;
- for (const row of Object.values(translationData)) {
- for (const val of Object.values(row)) {
- if (val.trim() !== '') translatedCount++;
- }
- }
+ : (translations[scope.id] ?? {});
+ const completion = computeCompletion(data, languages);
return {
scope,
- translatedCount,
- totalCount: totalKeys * langs,
+ open: completion.open,
+ total: completion.placeholders,
hasDirty: isActive ? dirtyCount > 0 : false,
};
});
@@ -352,6 +368,45 @@ export class AppComponent implements OnInit {
* Pure utilities (kept at module level — no side effects)
* ===================================================================== */
+/**
+ * Counts how far a scope has come: placeholders for the tab badge, plus the
+ * per-language gap for the table's column headers.
+ *
+ * Only the *active* languages are inspected. A payload can still carry values
+ * for a language that has since been removed from the CMS, and counting those
+ * was what let the old badge exceed its own denominator.
+ */
+function computeCompletion(
+ data: FormTranslations,
+ languages: FormTranslationsLanguage[],
+): ScopeCompletion {
+ const keys = Object.keys(data ?? {});
+ const missingByLanguage: Record = {};
+ for (const lang of languages) {
+ missingByLanguage[lang.code] = 0;
+ }
+
+ let open = 0;
+
+ for (const key of keys) {
+ const row = data[key] ?? {};
+ let rowComplete = true;
+
+ for (const lang of languages) {
+ if ((row[lang.code] ?? '').trim() === '') {
+ missingByLanguage[lang.code]++;
+ rowComplete = false;
+ }
+ }
+
+ if (!rowComplete) {
+ open++;
+ }
+ }
+
+ return { placeholders: keys.length, open, missingByLanguage };
+}
+
function merge(base: FormTranslations, delta: FormTranslations): FormTranslations {
const result: FormTranslations = { ...base };
for (const [key, langs] of Object.entries(delta)) {
diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html
index b99cb5e8fb..6a12088cf2 100644
--- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html
+++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html
@@ -1,7 +1,7 @@
diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss
index 938ab4cc7a..f2775a9181 100644
--- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss
+++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss
@@ -15,14 +15,18 @@
background: transparent;
border: none;
border-bottom: 3px solid transparent;
- padding: var(--pm-spacing-md);
+ padding: 10px var(--pm-spacing-md) 8px;
font-family: inherit;
font-size: var(--pm-font-size-md);
color: var(--pm-color-text-secondary);
cursor: pointer;
- display: inline-flex;
- align-items: center;
- gap: var(--pm-spacing-sm);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+ max-width: 260px;
+ text-align: left;
white-space: nowrap;
transition: var(--pm-transition);
@@ -31,24 +35,42 @@
background: var(--pm-color-primary-subtle);
}
+ &:focus-visible {
+ outline: 2px solid var(--pm-color-primary);
+ outline-offset: -2px;
+ }
+
&--active {
color: var(--pm-color-primary-dark);
border-bottom-color: var(--pm-color-primary);
background: var(--pm-color-primary-subtle);
- font-weight: 600;
- .scope-tabs__count {
- background: var(--pm-color-primary);
- color: #fff;
- }
+ .scope-tabs__label { font-weight: 600; }
+ .scope-tabs__stat { color: var(--pm-color-primary-dark); }
}
}
-.scope-tabs__count {
- background: var(--pm-color-border-light);
- color: var(--pm-color-text-secondary);
- border-radius: 999px;
- padding: 2px 8px 1px;
+.scope-tabs__head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.scope-tabs__label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.scope-tabs__stat {
font-size: var(--pm-font-size-xs);
- font-weight: 500;
+ color: var(--pm-color-text-secondary);
+ line-height: 1.3;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 700px) {
+ .scope-tabs { padding: 0 var(--pm-spacing-md); }
}
diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts
index b19d7cd085..3839beaa7a 100644
--- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts
+++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts
@@ -4,13 +4,18 @@ import {
EventEmitter,
Input,
Output,
+ inject,
} from '@angular/core';
+import { I18nService } from '@gentics/cms-components';
import { Scope, ScopeId } from '../../models/translations.model';
+/** What one tab renders. Every scope is measured the same way. */
export interface ScopeTabInfo {
scope: Scope;
- translatedCount: number;
- totalCount: number;
+ /** Placeholders with at least one empty active language. */
+ open: number;
+ /** Placeholders in the scope. */
+ total: number;
hasDirty: boolean;
}
@@ -22,6 +27,9 @@ export interface ScopeTabInfo {
styleUrls: ['./scope-tabs.component.scss'],
})
export class ScopeTabsComponent {
+
+ private readonly i18n = inject(I18nService);
+
@Input() tabs: ScopeTabInfo[] = [];
@Input() activeScopeId: ScopeId = '';
@Output() readonly scopeSelect = new EventEmitter();
@@ -30,4 +38,11 @@ export class ScopeTabsComponent {
if (id !== this.activeScopeId) this.scopeSelect.emit(id);
}
+ /** Spells out the short second line. */
+ hint(tab: ScopeTabInfo): string {
+ return this.i18n.instant('tool.scope_tab_open_hint', {
+ open: tab.open,
+ total: tab.total,
+ });
+ }
}
diff --git a/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html b/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html
index 36679e2516..a528f511a3 100644
--- a/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html
+++ b/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html
@@ -5,9 +5,26 @@