@sensolus/create-snt-agent-app 0.1.0 → 0.2.0

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 (44) hide show
  1. package/index.js +2 -1
  2. package/package.json +1 -1
  3. package/template/CLAUDE.md +218 -0
  4. package/template/Dockerfile +32 -0
  5. package/template/Jenkinsfile +28 -0
  6. package/template/README.md +493 -16
  7. package/template/_env.example +14 -0
  8. package/template/backend/app.py +642 -49
  9. package/template/backend/db_config.py +16 -0
  10. package/template/backend/extensions.py +3 -0
  11. package/template/backend/init_db.py +75 -0
  12. package/template/backend/migrations/README +1 -0
  13. package/template/backend/migrations/alembic.ini +50 -0
  14. package/template/backend/migrations/env.py +113 -0
  15. package/template/backend/migrations/script.py.mako +24 -0
  16. package/template/backend/migrations/versions/001_add_favourite_organisations.py +31 -0
  17. package/template/backend/migrations/versions/002_add_org_daily_stats.py +36 -0
  18. package/template/backend/models.py +31 -0
  19. package/template/backend/requirements.txt +8 -2
  20. package/template/eslint.config.js +6 -2
  21. package/template/index.html +11 -8
  22. package/template/infra/docker-compose.yml +15 -0
  23. package/template/openapi.json +40357 -0
  24. package/template/package.json +8 -1
  25. package/template/scripts/create-ecr-repo.sh +42 -0
  26. package/template/src/App.jsx +30 -33
  27. package/template/src/AppConfigContext.jsx +45 -0
  28. package/template/src/hooks/useFavourites.js +44 -0
  29. package/template/src/i18n/index.js +3 -0
  30. package/template/src/i18n/messages.js +8 -14
  31. package/template/src/i18n/translations/de.js +72 -0
  32. package/template/src/i18n/translations/en.js +79 -0
  33. package/template/src/i18n/translations/es.js +72 -0
  34. package/template/src/i18n/translations/fr.js +72 -0
  35. package/template/src/i18n/translations/nl.js +72 -0
  36. package/template/src/main.jsx +2 -6
  37. package/template/src/pages/Home.jsx +170 -0
  38. package/template/src/pages/OrganisationDetail.jsx +263 -0
  39. package/template/src/pages/OrganisationList.jsx +457 -0
  40. package/template/src/pages/Overview.jsx +199 -0
  41. package/template/src/pages/WidgetShowcase.jsx +522 -0
  42. package/template/src/styles/app.css +543 -4
  43. package/template/start-backend.sh +4 -0
  44. package/template/start-frontend.sh +3 -0
@@ -0,0 +1,457 @@
1
+ import { useState, useEffect, useMemo, useTransition } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import {
4
+ SntButton,
5
+ SntInput,
6
+ SntBadge,
7
+ SntButtonGroup,
8
+ SntLoadingOverlay,
9
+ SntTable,
10
+ SntProgressBar,
11
+ SntSidepanel,
12
+ SntFilterSection,
13
+ SntSwitch,
14
+ SntCheckboxList,
15
+ SntSummaryStat,
16
+ } from '@sensolus/snt-agent-kit'
17
+ import { useLocale, formatNumber } from '../i18n'
18
+ import { useFavourites } from '../hooks/useFavourites'
19
+
20
+ export function OrganisationList({ authReady, reloadRef, onLoadingChange }) {
21
+ const navigate = useNavigate()
22
+ const { t, intlLocale } = useLocale()
23
+ const [organisations, setOrganisations] = useState([])
24
+ const [loading, setLoading] = useState(false)
25
+ const [error, setError] = useState('')
26
+ const [loaded, setLoaded] = useState(false)
27
+ const [searchFilter, setSearchFilter] = useState('')
28
+ const [viewMode, setViewMode] = useState('cards')
29
+ const [sidepanelOpen, setSidepanelOpen] = useState(true)
30
+ const [showOnlyActive, setShowOnlyActive] = useState(false)
31
+ const [minSubscriptions, setMinSubscriptions] = useState(0)
32
+ const [selectedTypes, setSelectedTypes] = useState([])
33
+ const [showOnlyFavourites, setShowOnlyFavourites] = useState(false)
34
+ const [filterPending, startFilterTransition] = useTransition()
35
+ const { favourites, toggleFavourite, isFavourite } = useFavourites()
36
+
37
+ const setShowOnlyFavouritesDeferred = (value) => {
38
+ startFilterTransition(() => setShowOnlyFavourites(value))
39
+ }
40
+
41
+ const fetchOrganisations = async () => {
42
+ if (!authReady) return
43
+ setLoading(true)
44
+ setError('')
45
+
46
+ try {
47
+ const response = await fetch('/api/organisations')
48
+ const data = await response.json()
49
+
50
+ if (!response.ok) {
51
+ throw new Error(data.error || t('orgList.fetchFailed'))
52
+ }
53
+
54
+ setOrganisations(data)
55
+ setLoaded(true)
56
+ } catch (err) {
57
+ setError(err.message)
58
+ setOrganisations([])
59
+ } finally {
60
+ setLoading(false)
61
+ }
62
+ }
63
+
64
+ // Auto-load once auth becomes available
65
+ useEffect(() => {
66
+ if (authReady && !loaded && !loading) {
67
+ fetchOrganisations()
68
+ }
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [authReady])
71
+
72
+ // Expose reload to parent so the action button can live in the app header
73
+ if (reloadRef) reloadRef.current = fetchOrganisations
74
+
75
+ useEffect(() => {
76
+ onLoadingChange?.(loading)
77
+ }, [loading, onLoadingChange])
78
+
79
+ // Get unique organisation types for the checkbox filter
80
+ const organisationTypes = useMemo(() => {
81
+ const types = new Set(organisations.map(org => org.organisationType || 'NORMAL'))
82
+ return Array.from(types).sort()
83
+ }, [organisations])
84
+
85
+ // Filter organisations by all criteria, then sort favourites to top
86
+ const filteredOrganisations = useMemo(() => {
87
+ const filtered = organisations.filter(org => {
88
+ // Favourites filter
89
+ if (showOnlyFavourites && !isFavourite(org.id)) return false
90
+
91
+ // Search filter
92
+ if (searchFilter.trim()) {
93
+ const search = searchFilter.toLowerCase()
94
+ const matchesSearch =
95
+ org.name?.toLowerCase().includes(search) ||
96
+ org.id?.toString().includes(search) ||
97
+ org.organisationType?.toLowerCase().includes(search)
98
+ if (!matchesSearch) return false
99
+ }
100
+
101
+ // Active subscriptions filter
102
+ if (showOnlyActive) {
103
+ const activeSubs = org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0
104
+ if (activeSubs === 0) return false
105
+ }
106
+
107
+ // Minimum subscriptions slider
108
+ if (minSubscriptions > 0) {
109
+ const subs = org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0
110
+ if (subs < minSubscriptions) return false
111
+ }
112
+
113
+ // Organisation type filter
114
+ if (selectedTypes.length > 0) {
115
+ const orgType = org.organisationType || 'NORMAL'
116
+ if (!selectedTypes.includes(orgType)) return false
117
+ }
118
+
119
+ return true
120
+ })
121
+
122
+ // Sort favourites to the top
123
+ return filtered.sort((a, b) => {
124
+ const aFav = isFavourite(a.id) ? 0 : 1
125
+ const bFav = isFavourite(b.id) ? 0 : 1
126
+ return aFav - bFav
127
+ })
128
+ }, [organisations, searchFilter, showOnlyActive, minSubscriptions, selectedTypes, showOnlyFavourites, isFavourite])
129
+
130
+ // Compute summary statistics
131
+ const summaryStats = useMemo(() => {
132
+ const totalOrgs = organisations.length
133
+ const totalTrackers = organisations.reduce((sum, org) =>
134
+ sum + (org.statistics?.metrics?.NUMBER_OF_TRACKERS ?? 0), 0)
135
+ const totalSubscriptions = organisations.reduce((sum, org) =>
136
+ sum + (org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0), 0)
137
+ const totalUsers = organisations.reduce((sum, org) =>
138
+ sum + (org.statistics?.metrics?.NUMBER_OF_USERS ?? 0), 0)
139
+ const avgOnlineRatio = organisations.length > 0
140
+ ? organisations.reduce((sum, org) =>
141
+ sum + (org.statistics?.metrics?.ONLINE_RATIO ?? 0), 0) / organisations.length
142
+ : 0
143
+
144
+ return { totalOrgs, totalTrackers, totalSubscriptions, totalUsers, avgOnlineRatio }
145
+ }, [organisations])
146
+
147
+ // Get max subscriptions for slider
148
+ const maxSubscriptions = useMemo(() => {
149
+ return Math.max(
150
+ ...organisations.map(org => org.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? 0),
151
+ 100
152
+ )
153
+ }, [organisations])
154
+
155
+ const getTypeBadgeVariant = (type) => {
156
+ switch (type?.toUpperCase()) {
157
+ case 'PARTNER': return 'success'
158
+ case 'SYSTEM': return 'warning'
159
+ default: return 'info'
160
+ }
161
+ }
162
+
163
+ // Helper to get metric from statistics.metrics
164
+ const getMetric = (org, metricName) => {
165
+ const value = org.statistics?.metrics?.[metricName]
166
+ return value !== undefined ? Math.round(value) : null
167
+ }
168
+
169
+ const handleOrgClick = (org) => {
170
+ // Store the org data in sessionStorage for the detail page
171
+ sessionStorage.setItem(`org_${org.id}`, JSON.stringify(org))
172
+ navigate(`/organisation/${org.id}`)
173
+ }
174
+
175
+ // Table columns definition - using actual API metric names
176
+ const tableColumns = [
177
+ {
178
+ key: 'favourite',
179
+ header: '',
180
+ sortable: false,
181
+ render: (row) => (
182
+ <button
183
+ className={`star-btn ${isFavourite(row.id) ? 'star-active' : ''}`}
184
+ onClick={(e) => { e.stopPropagation(); toggleFavourite(row.id) }}
185
+ title={isFavourite(row.id) ? t('orgList.removeFavourite') : t('orgList.addFavourite')}
186
+ >
187
+ {isFavourite(row.id) ? '\u2605' : '\u2606'}
188
+ </button>
189
+ )
190
+ },
191
+ {
192
+ key: 'name',
193
+ header: t('orgList.col.name'),
194
+ render: (row, value) => (
195
+ <button
196
+ className="link-button"
197
+ onClick={() => handleOrgClick(row)}
198
+ >
199
+ {value}
200
+ </button>
201
+ )
202
+ },
203
+ { key: 'id', header: t('orgList.col.id') },
204
+ {
205
+ key: 'organisationType',
206
+ header: t('orgList.col.type'),
207
+ render: (row, value) => (
208
+ <SntBadge variant={getTypeBadgeVariant(value)} text={value || 'NORMAL'} />
209
+ )
210
+ },
211
+ {
212
+ key: 'trackers',
213
+ header: t('orgList.col.trackers'),
214
+ getValue: (row) => row.statistics?.metrics?.NUMBER_OF_TRACKERS ?? null,
215
+ render: (row) => getMetric(row, 'NUMBER_OF_TRACKERS') ?? '-'
216
+ },
217
+ {
218
+ key: 'subscriptions',
219
+ header: t('orgList.col.activeSubs'),
220
+ getValue: (row) => row.statistics?.metrics?.NUMBER_OF_ACTIVE_SUBSCRIPTION ?? null,
221
+ render: (row) => getMetric(row, 'NUMBER_OF_ACTIVE_SUBSCRIPTION') ?? '-'
222
+ },
223
+ {
224
+ key: 'users',
225
+ header: t('orgList.col.users'),
226
+ getValue: (row) => row.statistics?.metrics?.NUMBER_OF_USERS ?? null,
227
+ render: (row) => getMetric(row, 'NUMBER_OF_USERS') ?? '-'
228
+ },
229
+ {
230
+ key: 'onlineRatio',
231
+ header: t('orgList.col.online'),
232
+ getValue: (row) => row.statistics?.metrics?.ONLINE_RATIO ?? null,
233
+ render: (row) => {
234
+ const ratio = row.statistics?.metrics?.ONLINE_RATIO
235
+ return ratio !== undefined
236
+ ? <SntProgressBar value={ratio} variant="auto" />
237
+ : '-'
238
+ }
239
+ },
240
+ ]
241
+
242
+ // Reset filters handler
243
+ const handleResetFilters = () => {
244
+ setSearchFilter('')
245
+ setShowOnlyActive(false)
246
+ setShowOnlyFavourites(false)
247
+ setMinSubscriptions(0)
248
+ setSelectedTypes([])
249
+ }
250
+
251
+ return (
252
+ <div className="page-container">
253
+ {/* Summary Stats */}
254
+ {loaded && organisations.length > 0 && (
255
+ <div className="summary-stats-row">
256
+ <SntSummaryStat
257
+ value={favourites.size}
258
+ label={t('orgList.summary.favourites')}
259
+ variant="warning"
260
+ onClick={() => setShowOnlyFavouritesDeferred(!showOnlyFavourites)}
261
+ active={showOnlyFavourites}
262
+ />
263
+ <SntSummaryStat
264
+ value={summaryStats.totalOrgs}
265
+ label={t('orgList.summary.organisations')}
266
+ variant="info"
267
+ onClick={() => setShowOnlyFavouritesDeferred(false)}
268
+ active={!showOnlyFavourites}
269
+ />
270
+ <SntSummaryStat
271
+ value={formatNumber(summaryStats.totalTrackers, intlLocale)}
272
+ label={t('orgList.summary.totalTrackers')}
273
+ />
274
+ <SntSummaryStat
275
+ value={formatNumber(summaryStats.totalSubscriptions, intlLocale)}
276
+ label={t('orgList.summary.activeSubscriptions')}
277
+ variant="success"
278
+ />
279
+ <SntSummaryStat
280
+ value={formatNumber(summaryStats.totalUsers, intlLocale)}
281
+ label={t('orgList.summary.totalUsers')}
282
+ />
283
+ <SntSummaryStat
284
+ value={`${Math.round(summaryStats.avgOnlineRatio)}%`}
285
+ label={t('orgList.summary.avgOnlineRatio')}
286
+ variant={summaryStats.avgOnlineRatio >= 80 ? 'success' : summaryStats.avgOnlineRatio >= 50 ? 'warning' : 'danger'}
287
+ />
288
+ </div>
289
+ )}
290
+
291
+ {error && <div className="error">{error}</div>}
292
+
293
+ {loading && (
294
+ <SntLoadingOverlay message={t('orgList.loadingOrgs')} />
295
+ )}
296
+
297
+ {/* Main content with sidepanel */}
298
+ {loaded && organisations.length > 0 && (
299
+ <div className="page-with-sidepanel">
300
+ <SntSidepanel
301
+ title={t('common.filters')}
302
+ open={sidepanelOpen}
303
+ onToggle={() => setSidepanelOpen(!sidepanelOpen)}
304
+ >
305
+ <SntFilterSection label={t('orgList.favourites')}>
306
+ <SntSwitch
307
+ checked={showOnlyFavourites}
308
+ onChange={setShowOnlyFavourites}
309
+ label={t('orgList.onlyFavourites')}
310
+ />
311
+ </SntFilterSection>
312
+
313
+ <SntFilterSection label={t('common.search')}>
314
+ <SntInput
315
+ type="text"
316
+ placeholder={t('orgList.searchPlaceholder')}
317
+ value={searchFilter}
318
+ onChange={setSearchFilter}
319
+ />
320
+ </SntFilterSection>
321
+
322
+ <SntFilterSection label={t('orgList.activeSubscriptions')}>
323
+ <SntSwitch
324
+ checked={showOnlyActive}
325
+ onChange={setShowOnlyActive}
326
+ label={t('orgList.onlyActive')}
327
+ />
328
+ </SntFilterSection>
329
+
330
+ <SntFilterSection label={t('orgList.minSubscriptions', minSubscriptions)}>
331
+ <input
332
+ type="range"
333
+ className="snt-slider"
334
+ min={0}
335
+ max={maxSubscriptions}
336
+ value={minSubscriptions}
337
+ onChange={(e) => setMinSubscriptions(Number(e.target.value))}
338
+ />
339
+ </SntFilterSection>
340
+
341
+ {organisationTypes.length > 0 && (
342
+ <SntFilterSection label={t('orgList.organisationType')}>
343
+ <SntCheckboxList
344
+ options={organisationTypes}
345
+ selected={selectedTypes}
346
+ onChange={setSelectedTypes}
347
+ />
348
+ </SntFilterSection>
349
+ )}
350
+
351
+ <SntFilterSection>
352
+ <SntButton variant="secondary" onClick={handleResetFilters}>
353
+ {t('common.resetFilters')}
354
+ </SntButton>
355
+ </SntFilterSection>
356
+ </SntSidepanel>
357
+
358
+ <div className={`page-main-content ${filterPending ? 'content-pending' : ''}`}>
359
+ <div className="toolbar-row">
360
+ <span className="filter-count">
361
+ {t('orgList.showing', filteredOrganisations.length, organisations.length)}
362
+ </span>
363
+ <SntButtonGroup
364
+ options={[
365
+ { value: 'cards', label: t('common.cards') },
366
+ { value: 'table', label: t('common.table') },
367
+ ]}
368
+ value={viewMode}
369
+ onChange={setViewMode}
370
+ />
371
+ </div>
372
+
373
+ {filteredOrganisations.length === 0 && (
374
+ <div className="empty-state">
375
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
376
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
377
+ </svg>
378
+ <h3>{t('orgList.noOrgsFound')}</h3>
379
+ <p>{t('orgList.noOrgsMatch')}</p>
380
+ </div>
381
+ )}
382
+
383
+ {filteredOrganisations.length > 0 && viewMode === 'cards' && (
384
+ <div className="org-grid">
385
+ {filteredOrganisations.map((org) => (
386
+ <div
387
+ key={org.id}
388
+ className="org-card org-card-clickable"
389
+ onClick={() => handleOrgClick(org)}
390
+ >
391
+ <div className="org-card-header">
392
+ <h3 className="org-name">{org.name}</h3>
393
+ <button
394
+ className={`star-btn ${isFavourite(org.id) ? 'star-active' : ''}`}
395
+ onClick={(e) => { e.stopPropagation(); toggleFavourite(org.id) }}
396
+ title={isFavourite(org.id) ? t('orgList.removeFavourite') : t('orgList.addFavourite')}
397
+ >
398
+ {isFavourite(org.id) ? '\u2605' : '\u2606'}
399
+ </button>
400
+ </div>
401
+ <span className="org-type">
402
+ <SntBadge
403
+ variant={getTypeBadgeVariant(org.organisationType)}
404
+ text={org.organisationType || 'NORMAL'}
405
+ />
406
+ </span>
407
+ <div className="org-details">
408
+ <div className="org-stat">
409
+ <span className="stat-label">{t('orgList.stat.id')}</span>
410
+ <span className="stat-value">{org.id}</span>
411
+ </div>
412
+ {getMetric(org, 'NUMBER_OF_TRACKERS') !== null && (
413
+ <div className="org-stat">
414
+ <span className="stat-label">{t('orgList.stat.trackers')}</span>
415
+ <span className="stat-value">{getMetric(org, 'NUMBER_OF_TRACKERS')}</span>
416
+ </div>
417
+ )}
418
+ {getMetric(org, 'NUMBER_OF_ACTIVE_SUBSCRIPTION') !== null && (
419
+ <div className="org-stat">
420
+ <span className="stat-label">{t('orgList.stat.activeSubscriptions')}</span>
421
+ <span className="stat-value">{getMetric(org, 'NUMBER_OF_ACTIVE_SUBSCRIPTION')}</span>
422
+ </div>
423
+ )}
424
+ {getMetric(org, 'NUMBER_OF_USERS') !== null && (
425
+ <div className="org-stat">
426
+ <span className="stat-label">{t('orgList.stat.users')}</span>
427
+ <span className="stat-value">{getMetric(org, 'NUMBER_OF_USERS')}</span>
428
+ </div>
429
+ )}
430
+ {org.statistics?.metrics?.ONLINE_RATIO !== undefined && (
431
+ <div className="org-stat">
432
+ <span className="stat-label">{t('orgList.stat.online')}</span>
433
+ <span className="stat-value">
434
+ <SntProgressBar value={org.statistics.metrics.ONLINE_RATIO} variant="auto" />
435
+ </span>
436
+ </div>
437
+ )}
438
+ </div>
439
+ </div>
440
+ ))}
441
+ </div>
442
+ )}
443
+
444
+ {filteredOrganisations.length > 0 && viewMode === 'table' && (
445
+ <SntTable
446
+ data={filteredOrganisations}
447
+ columns={tableColumns}
448
+ defaultPageSize={25}
449
+ emptyMessage={t('orgList.noOrgsFound')}
450
+ />
451
+ )}
452
+ </div>
453
+ </div>
454
+ )}
455
+ </div>
456
+ )
457
+ }
@@ -0,0 +1,199 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { SntCard, SntLoadingOverlay, SntSummaryStat } from '@sensolus/snt-agent-kit'
3
+ import { useLocale, formatNumber, formatShortDate } from '../i18n'
4
+
5
+ const SERIES = [
6
+ { key: 'orgCount', color: '#212851', labelKey: 'overview.series.orgs' },
7
+ { key: 'trackerTotal', color: '#0071A1', labelKey: 'overview.series.trackers' },
8
+ { key: 'userTotal', color: '#39CB99', labelKey: 'overview.series.users' },
9
+ ]
10
+
11
+ const CHART_W = 720
12
+ const CHART_H = 280
13
+ const PADDING = { top: 16, right: 16, bottom: 32, left: 48 }
14
+
15
+ function LineChart({ data, intlLocale, timezone, t }) {
16
+ if (!data.length) return null
17
+
18
+ const innerW = CHART_W - PADDING.left - PADDING.right
19
+ const innerH = CHART_H - PADDING.top - PADDING.bottom
20
+
21
+ const maxValue = Math.max(
22
+ 1,
23
+ ...data.flatMap((d) => SERIES.map((s) => d[s.key] || 0)),
24
+ )
25
+
26
+ const xFor = (i) =>
27
+ PADDING.left + (data.length === 1 ? innerW / 2 : (i / (data.length - 1)) * innerW)
28
+ const yFor = (v) => PADDING.top + innerH - (v / maxValue) * innerH
29
+
30
+ const ticks = 4
31
+ const yTicks = Array.from({ length: ticks + 1 }, (_, i) => Math.round((maxValue / ticks) * i))
32
+
33
+ return (
34
+ <svg
35
+ viewBox={`0 0 ${CHART_W} ${CHART_H}`}
36
+ style={{ width: '100%', height: 'auto', maxWidth: CHART_W }}
37
+ role="img"
38
+ aria-label={t('overview.chart.title')}
39
+ >
40
+ {/* y-axis grid + labels */}
41
+ {yTicks.map((v, i) => {
42
+ const y = yFor(v)
43
+ return (
44
+ <g key={i}>
45
+ <line
46
+ x1={PADDING.left}
47
+ x2={CHART_W - PADDING.right}
48
+ y1={y}
49
+ y2={y}
50
+ stroke="#E5E8ED"
51
+ strokeWidth="1"
52
+ />
53
+ <text
54
+ x={PADDING.left - 8}
55
+ y={y + 4}
56
+ textAnchor="end"
57
+ fontSize="11"
58
+ fill="#535E6F"
59
+ >
60
+ {formatNumber(v, intlLocale)}
61
+ </text>
62
+ </g>
63
+ )
64
+ })}
65
+
66
+ {/* x-axis labels (first, middle, last) */}
67
+ {[0, Math.floor((data.length - 1) / 2), data.length - 1]
68
+ .filter((v, i, a) => a.indexOf(v) === i)
69
+ .map((i) => (
70
+ <text
71
+ key={i}
72
+ x={xFor(i)}
73
+ y={CHART_H - 8}
74
+ textAnchor="middle"
75
+ fontSize="11"
76
+ fill="#535E6F"
77
+ >
78
+ {formatShortDate(data[i].date, intlLocale, timezone)}
79
+ </text>
80
+ ))}
81
+
82
+ {/* lines */}
83
+ {SERIES.map((s) => {
84
+ const path = data
85
+ .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xFor(i)} ${yFor(d[s.key] || 0)}`)
86
+ .join(' ')
87
+ return (
88
+ <g key={s.key}>
89
+ <path d={path} fill="none" stroke={s.color} strokeWidth="2" />
90
+ {data.map((d, i) => (
91
+ <circle
92
+ key={i}
93
+ cx={xFor(i)}
94
+ cy={yFor(d[s.key] || 0)}
95
+ r="3"
96
+ fill={s.color}
97
+ >
98
+ <title>
99
+ {formatShortDate(d.date, intlLocale, timezone)} — {t(s.labelKey)}:{' '}
100
+ {formatNumber(d[s.key] || 0, intlLocale)}
101
+ </title>
102
+ </circle>
103
+ ))}
104
+ </g>
105
+ )
106
+ })}
107
+ </svg>
108
+ )
109
+ }
110
+
111
+ export function Overview({ authReady }) {
112
+ const { t, intlLocale, timezone } = useLocale()
113
+ const [data, setData] = useState([])
114
+ const [loading, setLoading] = useState(false)
115
+ const [error, setError] = useState(null)
116
+
117
+ useEffect(() => {
118
+ if (!authReady) return
119
+ let cancelled = false
120
+ setLoading(true)
121
+ fetch('/api/org-stats/totals')
122
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
123
+ .then((rows) => {
124
+ if (!cancelled) setData(rows)
125
+ })
126
+ .catch((e) => {
127
+ if (!cancelled) setError(e.message)
128
+ })
129
+ .finally(() => {
130
+ if (!cancelled) setLoading(false)
131
+ })
132
+ return () => {
133
+ cancelled = true
134
+ }
135
+ }, [authReady])
136
+
137
+ const latest = data[data.length - 1]
138
+
139
+ return (
140
+ <div className="page-container">
141
+ {loading && <SntLoadingOverlay message={t('common.loading')} />}
142
+
143
+ {!loading && error && (
144
+ <SntCard>
145
+ <p style={{ color: 'var(--snt-red)' }}>{error}</p>
146
+ </SntCard>
147
+ )}
148
+
149
+ {!loading && !error && data.length === 0 && (
150
+ <SntCard>
151
+ <p>{t('overview.empty')}</p>
152
+ </SntCard>
153
+ )}
154
+
155
+ {!loading && !error && data.length > 0 && (
156
+ <>
157
+ <div className="summary-stats-row">
158
+ <SntSummaryStat
159
+ value={formatNumber(latest.orgCount, intlLocale)}
160
+ label={t('overview.series.orgs')}
161
+ variant="info"
162
+ />
163
+ <SntSummaryStat
164
+ value={formatNumber(latest.trackerTotal, intlLocale)}
165
+ label={t('overview.series.trackers')}
166
+ />
167
+ <SntSummaryStat
168
+ value={formatNumber(latest.userTotal, intlLocale)}
169
+ label={t('overview.series.users')}
170
+ variant="success"
171
+ />
172
+ </div>
173
+
174
+ <SntCard title={t('overview.chart.title')}>
175
+ <div style={{ display: 'flex', gap: 16, marginBottom: 12, flexWrap: 'wrap' }}>
176
+ {SERIES.map((s) => (
177
+ <span
178
+ key={s.key}
179
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12 }}
180
+ >
181
+ <span
182
+ style={{
183
+ display: 'inline-block',
184
+ width: 12,
185
+ height: 2,
186
+ background: s.color,
187
+ }}
188
+ />
189
+ {t(s.labelKey)}
190
+ </span>
191
+ ))}
192
+ </div>
193
+ <LineChart data={data} intlLocale={intlLocale} timezone={timezone} t={t} />
194
+ </SntCard>
195
+ </>
196
+ )}
197
+ </div>
198
+ )
199
+ }