@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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/template/CLAUDE.md +218 -0
  3. package/template/Dockerfile +32 -0
  4. package/template/Jenkinsfile +28 -0
  5. package/template/README.md +477 -16
  6. package/template/_env.example +4 -0
  7. package/template/backend/app.py +630 -49
  8. package/template/backend/db_config.py +16 -0
  9. package/template/backend/extensions.py +3 -0
  10. package/template/backend/init_db.py +75 -0
  11. package/template/backend/migrations/README +1 -0
  12. package/template/backend/migrations/alembic.ini +50 -0
  13. package/template/backend/migrations/env.py +113 -0
  14. package/template/backend/migrations/script.py.mako +24 -0
  15. package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
  16. package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
  17. package/template/backend/models.py +31 -0
  18. package/template/backend/requirements.txt +8 -2
  19. package/template/eslint.config.js +6 -2
  20. package/template/index.html +11 -8
  21. package/template/infra/docker-compose.yml +15 -0
  22. package/template/openapi.json +40357 -0
  23. package/template/package.json +8 -1
  24. package/template/scripts/create-ecr-repo.sh +42 -0
  25. package/template/src/App.jsx +12 -34
  26. package/template/src/hooks/useFavourites.js +44 -0
  27. package/template/src/i18n/index.js +3 -0
  28. package/template/src/i18n/messages.js +8 -14
  29. package/template/src/i18n/translations/de.js +96 -0
  30. package/template/src/i18n/translations/en.js +103 -0
  31. package/template/src/i18n/translations/es.js +96 -0
  32. package/template/src/i18n/translations/fr.js +96 -0
  33. package/template/src/i18n/translations/nl.js +96 -0
  34. package/template/src/main.jsx +2 -3
  35. package/template/src/pages/Home.jsx +170 -0
  36. package/template/src/pages/OrganisationDetail.jsx +259 -0
  37. package/template/src/pages/OrganisationList.jsx +457 -0
  38. package/template/src/pages/Overview.jsx +199 -0
  39. package/template/src/pages/WidgetShowcase.jsx +522 -0
  40. package/template/src/styles/app.css +543 -4
  41. package/template/start-backend.sh +4 -0
  42. 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
+ }
@@ -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/messages'
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
+ }