Introduce a hand-rolled i18n layer across all three UIs, each reading its
text from per-language JSON files (English base + Polish + Danish). Nothing
in the converted screens hardcodes English any more.
- Web App (Vue): src/i18n/{en,pl,da}.json + index.js exposing t()/tSplit(),
reactive to the signed-in profile locale. Every view, component, form and
the status labels in lib/format.js go through t().
- API Server panel (Vue): src/i18n/ with its own localStorage-persisted
language (the panel has no user profile) and a header language picker.
Chrome, cards, login and API section titles translated; endpoint reference
descriptions intentionally kept in English. Rebuilt embedded dist.
- Phone App (Flutter): assets/i18n/ + lib/i18n.dart loaded at startup,
driven by AppSettings.locale. Nav, login, lock, dashboard, the full
Settings panel (incl. language picker) and format.dart status labels
translated; remaining detail screens fall back to English.
Language = the language half of the existing BCP-47 locale; the region half
still drives date/number/currency formatting. Missing keys fall back to
English, and plurals use Intl.PluralRules / Intl.plural so Polish gets the
correct one/few/many forms. Settings flags languages without a translation.
Tests updated to assert the localized (Polish) status wording; all pass.
See TRANSLATIONS.md for the format and how to add a language.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
129 lines
3.9 KiB
Vue
129 lines
3.9 KiB
Vue
<script setup>
|
|
import { ref, onMounted } from "vue";
|
|
import { isSuperadmin, request } from "../api";
|
|
import { t } from "../i18n";
|
|
|
|
// Listing is manager-scoped (an admin sees only their own org); creating,
|
|
// renaming and deleting are superadmin-only, matching the server's gates.
|
|
const orgs = ref([]);
|
|
const error = ref("");
|
|
const busy = ref(false);
|
|
const editing = ref(null); // org id, or "new"
|
|
const draftName = ref("");
|
|
|
|
async function load() {
|
|
try {
|
|
const out = await request("/api/orgs");
|
|
orgs.value = out.organizations || [];
|
|
error.value = "";
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
}
|
|
}
|
|
onMounted(load);
|
|
|
|
function startNew() {
|
|
editing.value = "new";
|
|
draftName.value = "";
|
|
}
|
|
|
|
function startEdit(o) {
|
|
editing.value = o.id;
|
|
draftName.value = o.name;
|
|
}
|
|
|
|
function cancel() {
|
|
editing.value = null;
|
|
error.value = "";
|
|
}
|
|
|
|
async function save() {
|
|
busy.value = true;
|
|
error.value = "";
|
|
try {
|
|
if (editing.value === "new") {
|
|
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
|
} else {
|
|
await request(`/api/orgs/${editing.value}`, {
|
|
method: "PATCH",
|
|
body: { name: draftName.value },
|
|
});
|
|
}
|
|
editing.value = null;
|
|
await load();
|
|
} catch (e) {
|
|
error.value = e.message;
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove(o) {
|
|
if (!confirm(t("orgs.confirmDelete", { name: o.name }))) return;
|
|
busy.value = true;
|
|
error.value = "";
|
|
try {
|
|
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
|
await load();
|
|
} catch (e) {
|
|
// The server refuses (409) while the org still has members.
|
|
error.value = e.message;
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dh-card overflow-hidden">
|
|
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
|
<div>
|
|
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ t("orgs.title") }}</div>
|
|
<p class="mt-0.5 text-xs text-muted">{{ t("orgs.subtitle") }}</p>
|
|
</div>
|
|
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">{{ t("orgs.newOrg") }}</button>
|
|
</div>
|
|
|
|
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
|
|
|
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
|
|
<label class="dh-label">{{ t("common.name") }}</label>
|
|
<input v-model="draftName" class="dh-input" :placeholder="t('orgs.namePlaceholder')" @keyup.enter="save" />
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
|
|
{{ editing === "new" ? t("common.create") : t("common.save") }}
|
|
</button>
|
|
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">{{ t("common.cancel") }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
|
{{ t("orgs.empty") }}
|
|
</p>
|
|
|
|
<table v-else class="w-full text-left text-sm">
|
|
<thead>
|
|
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
|
<th>{{ t("common.name") }}</th>
|
|
<th>{{ t("orgs.colId") }}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="o in orgs" :key="o.id" class="border-t border-subtle transition-colors hover:bg-sunken">
|
|
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
|
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
|
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
|
<template v-if="isSuperadmin">
|
|
<button class="dh-btn-ghost" @click="startEdit(o)">{{ t("common.rename") }}</button>
|
|
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
|
{{ t("common.delete") }}
|
|
</button>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</template>
|