@sensolus/create-snt-agent-app 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/CLAUDE.md +218 -0
- package/template/Dockerfile +32 -0
- package/template/Jenkinsfile +28 -0
- package/template/README.md +477 -16
- package/template/_env.example +4 -0
- package/template/backend/app.py +630 -49
- package/template/backend/db_config.py +16 -0
- package/template/backend/extensions.py +3 -0
- package/template/backend/init_db.py +75 -0
- package/template/backend/migrations/README +1 -0
- package/template/backend/migrations/alembic.ini +50 -0
- package/template/backend/migrations/env.py +113 -0
- package/template/backend/migrations/script.py.mako +24 -0
- package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
- package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
- package/template/backend/models.py +31 -0
- package/template/backend/requirements.txt +8 -2
- package/template/eslint.config.js +6 -2
- package/template/index.html +11 -8
- package/template/infra/docker-compose.yml +15 -0
- package/template/openapi.json +40357 -0
- package/template/package.json +8 -1
- package/template/scripts/create-ecr-repo.sh +42 -0
- package/template/src/App.jsx +12 -34
- package/template/src/hooks/useFavourites.js +44 -0
- package/template/src/i18n/index.js +3 -0
- package/template/src/i18n/messages.js +8 -14
- package/template/src/i18n/translations/de.js +96 -0
- package/template/src/i18n/translations/en.js +103 -0
- package/template/src/i18n/translations/es.js +96 -0
- package/template/src/i18n/translations/fr.js +96 -0
- package/template/src/i18n/translations/nl.js +96 -0
- package/template/src/main.jsx +2 -3
- package/template/src/pages/Home.jsx +170 -0
- package/template/src/pages/OrganisationDetail.jsx +259 -0
- package/template/src/pages/OrganisationList.jsx +457 -0
- package/template/src/pages/Overview.jsx +199 -0
- package/template/src/pages/WidgetShowcase.jsx +522 -0
- package/template/src/styles/app.css +543 -4
- package/template/start-backend.sh +4 -0
- package/template/start-frontend.sh +3 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
// Home / tabs
|
|
3
|
+
'home.tab.overview': 'Overzicht',
|
|
4
|
+
'home.tab.explore': 'Verkennen',
|
|
5
|
+
'home.tab.showcase': 'Widgets',
|
|
6
|
+
|
|
7
|
+
// Overview
|
|
8
|
+
'overview.title': 'Overzicht',
|
|
9
|
+
'overview.empty': 'Nog geen snapshots verzameld. De dagelijkse snapshot vult deze grafiek.',
|
|
10
|
+
'overview.chart.title': 'Dagtotalen over organisaties',
|
|
11
|
+
'overview.series.orgs': 'Organisaties',
|
|
12
|
+
'overview.series.trackers': 'Trackers',
|
|
13
|
+
'overview.series.users': 'Gebruikers',
|
|
14
|
+
|
|
15
|
+
// Common
|
|
16
|
+
|
|
17
|
+
// Table widget
|
|
18
|
+
|
|
19
|
+
// CheckboxList widget
|
|
20
|
+
'checkboxList.selectAll': 'Alles selecteren',
|
|
21
|
+
'checkboxList.deselectAll': 'Alles deselecteren',
|
|
22
|
+
|
|
23
|
+
// Sidepanel
|
|
24
|
+
'sidepanel.collapse': 'Paneel inklappen',
|
|
25
|
+
'sidepanel.expand': 'Paneel uitklappen',
|
|
26
|
+
|
|
27
|
+
// DateRangePicker presets
|
|
28
|
+
'dateRange.period': 'Periode:',
|
|
29
|
+
'dateRange.from': 'Van',
|
|
30
|
+
'dateRange.to': 'Tot',
|
|
31
|
+
'dateRange.preset.this_month': 'Deze maand',
|
|
32
|
+
'dateRange.preset.last_month': 'Vorige maand',
|
|
33
|
+
'dateRange.preset.last_3_months': 'Laatste 3 maanden',
|
|
34
|
+
'dateRange.preset.last_6_months': 'Laatste 6 maanden',
|
|
35
|
+
'dateRange.preset.last_12_months': 'Laatste 12 maanden',
|
|
36
|
+
'dateRange.preset.last_24_months': 'Laatste 24 maanden',
|
|
37
|
+
'dateRange.preset.last_36_months': 'Laatste 36 maanden',
|
|
38
|
+
'dateRange.preset.this_year': 'Dit jaar',
|
|
39
|
+
'dateRange.preset.last_year': 'Vorig jaar',
|
|
40
|
+
'dateRange.preset.all_time': 'Alles',
|
|
41
|
+
'dateRange.preset.custom': 'Aangepast',
|
|
42
|
+
'dateRange.preset.next_3_months': 'Volgende 3 maanden',
|
|
43
|
+
'dateRange.preset.next_6_months': 'Volgende 6 maanden',
|
|
44
|
+
'dateRange.preset.next_12_months': 'Volgende 12 maanden',
|
|
45
|
+
'dateRange.preset.this_quarter': 'Dit kwartaal',
|
|
46
|
+
'dateRange.preset.next_quarter': 'Volgend kwartaal',
|
|
47
|
+
'dateRange.preset.rolling_12_months': 'Rollende 12 maanden',
|
|
48
|
+
|
|
49
|
+
// OrganisationList page
|
|
50
|
+
'orgList.title': 'Organisaties',
|
|
51
|
+
'orgList.apiKeyPlaceholder': 'API sleutel...',
|
|
52
|
+
'orgList.searchPlaceholder': 'Zoek op naam...',
|
|
53
|
+
'orgList.enterApiKey': 'Voer een API sleutel in',
|
|
54
|
+
'orgList.fetchFailed': 'Ophalen van organisaties mislukt',
|
|
55
|
+
'orgList.loadingOrgs': 'Organisaties laden...',
|
|
56
|
+
'orgList.showing': (shown, total) => `${shown} van ${total} organisaties`,
|
|
57
|
+
'orgList.noOrgsFound': 'Geen organisaties gevonden',
|
|
58
|
+
'orgList.noOrgsMatch': 'Geen organisaties komen overeen met de huidige filters.',
|
|
59
|
+
'orgList.activeSubscriptions': 'Actieve abonnementen',
|
|
60
|
+
'orgList.onlyActive': 'Alleen met actieve abonnementen',
|
|
61
|
+
'orgList.minSubscriptions': (n) => `Min. abonnementen: ${n}`,
|
|
62
|
+
'orgList.organisationType': 'Organisatietype',
|
|
63
|
+
'orgList.favourites': 'Favorieten',
|
|
64
|
+
'orgList.onlyFavourites': 'Alleen favorieten',
|
|
65
|
+
'orgList.addFavourite': 'Toevoegen aan favorieten',
|
|
66
|
+
'orgList.removeFavourite': 'Verwijderen uit favorieten',
|
|
67
|
+
'orgList.summary.favourites': 'Favorieten',
|
|
68
|
+
'orgList.summary.organisations': 'Organisaties',
|
|
69
|
+
'orgList.summary.totalTrackers': 'Totaal trackers',
|
|
70
|
+
'orgList.summary.activeSubscriptions': 'Actieve abonnementen',
|
|
71
|
+
'orgList.summary.totalUsers': 'Totaal gebruikers',
|
|
72
|
+
'orgList.summary.avgOnlineRatio': 'Gem. online ratio',
|
|
73
|
+
'orgList.col.name': 'Naam',
|
|
74
|
+
'orgList.col.id': 'ID',
|
|
75
|
+
'orgList.col.type': 'Type',
|
|
76
|
+
'orgList.col.trackers': 'Trackers',
|
|
77
|
+
'orgList.col.activeSubs': 'Actieve abon.',
|
|
78
|
+
'orgList.col.users': 'Gebruikers',
|
|
79
|
+
'orgList.col.online': 'Online',
|
|
80
|
+
'orgList.stat.id': 'ID',
|
|
81
|
+
'orgList.stat.trackers': 'Trackers',
|
|
82
|
+
'orgList.stat.activeSubscriptions': 'Actieve abonnementen',
|
|
83
|
+
'orgList.stat.users': 'Gebruikers',
|
|
84
|
+
'orgList.stat.online': 'Online',
|
|
85
|
+
|
|
86
|
+
// OrganisationDetail page
|
|
87
|
+
'orgDetail.notFound': 'Organisatie niet gevonden',
|
|
88
|
+
'orgDetail.notAvailable': 'Organisatiegegevens niet beschikbaar',
|
|
89
|
+
'orgDetail.goBackPrompt': 'Ga terug naar de lijst en selecteer een organisatie.',
|
|
90
|
+
'orgDetail.basicInfo': 'Basisinformatie',
|
|
91
|
+
'orgDetail.metrics': 'Metrieken',
|
|
92
|
+
'orgDetail.statistics': 'Statistieken',
|
|
93
|
+
'orgDetail.otherDetails': 'Overige details',
|
|
94
|
+
'orgDetail.locked': 'Vergrendeld',
|
|
95
|
+
'orgDetail.active': 'Actief',
|
|
96
|
+
}
|
package/template/src/main.jsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
|
-
import { LocaleProvider } from '@sensolus/snt-agent-kit'
|
|
4
|
-
import '@sensolus/snt-agent-kit/theme.css'
|
|
5
3
|
import App from './App'
|
|
6
|
-
import { messages } from './i18n
|
|
4
|
+
import { LocaleProvider, messages } from './i18n'
|
|
5
|
+
import '@sensolus/snt-agent-kit/theme.css'
|
|
7
6
|
import './styles/app.css'
|
|
8
7
|
|
|
9
8
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { SntTabs, SntTabPanel, SntDialog, SntButton, SntInput, SntPageHeader } from '@sensolus/snt-agent-kit'
|
|
3
|
+
import { useLocale } from '../i18n'
|
|
4
|
+
import { Overview } from './Overview'
|
|
5
|
+
import { OrganisationList } from './OrganisationList'
|
|
6
|
+
import { WidgetShowcase } from './WidgetShowcase'
|
|
7
|
+
|
|
8
|
+
export function Home() {
|
|
9
|
+
const { t } = useLocale()
|
|
10
|
+
const [activeTab, setActiveTab] = useState('explore')
|
|
11
|
+
const [authReady, setAuthReady] = useState(false)
|
|
12
|
+
const [hasCookieToken, setHasCookieToken] = useState(false)
|
|
13
|
+
const [showApiKeyDialog, setShowApiKeyDialog] = useState(false)
|
|
14
|
+
const [apiKeyInput, setApiKeyInput] = useState('')
|
|
15
|
+
const [submitting, setSubmitting] = useState(false)
|
|
16
|
+
const [submitError, setSubmitError] = useState('')
|
|
17
|
+
const orgListReloadRef = useRef(null)
|
|
18
|
+
const [orgListLoading, setOrgListLoading] = useState(false)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let cancelled = false
|
|
22
|
+
fetch('/api/auth/check')
|
|
23
|
+
.then((r) => r.json())
|
|
24
|
+
.then((data) => {
|
|
25
|
+
if (cancelled) return
|
|
26
|
+
if (data.hasSessionToken) {
|
|
27
|
+
setHasCookieToken(true)
|
|
28
|
+
setAuthReady(true)
|
|
29
|
+
} else if (data.hasStoredApiKey) {
|
|
30
|
+
setAuthReady(true)
|
|
31
|
+
} else {
|
|
32
|
+
setShowApiKeyDialog(true)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {
|
|
36
|
+
if (!cancelled) setShowApiKeyDialog(true)
|
|
37
|
+
})
|
|
38
|
+
return () => {
|
|
39
|
+
cancelled = true
|
|
40
|
+
}
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const submitApiKey = async () => {
|
|
44
|
+
const key = apiKeyInput.trim()
|
|
45
|
+
if (!key) return
|
|
46
|
+
setSubmitting(true)
|
|
47
|
+
setSubmitError('')
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch('/api/auth/api-key', {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ apiKey: key }),
|
|
53
|
+
})
|
|
54
|
+
const data = await r.json()
|
|
55
|
+
if (!r.ok) {
|
|
56
|
+
setSubmitError(data.error || t('auth.dialog.failed'))
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
setAuthReady(true)
|
|
60
|
+
setShowApiKeyDialog(false)
|
|
61
|
+
setApiKeyInput('')
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setSubmitError(err.message)
|
|
64
|
+
} finally {
|
|
65
|
+
setSubmitting(false)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const openApiKeyDialog = () => {
|
|
70
|
+
setSubmitError('')
|
|
71
|
+
setApiKeyInput('')
|
|
72
|
+
setShowApiKeyDialog(true)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tabs = [
|
|
76
|
+
{ key: 'explore', label: t('home.tab.explore') },
|
|
77
|
+
{ key: 'overview', label: t('home.tab.overview') },
|
|
78
|
+
{ key: 'showcase', label: t('home.tab.showcase') },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const headerActions = (
|
|
82
|
+
<div className="header-actions">
|
|
83
|
+
{!hasCookieToken && (
|
|
84
|
+
<SntButton variant="secondary" onClick={openApiKeyDialog}>
|
|
85
|
+
{t('orgList.changeApiKey')}
|
|
86
|
+
</SntButton>
|
|
87
|
+
)}
|
|
88
|
+
{activeTab === 'explore' && (
|
|
89
|
+
<SntButton
|
|
90
|
+
variant="primary"
|
|
91
|
+
onClick={() => orgListReloadRef.current?.()}
|
|
92
|
+
disabled={orgListLoading || !authReady}
|
|
93
|
+
>
|
|
94
|
+
{orgListLoading ? t('common.loading') : t('common.reload')}
|
|
95
|
+
</SntButton>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="home-page">
|
|
102
|
+
<SntPageHeader title="Sample app" actions={headerActions} />
|
|
103
|
+
<SntTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab}>
|
|
104
|
+
<SntTabPanel tabKey="overview" activeTab={activeTab}>
|
|
105
|
+
<Overview authReady={authReady} />
|
|
106
|
+
</SntTabPanel>
|
|
107
|
+
<SntTabPanel tabKey="explore" activeTab={activeTab}>
|
|
108
|
+
<OrganisationList
|
|
109
|
+
authReady={authReady}
|
|
110
|
+
reloadRef={orgListReloadRef}
|
|
111
|
+
onLoadingChange={setOrgListLoading}
|
|
112
|
+
/>
|
|
113
|
+
</SntTabPanel>
|
|
114
|
+
<SntTabPanel tabKey="showcase" activeTab={activeTab}>
|
|
115
|
+
<WidgetShowcase />
|
|
116
|
+
</SntTabPanel>
|
|
117
|
+
</SntTabs>
|
|
118
|
+
|
|
119
|
+
<SntDialog
|
|
120
|
+
open={showApiKeyDialog}
|
|
121
|
+
onClose={() => {
|
|
122
|
+
if (authReady) setShowApiKeyDialog(false)
|
|
123
|
+
}}
|
|
124
|
+
title={t('auth.dialog.title')}
|
|
125
|
+
size="small"
|
|
126
|
+
>
|
|
127
|
+
<p>{t('auth.dialog.description')}</p>
|
|
128
|
+
<SntInput
|
|
129
|
+
type="password"
|
|
130
|
+
placeholder={t('orgList.apiKeyPlaceholder')}
|
|
131
|
+
value={apiKeyInput}
|
|
132
|
+
onChange={setApiKeyInput}
|
|
133
|
+
onKeyPress={(e) => {
|
|
134
|
+
if (e.key === 'Enter') submitApiKey()
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
{submitError && (
|
|
138
|
+
<div className="error" style={{ marginTop: 8 }}>
|
|
139
|
+
{submitError}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
<div
|
|
143
|
+
style={{
|
|
144
|
+
marginTop: 16,
|
|
145
|
+
display: 'flex',
|
|
146
|
+
justifyContent: 'flex-end',
|
|
147
|
+
gap: 8,
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{authReady && (
|
|
151
|
+
<SntButton
|
|
152
|
+
variant="secondary"
|
|
153
|
+
onClick={() => setShowApiKeyDialog(false)}
|
|
154
|
+
disabled={submitting}
|
|
155
|
+
>
|
|
156
|
+
{t('common.cancel')}
|
|
157
|
+
</SntButton>
|
|
158
|
+
)}
|
|
159
|
+
<SntButton
|
|
160
|
+
variant="primary"
|
|
161
|
+
onClick={submitApiKey}
|
|
162
|
+
disabled={submitting || !apiKeyInput.trim()}
|
|
163
|
+
>
|
|
164
|
+
{submitting ? t('common.loading') : t('common.save')}
|
|
165
|
+
</SntButton>
|
|
166
|
+
</div>
|
|
167
|
+
</SntDialog>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useParams } from 'react-router-dom'
|
|
2
|
+
import { useMemo, useState, useEffect } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
SntPageHeader,
|
|
5
|
+
SntBadge,
|
|
6
|
+
SntProgressBar,
|
|
7
|
+
SntCard,
|
|
8
|
+
SntMap,
|
|
9
|
+
SntSpinner,
|
|
10
|
+
} from '@sensolus/snt-agent-kit'
|
|
11
|
+
import { useLocale, formatNumber } from '../i18n'
|
|
12
|
+
|
|
13
|
+
export function OrganisationDetail() {
|
|
14
|
+
const { id } = useParams()
|
|
15
|
+
const { t, intlLocale } = useLocale()
|
|
16
|
+
|
|
17
|
+
// Try to get org data from sessionStorage
|
|
18
|
+
const organisation = useMemo(() => {
|
|
19
|
+
const cached = sessionStorage.getItem(`org_${id}`)
|
|
20
|
+
if (cached) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(cached)
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}, [id])
|
|
29
|
+
|
|
30
|
+
const [devices, setDevices] = useState([])
|
|
31
|
+
const [mapLoading, setMapLoading] = useState(true)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!organisation) return
|
|
35
|
+
setMapLoading(true)
|
|
36
|
+
|
|
37
|
+
fetch(`/api/devices/byFilter?fields=serial,name,lastLat,lastLng,lastAddress,lastLocationUpdate,deviceCategory`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
maxResults: 1000,
|
|
42
|
+
startIndex: 0,
|
|
43
|
+
filter: {
|
|
44
|
+
type: 'ORGANISATION',
|
|
45
|
+
ids: [String(id)],
|
|
46
|
+
includedNull: false,
|
|
47
|
+
includedNotNull: false
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
.then(r => r.ok ? r.json() : { data: [] })
|
|
52
|
+
.then(result => {
|
|
53
|
+
const items = Array.isArray(result) ? result : (result.data || [])
|
|
54
|
+
setDevices(items.filter(d => d.deviceCategory === 'TRACKER' && !(d.lastLat === 0 && d.lastLng === 0)))
|
|
55
|
+
setMapLoading(false)
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
setDevices([])
|
|
59
|
+
setMapLoading(false)
|
|
60
|
+
})
|
|
61
|
+
}, [organisation, id])
|
|
62
|
+
|
|
63
|
+
const getTypeBadgeVariant = (type) => {
|
|
64
|
+
switch (type?.toUpperCase()) {
|
|
65
|
+
case 'PARTNER': return 'success'
|
|
66
|
+
case 'SYSTEM': return 'warning'
|
|
67
|
+
default: return 'info'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Format a value for display
|
|
72
|
+
const formatValue = (value) => {
|
|
73
|
+
if (value === null || value === undefined) return '-'
|
|
74
|
+
if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no')
|
|
75
|
+
if (typeof value === 'number') return formatNumber(value, intlLocale)
|
|
76
|
+
if (typeof value === 'object') return JSON.stringify(value, null, 2)
|
|
77
|
+
return String(value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Flatten an object into key-value pairs
|
|
81
|
+
const flattenObject = (obj, prefix = '') => {
|
|
82
|
+
const result = []
|
|
83
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
84
|
+
const fullKey = prefix ? `${prefix}.${key}` : key
|
|
85
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
86
|
+
result.push(...flattenObject(value, fullKey))
|
|
87
|
+
} else {
|
|
88
|
+
result.push({ key: fullKey, value })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Group fields by category
|
|
95
|
+
const categorizeFields = (org) => {
|
|
96
|
+
const basicInfo = []
|
|
97
|
+
const statistics = []
|
|
98
|
+
const metrics = []
|
|
99
|
+
const other = []
|
|
100
|
+
|
|
101
|
+
const allFields = flattenObject(org)
|
|
102
|
+
|
|
103
|
+
for (const field of allFields) {
|
|
104
|
+
const key = field.key.toLowerCase()
|
|
105
|
+
if (key.startsWith('statistics.metrics.')) {
|
|
106
|
+
metrics.push({
|
|
107
|
+
...field,
|
|
108
|
+
label: field.key.replace('statistics.metrics.', '').replace(/_/g, ' ')
|
|
109
|
+
})
|
|
110
|
+
} else if (key.startsWith('statistics.')) {
|
|
111
|
+
statistics.push({
|
|
112
|
+
...field,
|
|
113
|
+
label: field.key.replace('statistics.', '').replace(/([A-Z])/g, ' $1').trim()
|
|
114
|
+
})
|
|
115
|
+
} else if (['id', 'name', 'organisationtype', 'locked', 'platformplan', 'region', 'segment'].includes(key)) {
|
|
116
|
+
basicInfo.push({
|
|
117
|
+
...field,
|
|
118
|
+
label: field.key.replace(/([A-Z])/g, ' $1').trim()
|
|
119
|
+
})
|
|
120
|
+
} else {
|
|
121
|
+
other.push({
|
|
122
|
+
...field,
|
|
123
|
+
label: field.key.replace(/([A-Z])/g, ' $1').trim()
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { basicInfo, statistics, metrics, other }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!organisation) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="page-container">
|
|
134
|
+
<SntPageHeader title={t('orgDetail.notFound')} backTo="/" />
|
|
135
|
+
<div className="empty-state">
|
|
136
|
+
<h3>{t('orgDetail.notAvailable')}</h3>
|
|
137
|
+
<p>{t('orgDetail.goBackPrompt')}</p>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { basicInfo, statistics, metrics, other } = categorizeFields(organisation)
|
|
144
|
+
|
|
145
|
+
const renderField = (field) => {
|
|
146
|
+
const { key, value, label } = field
|
|
147
|
+
const lowerKey = key.toLowerCase()
|
|
148
|
+
|
|
149
|
+
// Special rendering for specific fields
|
|
150
|
+
if (lowerKey === 'organisationtype') {
|
|
151
|
+
return (
|
|
152
|
+
<div key={key} className="org-detail-field">
|
|
153
|
+
<span className="org-detail-label">{label}</span>
|
|
154
|
+
<span className="org-detail-value">
|
|
155
|
+
<SntBadge
|
|
156
|
+
variant={getTypeBadgeVariant(value)}
|
|
157
|
+
text={value || 'NORMAL'}
|
|
158
|
+
/>
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (lowerKey.includes('online_ratio') || lowerKey.includes('onlineratio')) {
|
|
165
|
+
return (
|
|
166
|
+
<div key={key} className="org-detail-field">
|
|
167
|
+
<span className="org-detail-label">{label}</span>
|
|
168
|
+
<span className="org-detail-value">
|
|
169
|
+
<SntProgressBar value={value} variant="auto" showLabel />
|
|
170
|
+
</span>
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (lowerKey === 'locked') {
|
|
176
|
+
return (
|
|
177
|
+
<div key={key} className="org-detail-field">
|
|
178
|
+
<span className="org-detail-label">{label}</span>
|
|
179
|
+
<span className="org-detail-value">
|
|
180
|
+
<SntBadge
|
|
181
|
+
variant={value ? 'danger' : 'success'}
|
|
182
|
+
text={value ? t('orgDetail.locked') : t('orgDetail.active')}
|
|
183
|
+
/>
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div key={key} className="org-detail-field">
|
|
191
|
+
<span className="org-detail-label">{label}</span>
|
|
192
|
+
<span className="org-detail-value">{formatValue(value)}</span>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className="page-container">
|
|
199
|
+
<SntPageHeader
|
|
200
|
+
title={organisation.name}
|
|
201
|
+
backTo="/"
|
|
202
|
+
>
|
|
203
|
+
<SntBadge
|
|
204
|
+
variant={getTypeBadgeVariant(organisation.organisationType)}
|
|
205
|
+
text={organisation.organisationType || 'NORMAL'}
|
|
206
|
+
/>
|
|
207
|
+
</SntPageHeader>
|
|
208
|
+
|
|
209
|
+
<SntCard title={`Trackers (${mapLoading ? '...' : devices.filter(d => d.lastLat != null).length})`}>
|
|
210
|
+
{mapLoading ? (
|
|
211
|
+
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
|
212
|
+
<SntSpinner size="medium" />
|
|
213
|
+
</div>
|
|
214
|
+
) : (
|
|
215
|
+
<SntMap
|
|
216
|
+
height="500px"
|
|
217
|
+
devices={devices}
|
|
218
|
+
orgId={id}
|
|
219
|
+
showGeozones={true}
|
|
220
|
+
center={[50.85, 4.35]}
|
|
221
|
+
zoom={6}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</SntCard>
|
|
225
|
+
|
|
226
|
+
{basicInfo.length > 0 && (
|
|
227
|
+
<SntCard title={t('orgDetail.basicInfo')}>
|
|
228
|
+
<div className="org-detail-grid">
|
|
229
|
+
{basicInfo.map(renderField)}
|
|
230
|
+
</div>
|
|
231
|
+
</SntCard>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{metrics.length > 0 && (
|
|
235
|
+
<SntCard title={t('orgDetail.metrics')} style={{ marginTop: 24 }}>
|
|
236
|
+
<div className="org-detail-grid">
|
|
237
|
+
{metrics.map(renderField)}
|
|
238
|
+
</div>
|
|
239
|
+
</SntCard>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{statistics.length > 0 && (
|
|
243
|
+
<SntCard title={t('orgDetail.statistics')} style={{ marginTop: 24 }}>
|
|
244
|
+
<div className="org-detail-grid">
|
|
245
|
+
{statistics.map(renderField)}
|
|
246
|
+
</div>
|
|
247
|
+
</SntCard>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{other.length > 0 && (
|
|
251
|
+
<SntCard title={t('orgDetail.otherDetails')} style={{ marginTop: 24 }}>
|
|
252
|
+
<div className="org-detail-grid">
|
|
253
|
+
{other.map(renderField)}
|
|
254
|
+
</div>
|
|
255
|
+
</SntCard>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|