Skip to content

Commit ed5669f

Browse files
committed
feat: add scripts for translation management
- Introduced `generate_translation_seed_migration.py` to create SQL seed data from frontend locale JSON files for translations in English, German, French, and Russian. - Added `sync-translations-from-locales.mjs` to synchronize translations with a GraphQL API, ensuring missing translations can be filled with English values if specified.
1 parent 541aec6 commit ed5669f

12 files changed

Lines changed: 3452 additions & 23 deletions

File tree

apps/frontend/src/App.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { useEffect } from 'react';
12
import { Routes, Route, Navigate } from 'react-router-dom';
23
import { useAuthStore } from './stores/authStore';
4+
import { useUIStore } from './stores/uiStore';
35
import MainLayout from './layouts/MainLayout';
46
import AuthLayout from './layouts/AuthLayout';
57
import LoginPage from './pages/auth/LoginPage';
@@ -44,6 +46,24 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
4446
}
4547

4648
export default function App() {
49+
const currentCompanyId = useAuthStore((state) => state.currentCompanyId);
50+
const setTheme = useUIStore((state) => state.setTheme);
51+
const setShowTranslationKeys = useUIStore((state) => state.setShowTranslationKeys);
52+
53+
useEffect(() => {
54+
const scope = currentCompanyId || 'global';
55+
56+
const scopedTheme = localStorage.getItem(`erp-ui-theme:${scope}`) as 'light' | 'dark' | 'system' | null;
57+
if (scopedTheme && ['light', 'dark', 'system'].includes(scopedTheme)) {
58+
setTheme(scopedTheme);
59+
}
60+
61+
const scopedShowKeys = localStorage.getItem(`erp-ui-showTranslationKeys:${scope}`);
62+
if (scopedShowKeys !== null) {
63+
setShowTranslationKeys(scopedShowKeys === 'true');
64+
}
65+
}, [currentCompanyId, setTheme, setShowTranslationKeys]);
66+
4767
return (
4868
<Routes>
4969
{/* Auth Routes */}

apps/frontend/src/pages/settings/SettingsPage.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ export default function SettingsPage() {
1919
const theme = useUIStore((state) => state.theme);
2020
const setTheme = useUIStore((state) => state.setTheme);
2121
const showTranslationKeys = useUIStore((state) => state.showTranslationKeys);
22-
const toggleTranslationKeys = useUIStore((state) => state.toggleTranslationKeys);
22+
const setShowTranslationKeys = useUIStore((state) => state.setShowTranslationKeys);
2323
const user = useAuthStore((state) => state.user);
2424
const isAdmin = useAuthStore((state) => state.isAdmin);
25+
const currentCompanyId = useAuthStore((state) => state.currentCompanyId);
2526

2627
// Use current window location to construct dynamic URLs for API interfaces
2728
// This ensures the links work regardless of the domain (localhost, shopping-now.net, etc.)
@@ -42,6 +43,23 @@ export default function SettingsPage() {
4243
const [smtpLoading, setSmtpLoading] = useState(false);
4344
const [smtpMessage, setSmtpMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
4445
const [testEmailAddress, setTestEmailAddress] = useState('');
46+
const settingScope = currentCompanyId || 'global';
47+
48+
const handleThemeChange = (themeOption: 'light' | 'dark' | 'system') => {
49+
setTheme(themeOption);
50+
localStorage.setItem(`erp-ui-theme:${settingScope}`, themeOption);
51+
};
52+
53+
const handleTranslationKeysToggle = () => {
54+
const nextValue = !showTranslationKeys;
55+
setShowTranslationKeys(nextValue);
56+
localStorage.setItem(`erp-ui-showTranslationKeys:${settingScope}`, String(nextValue));
57+
};
58+
59+
const smtpHeaders = {
60+
'Content-Type': 'application/json',
61+
...(currentCompanyId ? { 'X-Company-Id': currentCompanyId } : {}),
62+
};
4563

4664
// Load SMTP configuration when SMTP tab is selected
4765
useEffect(() => {
@@ -53,7 +71,9 @@ export default function SettingsPage() {
5371
try {
5472
console.log('Loading SMTP configuration from API...');
5573
setSmtpLoading(true);
56-
const response = await fetch('/api/smtp-configuration');
74+
const response = await fetch('/api/smtp-configuration', {
75+
headers: smtpHeaders,
76+
});
5777
const data = await response.json();
5878
console.log('SMTP API response:', data);
5979

@@ -91,7 +111,7 @@ export default function SettingsPage() {
91111

92112
const response = await fetch('/api/smtp-configuration', {
93113
method: 'POST',
94-
headers: { 'Content-Type': 'application/json' },
114+
headers: smtpHeaders,
95115
body: JSON.stringify(smtpConfig),
96116
});
97117

@@ -123,7 +143,7 @@ export default function SettingsPage() {
123143

124144
const response = await fetch('/api/smtp-configuration/test', {
125145
method: 'POST',
126-
headers: { 'Content-Type': 'application/json' },
146+
headers: smtpHeaders,
127147
body: JSON.stringify(smtpConfig),
128148
});
129149

@@ -167,7 +187,7 @@ export default function SettingsPage() {
167187

168188
const response = await fetch('/api/smtp-configuration/test-email', {
169189
method: 'POST',
170-
headers: { 'Content-Type': 'application/json' },
190+
headers: smtpHeaders,
171191
body: JSON.stringify(requestBody),
172192
});
173193

@@ -255,7 +275,7 @@ export default function SettingsPage() {
255275
{(['light', 'dark', 'system'] as const).map((themeOption) => (
256276
<button
257277
key={themeOption}
258-
onClick={() => setTheme(themeOption)}
278+
onClick={() => handleThemeChange(themeOption)}
259279
className={`rounded-md px-4 py-2 ${
260280
theme === themeOption
261281
? 'bg-primary-600 text-white'
@@ -305,7 +325,7 @@ export default function SettingsPage() {
305325
</p>
306326
</div>
307327
<button
308-
onClick={toggleTranslationKeys}
328+
onClick={handleTranslationKeysToggle}
309329
className={`relative h-6 w-11 rounded-full transition-colors ${
310330
showTranslationKeys ? 'bg-primary-600' : 'bg-gray-300 dark:bg-gray-600'
311331
}`}

apps/frontend/src/pages/translations/TranslationModal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const CREATE_TRANSLATION_KEY = gql`
1515
`;
1616

1717
const UPDATE_TRANSLATION = gql`
18-
mutation UpdateTranslation($keyId: ID!, $language: String!, $valueText: String!) {
19-
setTranslation(input: { keyId: $keyId, language: $language, valueText: $valueText }) {
18+
mutation UpdateTranslation($keyId: ID!, $language: String!, $valueText: String!, $companyId: ID) {
19+
setTranslation(input: { keyId: $keyId, language: $language, valueText: $valueText, companyId: $companyId }) {
2020
id
2121
valueText
2222
}
@@ -37,11 +37,12 @@ interface TranslationKey {
3737

3838
interface Props {
3939
translationKey: TranslationKey | null;
40+
companyId?: string | null;
4041
onSaved?: () => Promise<void> | void;
4142
onClose: () => void;
4243
}
4344

44-
export default function TranslationModal({ translationKey, onSaved, onClose }: Props) {
45+
export default function TranslationModal({ translationKey, companyId, onSaved, onClose }: Props) {
4546
const { t } = useI18n();
4647
useEscapeKey(onClose);
4748
const isEditing = !!translationKey;
@@ -73,6 +74,7 @@ export default function TranslationModal({ translationKey, onSaved, onClose }: P
7374
keyId: translationKey.id,
7475
language: lang,
7576
valueText: values[lang],
77+
companyId,
7678
},
7779
});
7880
}
@@ -97,6 +99,7 @@ export default function TranslationModal({ translationKey, onSaved, onClose }: P
9799
keyId: newKeyId,
98100
language: lang,
99101
valueText: values[lang],
102+
companyId,
100103
},
101104
});
102105
}

apps/frontend/src/pages/translations/TranslationsPage.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useState } from 'react';
22
import { useQuery, gql } from '@apollo/client';
33
import { PlusIcon, PencilIcon, ArrowDownTrayIcon, ClipboardIcon, CheckIcon } from '@heroicons/react/24/outline';
44
import { useI18n, SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../../providers/I18nProvider';
5+
import { useUIStore } from '../../stores/uiStore';
6+
import { useAuthStore } from '../../stores/authStore';
57
import TranslationModal from './TranslationModal';
68

79
const GET_TRANSLATION_KEYS = gql`
@@ -13,6 +15,7 @@ const GET_TRANSLATION_KEYS = gql`
1315
values {
1416
language
1517
valueText
18+
companyId
1619
}
1720
}
1821
}
@@ -21,6 +24,7 @@ const GET_TRANSLATION_KEYS = gql`
2124
interface TranslationValue {
2225
language: string;
2326
valueText: string;
27+
companyId?: string | null;
2428
}
2529

2630
interface TranslationKey {
@@ -32,6 +36,8 @@ interface TranslationKey {
3236

3337
export default function TranslationsPage() {
3438
const { t, language, refreshTranslations } = useI18n();
39+
const showTranslationKeys = useUIStore((state) => state.showTranslationKeys);
40+
const currentCompanyId = useAuthStore((state) => state.currentCompanyId);
3541
const [isModalOpen, setIsModalOpen] = useState(false);
3642
const [editingKey, setEditingKey] = useState<TranslationKey | null>(null);
3743
const [filterNamespace, setFilterNamespace] = useState<string>('');
@@ -68,12 +74,27 @@ export default function TranslationsPage() {
6874
// Filter translations
6975
const filteredKeys = data?.translationKeys?.filter((tk: TranslationKey) => {
7076
const matchesNamespace = !filterNamespace || tk.namespace === filterNamespace;
77+
const fullKey = `${tk.namespace}.${tk.keyName}`.toLowerCase();
7178
const matchesSearch = !searchTerm ||
79+
tk.namespace.toLowerCase().includes(searchTerm.toLowerCase()) ||
7280
tk.keyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
81+
fullKey.includes(searchTerm.toLowerCase()) ||
7382
tk.values.some(v => v.valueText.toLowerCase().includes(searchTerm.toLowerCase()));
7483
return matchesNamespace && matchesSearch;
7584
}) || [];
7685

86+
const getEffectiveValue = (tk: TranslationKey, lang: string): string | undefined => {
87+
if (currentCompanyId) {
88+
const companyValue = tk.values.find(v => v.language === lang && v.companyId === currentCompanyId);
89+
if (companyValue?.valueText) {
90+
return companyValue.valueText;
91+
}
92+
}
93+
94+
const defaultValue = tk.values.find(v => v.language === lang && (v.companyId === null || v.companyId === undefined));
95+
return defaultValue?.valueText;
96+
};
97+
7798
return (
7899
<div className="flex flex-col h-full">
79100
{/* Header */}
@@ -136,6 +157,15 @@ export default function TranslationsPage() {
136157
</p>
137158
</div>
138159

160+
{showTranslationKeys && (
161+
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
162+
<p className="text-sm text-amber-800 dark:text-amber-300">
163+
<span className="font-semibold">Key display mode is active.</span>{' '}
164+
The UI intentionally shows translation keys (like <code className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-xs dark:bg-amber-900/50">nav.templates</code>) instead of translated text.
165+
</p>
166+
</div>
167+
)}
168+
139169
{/* Table */}
140170
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden flex-1 min-h-0">
141171
<div className="overflow-x-auto h-full">
@@ -185,18 +215,18 @@ export default function TranslationsPage() {
185215
<tr key={tk.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
186216
<td className="whitespace-nowrap px-6 py-4 font-mono text-sm">
187217
<div className="flex items-center gap-2">
188-
<span>{tk.keyName}</span>
218+
<span>{fullKey}</span>
189219
<CopyRefButton refKey={fullKey} />
190220
</div>
191221
</td>
192222
<td className="whitespace-nowrap px-6 py-4 text-gray-500 dark:text-gray-400">
193223
{tk.namespace}
194224
</td>
195225
{SUPPORTED_LANGUAGES.map((lang) => {
196-
const value = tk.values.find(v => v.language === lang);
226+
const valueText = getEffectiveValue(tk, lang);
197227
return (
198228
<td key={lang} className="max-w-xs truncate px-6 py-4">
199-
{value?.valueText || (
229+
{valueText || (
200230
<span className="text-gray-400 italic">
201231
{t('translations.missing')}
202232
</span>
@@ -225,6 +255,7 @@ export default function TranslationsPage() {
225255
{isModalOpen && (
226256
<TranslationModal
227257
translationKey={editingKey}
258+
companyId={currentCompanyId}
228259
onSaved={handleModalSaved}
229260
onClose={handleModalClose}
230261
/>

apps/frontend/src/providers/I18nProvider.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,21 @@ interface I18nContextType {
2626

2727
const I18nContext = createContext<I18nContextType | null>(null);
2828

29+
function getLanguageStorageKey(companyId?: string | null): string {
30+
return companyId ? `erp-language:${companyId}` : 'erp-language';
31+
}
32+
2933

3034
export function I18nProvider({ children }: { children: React.ReactNode }) {
35+
const currentCompanyId = useAuthStore((state) => state.currentCompanyId);
36+
3137
const [language, setLanguageState] = useState<Language>(() => {
38+
const initialCompanyId = useAuthStore.getState().currentCompanyId;
39+
const scopedStored = localStorage.getItem(getLanguageStorageKey(initialCompanyId));
40+
if (scopedStored && SUPPORTED_LANGUAGES.includes(scopedStored as Language)) {
41+
return scopedStored as Language;
42+
}
43+
3244
const stored = localStorage.getItem('erp-language');
3345
if (stored && SUPPORTED_LANGUAGES.includes(stored as Language)) {
3446
return stored as Language;
@@ -47,7 +59,12 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
4759
});
4860
return initial;
4961
});
50-
const currentCompanyId = useAuthStore((state) => state.currentCompanyId);
62+
useEffect(() => {
63+
const scopedStored = localStorage.getItem(getLanguageStorageKey(currentCompanyId));
64+
if (scopedStored && SUPPORTED_LANGUAGES.includes(scopedStored as Language) && scopedStored !== language) {
65+
setLanguageState(scopedStored as Language);
66+
}
67+
}, [currentCompanyId, language]);
5168

5269
// Load local translations when language changes
5370
useEffect(() => {
@@ -102,6 +119,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
102119
}
103120

104121
setLanguageState(lang);
122+
localStorage.setItem(getLanguageStorageKey(currentCompanyId), lang);
105123
localStorage.setItem('erp-language', lang);
106124
document.documentElement.lang = lang;
107125

@@ -112,7 +130,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
112130
} catch (err) {
113131
// ignore if Apollo client not available
114132
}
115-
}, []);
133+
}, [currentCompanyId]);
116134

117135
const refreshTranslations = useCallback(async () => {
118136
try {

apps/frontend/src/stores/uiStore.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface UIState {
1111
toggleSidebar: () => void;
1212
setSidebarOpen: (open: boolean) => void;
1313
setTheme: (theme: Theme) => void;
14+
setShowTranslationKeys: (value: boolean) => void;
1415
toggleTranslationKeys: () => void;
1516
}
1617

@@ -34,6 +35,10 @@ export const useUIStore = create<UIState>()(
3435
applyTheme(theme);
3536
},
3637

38+
setShowTranslationKeys: (value) => {
39+
set({ showTranslationKeys: value });
40+
},
41+
3742
toggleTranslationKeys: () => {
3843
set({ showTranslationKeys: !get().showTranslationKeys });
3944
},

0 commit comments

Comments
 (0)