Add Slides builder, data source selector, fix cross-year labels
Features: - Slides page: build presentations with configurable charts per slide - Export slides as HTML zip - Data source dropdown (Museums active, Coffees/eCommerce placeholders) - Renamed app to 'HiHala Data' with 'Museums' as subtitle Fixes: - Cross-year period labels now show 'Nov 25–Jan 26' instead of misleading year - Period display boxes updated to use smart labels UI: - New nav link for Slides - Mobile nav updated - Data source select styled to match brand
This commit is contained in:
76
package-lock.json
generated
76
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -9078,6 +9079,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "9.0.21",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||
@@ -10933,6 +10940,54 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -11019,6 +11074,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -11835,6 +11899,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
@@ -14731,6 +14801,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
20
public/hihala-logo-horizontal.svg
Executable file
20
public/hihala-logo-horizontal.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
20
public/hihala-logo-vertical.svg
Executable file
20
public/hihala-logo-vertical.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
@@ -15,6 +15,9 @@
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
@@ -24,7 +27,7 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>HiHala Museums Dashboard</title>
|
||||
<title>HiHala Data – Museums</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "HiHala Data",
|
||||
"name": "HiHala Data – Museums",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
490
src/App.css
490
src/App.css
@@ -152,29 +152,74 @@ body {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1rem;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-brand-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-brand-text {
|
||||
font-family: 'DM Sans', 'Inter', -apple-system, sans-serif;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
color: #1e3a5f;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 24px;
|
||||
width: auto;
|
||||
.data-source-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 2px 20px 2px 6px;
|
||||
margin-left: 4px;
|
||||
border-radius: 6px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-brand span {
|
||||
color: var(--text-secondary);
|
||||
.data-source-select:hover {
|
||||
background-color: rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.data-source-select:focus {
|
||||
outline: none;
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.data-source-select option {
|
||||
color: #1e3a5f;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-source-select option:disabled {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@@ -1015,15 +1060,25 @@ table tbody tr:hover {
|
||||
|
||||
.nav-bar {
|
||||
padding: 12px 16px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 0.9rem;
|
||||
.nav-brand-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 20px;
|
||||
.nav-brand-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.data-source-select {
|
||||
font-size: 0.9rem;
|
||||
padding: 2px 18px 2px 4px;
|
||||
}
|
||||
|
||||
/* Mobile Bottom Navigation */
|
||||
@@ -1310,3 +1365,412 @@ table tbody tr:hover {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== Slides Builder ========== */
|
||||
.slides-builder {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.slides-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.slides-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 24px;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.slides-list {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.slides-list h3 {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-slides {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-slides button {
|
||||
margin-top: 16px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slides-thumbnails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.slide-thumbnail {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 32px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.slide-thumbnail:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.slide-thumbnail.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.slide-thumbnail .slide-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slide-thumbnail .slide-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.slide-thumbnail .slide-title-preview {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.slide-thumbnail .slide-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-thumbnail:hover .slide-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide-actions button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slide-actions button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.slide-actions button.delete:hover {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Slide Editor */
|
||||
.slide-editor {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editor-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.editor-section label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.editor-section input[type="text"],
|
||||
.editor-section input[type="date"],
|
||||
.editor-section select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-section input:focus,
|
||||
.editor-section select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-type-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 16px 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chart-type-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chart-type-btn.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.chart-type-btn .chart-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.chart-type-btn span:last-child {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.slide-preview-box {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.slide-preview-box h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-chart {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.preview-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-kpi {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-kpi .kpi-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-kpi .kpi-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Preview Fullscreen */
|
||||
.preview-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-slide {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: #f8fafc;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.preview-content .preview-chart {
|
||||
height: 350px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.preview-content .preview-kpis .preview-kpi {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.preview-content .preview-kpis .kpi-value {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.preview-content .preview-kpis .kpi-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.preview-controls button {
|
||||
padding: 10px 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.preview-controls button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.preview-controls button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slides-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.slides-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-type-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.preview-slide {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
111
src/App.js
111
src/App.js
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Comparison from './components/Comparison';
|
||||
import Slides from './components/Slides';
|
||||
import { fetchData } from './services/dataService';
|
||||
import './App.css';
|
||||
|
||||
@@ -20,6 +21,13 @@ function App() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showDataLabels, setShowDataLabels] = useState(false);
|
||||
const [dataSource, setDataSource] = useState('museums');
|
||||
|
||||
const dataSources = [
|
||||
{ id: 'museums', label: 'Museums', enabled: true },
|
||||
{ id: 'coffees', label: 'Coffees', enabled: false },
|
||||
{ id: 'ecommerce', label: 'eCommerce', enabled: false }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
@@ -61,46 +69,75 @@ function App() {
|
||||
<Router>
|
||||
<div className="app">
|
||||
<nav className="nav-bar">
|
||||
<div className="nav-brand">
|
||||
<img src="/hihala-logo.svg" alt="HiHala" className="nav-logo" />
|
||||
<span>Museums Data</span>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
<div className="nav-content">
|
||||
<div className="nav-brand">
|
||||
<svg className="nav-brand-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/comparison">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<polyline points="18 14 22 10 18 6"/>
|
||||
<polyline points="6 10 2 14 6 18"/>
|
||||
</svg>
|
||||
Comparison
|
||||
</NavLink>
|
||||
<button
|
||||
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
title="Show values on charts"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
{showDataLabels ? 'Labels On' : 'Labels Off'}
|
||||
</button>
|
||||
<span className="nav-brand-text">
|
||||
HiHala Data
|
||||
<select
|
||||
className="data-source-select"
|
||||
value={dataSource}
|
||||
onChange={e => setDataSource(e.target.value)}
|
||||
>
|
||||
{dataSources.map(src => (
|
||||
<option key={src.id} value={src.id} disabled={!src.enabled}>
|
||||
{src.label}{!src.enabled ? ' (soon)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<NavLink to="/">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="5" rx="1"/>
|
||||
<rect x="14" y="12" width="7" height="9" rx="1"/>
|
||||
<rect x="3" y="16" width="7" height="5" rx="1"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/comparison">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<polyline points="18 14 22 10 18 6"/>
|
||||
<polyline points="6 10 2 14 6 18"/>
|
||||
</svg>
|
||||
Comparison
|
||||
</NavLink>
|
||||
<NavLink to="/slides">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
Slides
|
||||
</NavLink>
|
||||
<button
|
||||
className={`nav-label-toggle ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
title="Show values on charts"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
{showDataLabels ? 'Labels On' : 'Labels Off'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/comparison" element={<Comparison data={data} showDataLabels={showDataLabels} />} />
|
||||
<Route path="/slides" element={<Slides data={data} />} />
|
||||
</Routes>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
@@ -122,6 +159,14 @@ function App() {
|
||||
</svg>
|
||||
<span>Compare</span>
|
||||
</NavLink>
|
||||
<NavLink to="/slides" className="mobile-nav-item">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>Slides</span>
|
||||
</NavLink>
|
||||
<button
|
||||
className={`mobile-nav-item ${showDataLabels ? 'active' : ''}`}
|
||||
onClick={() => setShowDataLabels(!showDataLabels)}
|
||||
|
||||
@@ -41,25 +41,41 @@ const generatePresetDates = (year) => ({
|
||||
function Comparison({ data, showDataLabels }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get latest year from data for default presets
|
||||
// Get available years from data
|
||||
const latestYear = useMemo(() => getLatestYear(data), [data]);
|
||||
const presetDates = useMemo(() => generatePresetDates(latestYear), [latestYear]);
|
||||
const availableYears = useMemo(() => {
|
||||
const years = [...new Set(data.map(r => {
|
||||
const d = r.date || r.Date;
|
||||
return d ? new Date(d).getFullYear() : null;
|
||||
}).filter(Boolean))].sort((a, b) => b - a);
|
||||
return years.length ? years : [new Date().getFullYear()];
|
||||
}, [data]);
|
||||
|
||||
// Initialize state from URL or defaults
|
||||
const [selectedYear, setSelectedYearState] = useState(() => {
|
||||
const urlYear = searchParams.get('year');
|
||||
return urlYear ? parseInt(urlYear) : latestYear;
|
||||
});
|
||||
const presetDates = useMemo(() => generatePresetDates(selectedYear), [selectedYear]);
|
||||
|
||||
const [preset, setPresetState] = useState(() => searchParams.get('preset') || 'jan');
|
||||
const [startDate, setStartDateState] = useState(() => {
|
||||
const urlPreset = searchParams.get('preset');
|
||||
if (urlPreset && urlPreset !== 'custom' && presetDates[urlPreset]) {
|
||||
return presetDates[urlPreset].start;
|
||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
||||
const dates = generatePresetDates(year);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
return dates[urlPreset].start;
|
||||
}
|
||||
return searchParams.get('from') || `${latestYear}-01-01`;
|
||||
return searchParams.get('from') || `${year}-01-01`;
|
||||
});
|
||||
const [endDate, setEndDateState] = useState(() => {
|
||||
const urlPreset = searchParams.get('preset');
|
||||
if (urlPreset && urlPreset !== 'custom' && presetDates[urlPreset]) {
|
||||
return presetDates[urlPreset].end;
|
||||
const year = searchParams.get('year') ? parseInt(searchParams.get('year')) : latestYear;
|
||||
const dates = generatePresetDates(year);
|
||||
if (urlPreset && urlPreset !== 'custom' && dates[urlPreset]) {
|
||||
return dates[urlPreset].end;
|
||||
}
|
||||
return searchParams.get('to') || `${latestYear}-01-31`;
|
||||
return searchParams.get('to') || `${year}-01-31`;
|
||||
});
|
||||
const [filters, setFiltersState] = useState(() => ({
|
||||
district: searchParams.get('district') || 'all',
|
||||
@@ -72,9 +88,10 @@ function Comparison({ data, showDataLabels }) {
|
||||
const [activeCard, setActiveCard] = useState(0);
|
||||
|
||||
// Update URL with current state
|
||||
const updateUrl = useCallback((newPreset, newFrom, newTo, newFilters) => {
|
||||
const updateUrl = useCallback((newPreset, newFrom, newTo, newFilters, newYear) => {
|
||||
const params = new URLSearchParams();
|
||||
if (newPreset && newPreset !== 'jan') params.set('preset', newPreset);
|
||||
if (newYear && newYear !== latestYear) params.set('year', newYear.toString());
|
||||
if (newPreset === 'custom') {
|
||||
if (newFrom) params.set('from', newFrom);
|
||||
if (newTo) params.set('to', newTo);
|
||||
@@ -82,33 +99,43 @@ function Comparison({ data, showDataLabels }) {
|
||||
if (newFilters?.district && newFilters.district !== 'all') params.set('district', newFilters.district);
|
||||
if (newFilters?.museum && newFilters.museum !== 'all') params.set('museum', newFilters.museum);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
}, [setSearchParams, latestYear]);
|
||||
|
||||
const setSelectedYear = (year) => {
|
||||
setSelectedYearState(year);
|
||||
const newDates = generatePresetDates(year);
|
||||
if (preset !== 'custom' && newDates[preset]) {
|
||||
setStartDateState(newDates[preset].start);
|
||||
setEndDateState(newDates[preset].end);
|
||||
}
|
||||
updateUrl(preset, null, null, filters, year);
|
||||
};
|
||||
|
||||
const setPreset = (value) => {
|
||||
setPresetState(value);
|
||||
if (value !== 'custom' && presetDates[value]) {
|
||||
setStartDateState(presetDates[value].start);
|
||||
setEndDateState(presetDates[value].end);
|
||||
updateUrl(value, null, null, filters);
|
||||
updateUrl(value, null, null, filters, selectedYear);
|
||||
}
|
||||
};
|
||||
|
||||
const setStartDate = (value) => {
|
||||
setStartDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', value, endDate, filters);
|
||||
updateUrl('custom', value, endDate, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setEndDate = (value) => {
|
||||
setEndDateState(value);
|
||||
setPresetState('custom');
|
||||
updateUrl('custom', startDate, value, filters);
|
||||
updateUrl('custom', startDate, value, filters, selectedYear);
|
||||
};
|
||||
|
||||
const setFilters = (newFilters) => {
|
||||
const updated = typeof newFilters === 'function' ? newFilters(filters) : newFilters;
|
||||
setFiltersState(updated);
|
||||
updateUrl(preset, startDate, endDate, updated);
|
||||
updateUrl(preset, startDate, endDate, updated, selectedYear);
|
||||
};
|
||||
|
||||
const charts = [
|
||||
@@ -280,6 +307,24 @@ function Comparison({ data, showDataLabels }) {
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Generate period label - shows year if same year, or "MMM YY–MMM YY" if spans years
|
||||
const getPeriodLabel = useCallback((startDate, endDate) => {
|
||||
if (!startDate || !endDate) return '';
|
||||
const startYear = startDate.substring(0, 4);
|
||||
const endYear = endDate.substring(0, 4);
|
||||
|
||||
if (startYear === endYear) {
|
||||
return startYear;
|
||||
}
|
||||
|
||||
// Spans multiple years - show abbreviated range
|
||||
const [sy, sm] = startDate.split('-').map(Number);
|
||||
const [ey, em] = endDate.split('-').map(Number);
|
||||
const startMonth = new Date(sy, sm - 1, 1).toLocaleDateString('en-US', { month: 'short' });
|
||||
const endMonth = new Date(ey, em - 1, 1).toLocaleDateString('en-US', { month: 'short' });
|
||||
return `${startMonth} ${String(sy).slice(-2)}–${endMonth} ${String(ey).slice(-2)}`;
|
||||
}, []);
|
||||
|
||||
// Time series chart (daily or weekly)
|
||||
const timeSeriesChart = useMemo(() => {
|
||||
const groupByPeriod = (periodData, periodStart, metric, granularity) => {
|
||||
@@ -317,14 +362,14 @@ function Comparison({ data, showDataLabels }) {
|
||||
chartGranularity === 'week' ? `W${i + 1}` : `D${i + 1}`
|
||||
);
|
||||
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: prevYear,
|
||||
label: prevLabel,
|
||||
data: labels.map((_, i) => prevGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.muted,
|
||||
backgroundColor: 'transparent',
|
||||
@@ -334,7 +379,7 @@ function Comparison({ data, showDataLabels }) {
|
||||
pointBackgroundColor: chartColors.muted
|
||||
},
|
||||
{
|
||||
label: currYear,
|
||||
label: currLabel,
|
||||
data: labels.map((_, i) => currGrouped[i + 1] || 0),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '10',
|
||||
@@ -346,12 +391,12 @@ function Comparison({ data, showDataLabels }) {
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue]);
|
||||
}, [prevData, currData, ranges, chartMetric, chartGranularity, getMetricValue, getPeriodLabel]);
|
||||
|
||||
// Museum chart - only show museums with data
|
||||
const museumChart = useMemo(() => {
|
||||
const prevYear = ranges.prev.start.substring(0, 4);
|
||||
const currYear = ranges.curr.start.substring(0, 4);
|
||||
const prevLabel = getPeriodLabel(ranges.prev.start, ranges.prev.end);
|
||||
const currLabel = getPeriodLabel(ranges.curr.start, ranges.curr.end);
|
||||
const allMuseums = [...new Set(data.map(r => r.museum_name))].filter(Boolean);
|
||||
const prevByMuseum = {};
|
||||
const currByMuseum = {};
|
||||
@@ -366,11 +411,11 @@ function Comparison({ data, showDataLabels }) {
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [
|
||||
{ label: prevYear, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
|
||||
{ label: currYear, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
|
||||
{ label: prevLabel, data: museums.map(m => prevByMuseum[m]), backgroundColor: chartColors.muted, borderRadius: 4 },
|
||||
{ label: currLabel, data: museums.map(m => currByMuseum[m]), backgroundColor: chartColors.primary, borderRadius: 4 }
|
||||
]
|
||||
};
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue]);
|
||||
}, [data, prevData, currData, ranges, chartMetric, getMetricValue, getPeriodLabel]);
|
||||
|
||||
const baseOptions = useMemo(() => createBaseOptions(showDataLabels), [showDataLabels]);
|
||||
const chartOptions = {
|
||||
@@ -385,12 +430,12 @@ function Comparison({ data, showDataLabels }) {
|
||||
<div className="comparison">
|
||||
<div className="page-title">
|
||||
<h1>Period Comparison</h1>
|
||||
<p>Year-over-year analysis — same period, different years</p>
|
||||
<p>Select a period and year — automatically compares with the same period in the previous year</p>
|
||||
</div>
|
||||
|
||||
<FilterControls title="Select Period" onReset={resetFilters}>
|
||||
<FilterControls.Row>
|
||||
<FilterControls.Group label="Preset">
|
||||
<FilterControls.Group label="Period">
|
||||
<select value={preset} onChange={e => setPreset(e.target.value)}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="jan">January</option>
|
||||
@@ -414,6 +459,15 @@ function Comparison({ data, showDataLabels }) {
|
||||
<option value="full">Full Year</option>
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
{preset !== 'custom' && (
|
||||
<FilterControls.Group label="Year">
|
||||
<select value={selectedYear} onChange={e => setSelectedYear(parseInt(e.target.value))}>
|
||||
{availableYears.map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</FilterControls.Group>
|
||||
)}
|
||||
{preset === 'custom' && (
|
||||
<>
|
||||
<FilterControls.Group label="From">
|
||||
@@ -439,11 +493,11 @@ function Comparison({ data, showDataLabels }) {
|
||||
</FilterControls.Row>
|
||||
<div className="period-display">
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.prev.start.substring(0, 4)}</div>
|
||||
<div className="label">{getPeriodLabel(ranges.prev.start, ranges.prev.end)}</div>
|
||||
<div className="dates">{formatDate(ranges.prev.start)} → {formatDate(ranges.prev.end)}</div>
|
||||
</div>
|
||||
<div className="period-box">
|
||||
<div className="label">{ranges.curr.start.substring(0, 4)}</div>
|
||||
<div className="label">{getPeriodLabel(ranges.curr.start, ranges.curr.end)}</div>
|
||||
<div className="dates">{formatDate(ranges.curr.start)} → {formatDate(ranges.curr.end)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,8 +525,8 @@ function Comparison({ data, showDataLabels }) {
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
prevYear={getPeriodLabel(ranges.prev.start, ranges.prev.end)}
|
||||
currYear={getPeriodLabel(ranges.curr.start, ranges.curr.end)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -497,8 +551,8 @@ function Comparison({ data, showDataLabels }) {
|
||||
isCurrency={card.isCurrency}
|
||||
isPercent={card.isPercent}
|
||||
pendingMessage={card.pendingMessage}
|
||||
prevYear={ranges.prev.start.substring(0, 4)}
|
||||
currYear={ranges.curr.start.substring(0, 4)}
|
||||
prevYear={getPeriodLabel(ranges.prev.start, ranges.prev.end)}
|
||||
currYear={getPeriodLabel(ranges.curr.start, ranges.curr.end)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
594
src/components/Slides.js
Normal file
594
src/components/Slides.js
Normal file
@@ -0,0 +1,594 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Line, Bar } from 'react-chartjs-2';
|
||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
||||
import {
|
||||
filterDataByDateRange,
|
||||
calculateMetrics,
|
||||
formatCompact,
|
||||
formatCompactCurrency,
|
||||
getUniqueDistricts,
|
||||
getDistrictMuseumMap,
|
||||
getMuseumsForDistrict
|
||||
} from '../services/dataService';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
const CHART_TYPES = [
|
||||
{ id: 'trend', label: 'Revenue Trend', icon: '📈' },
|
||||
{ id: 'museum-bar', label: 'By Museum', icon: '📊' },
|
||||
{ id: 'kpi-cards', label: 'KPI Summary', icon: '🎯' },
|
||||
{ id: 'comparison', label: 'YoY Comparison', icon: '⚖️' }
|
||||
];
|
||||
|
||||
const METRICS = [
|
||||
{ id: 'revenue', label: 'Revenue', field: 'revenue_incl_tax' },
|
||||
{ id: 'visitors', label: 'Visitors', field: 'visits' },
|
||||
{ id: 'tickets', label: 'Tickets', field: 'tickets' }
|
||||
];
|
||||
|
||||
function Slides({ data }) {
|
||||
const [slides, setSlides] = useState([]);
|
||||
const [editingSlide, setEditingSlide] = useState(null);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [currentPreviewSlide, setCurrentPreviewSlide] = useState(0);
|
||||
|
||||
const districts = useMemo(() => getUniqueDistricts(data), [data]);
|
||||
const districtMuseumMap = useMemo(() => getDistrictMuseumMap(data), [data]);
|
||||
|
||||
const defaultSlideConfig = {
|
||||
title: 'Slide Title',
|
||||
chartType: 'trend',
|
||||
metric: 'revenue',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-31',
|
||||
district: 'all',
|
||||
museum: 'all',
|
||||
showComparison: false
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
const newSlide = {
|
||||
id: Date.now(),
|
||||
...defaultSlideConfig,
|
||||
title: `Slide ${slides.length + 1}`
|
||||
};
|
||||
setSlides([...slides, newSlide]);
|
||||
setEditingSlide(newSlide.id);
|
||||
};
|
||||
|
||||
const updateSlide = (id, updates) => {
|
||||
setSlides(slides.map(s => s.id === id ? { ...s, ...updates } : s));
|
||||
};
|
||||
|
||||
const removeSlide = (id) => {
|
||||
setSlides(slides.filter(s => s.id !== id));
|
||||
if (editingSlide === id) setEditingSlide(null);
|
||||
};
|
||||
|
||||
const moveSlide = (id, direction) => {
|
||||
const index = slides.findIndex(s => s.id === id);
|
||||
if ((direction === -1 && index === 0) || (direction === 1 && index === slides.length - 1)) return;
|
||||
const newSlides = [...slides];
|
||||
[newSlides[index], newSlides[index + direction]] = [newSlides[index + direction], newSlides[index]];
|
||||
setSlides(newSlides);
|
||||
};
|
||||
|
||||
const duplicateSlide = (id) => {
|
||||
const slide = slides.find(s => s.id === id);
|
||||
if (slide) {
|
||||
const newSlide = { ...slide, id: Date.now(), title: `${slide.title} (copy)` };
|
||||
const index = slides.findIndex(s => s.id === id);
|
||||
const newSlides = [...slides];
|
||||
newSlides.splice(index + 1, 0, newSlide);
|
||||
setSlides(newSlides);
|
||||
}
|
||||
};
|
||||
|
||||
const exportAsHTML = async () => {
|
||||
const zip = new JSZip();
|
||||
|
||||
// Generate HTML for each slide
|
||||
const slidesHTML = slides.map((slide, index) => {
|
||||
return generateSlideHTML(slide, index, data, districts, districtMuseumMap);
|
||||
}).join('\n');
|
||||
|
||||
const fullHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HiHala Data Presentation</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f172a; }
|
||||
.slide {
|
||||
width: 100vw; height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
justify-content: center; align-items: center;
|
||||
padding: 60px; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
page-break-after: always;
|
||||
}
|
||||
.slide-title {
|
||||
color: #f8fafc; font-size: 2.5rem; font-weight: 600;
|
||||
margin-bottom: 40px; text-align: center;
|
||||
}
|
||||
.slide-subtitle {
|
||||
color: #94a3b8; font-size: 1.1rem; margin-bottom: 30px;
|
||||
}
|
||||
.chart-container {
|
||||
width: 100%; max-width: 900px; height: 400px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 16px;
|
||||
padding: 30px;
|
||||
}
|
||||
.kpi-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 30px; width: 100%; max-width: 900px;
|
||||
}
|
||||
.kpi-card {
|
||||
background: rgba(255,255,255,0.05); border-radius: 16px;
|
||||
padding: 30px; text-align: center;
|
||||
}
|
||||
.kpi-value { color: #3b82f6; font-size: 2.5rem; font-weight: 700; }
|
||||
.kpi-label { color: #94a3b8; font-size: 1rem; margin-top: 8px; }
|
||||
.logo { position: absolute; bottom: 30px; right: 40px; opacity: 0.6; }
|
||||
.logo svg { height: 30px; }
|
||||
.slide-number {
|
||||
position: absolute; bottom: 30px; left: 40px;
|
||||
color: #475569; font-size: 0.9rem;
|
||||
}
|
||||
@media print {
|
||||
.slide { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${slidesHTML}
|
||||
<script>
|
||||
// Chart.js initialization scripts will be here
|
||||
${generateChartScripts(slides, data, districts, districtMuseumMap)}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
zip.file('presentation.html', fullHTML);
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'hihala-presentation.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (previewMode) {
|
||||
return (
|
||||
<PreviewMode
|
||||
slides={slides}
|
||||
data={data}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
currentSlide={currentPreviewSlide}
|
||||
setCurrentSlide={setCurrentPreviewSlide}
|
||||
onExit={() => setPreviewMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="slides-builder">
|
||||
<div className="page-title">
|
||||
<h1>Presentation Builder</h1>
|
||||
<p>Create slides with charts and export as HTML or PDF</p>
|
||||
</div>
|
||||
|
||||
<div className="slides-toolbar">
|
||||
<button className="btn-primary" onClick={addSlide}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Slide
|
||||
</button>
|
||||
{slides.length > 0 && (
|
||||
<>
|
||||
<button className="btn-secondary" onClick={() => setPreviewMode(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={exportAsHTML}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Export HTML
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="slides-workspace">
|
||||
<div className="slides-list">
|
||||
<h3>Slides ({slides.length})</h3>
|
||||
{slides.length === 0 ? (
|
||||
<div className="empty-slides">
|
||||
<p>No slides yet</p>
|
||||
<button onClick={addSlide}>Add your first slide</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="slides-thumbnails">
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={`slide-thumbnail ${editingSlide === slide.id ? 'active' : ''}`}
|
||||
onClick={() => setEditingSlide(slide.id)}
|
||||
>
|
||||
<div className="slide-number">{index + 1}</div>
|
||||
<div className="slide-icon">{CHART_TYPES.find(c => c.id === slide.chartType)?.icon}</div>
|
||||
<div className="slide-title-preview">{slide.title}</div>
|
||||
<div className="slide-actions">
|
||||
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, -1); }} disabled={index === 0}>↑</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); moveSlide(slide.id, 1); }} disabled={index === slides.length - 1}>↓</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); duplicateSlide(slide.id); }}>⎘</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); removeSlide(slide.id); }} className="delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingSlide && (
|
||||
<SlideEditor
|
||||
slide={slides.find(s => s.id === editingSlide)}
|
||||
onUpdate={(updates) => updateSlide(editingSlide, updates)}
|
||||
districts={districts}
|
||||
districtMuseumMap={districtMuseumMap}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlideEditor({ slide, onUpdate, districts, districtMuseumMap, data }) {
|
||||
const availableMuseums = useMemo(() =>
|
||||
getMuseumsForDistrict(districtMuseumMap, slide.district),
|
||||
[districtMuseumMap, slide.district]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="slide-editor">
|
||||
<div className="editor-section">
|
||||
<label>Slide Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.title}
|
||||
onChange={e => onUpdate({ title: e.target.value })}
|
||||
placeholder="Enter slide title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<label>Chart Type</label>
|
||||
<div className="chart-type-grid">
|
||||
{CHART_TYPES.map(type => (
|
||||
<button
|
||||
key={type.id}
|
||||
className={`chart-type-btn ${slide.chartType === type.id ? 'active' : ''}`}
|
||||
onClick={() => onUpdate({ chartType: type.id })}
|
||||
>
|
||||
<span className="chart-icon">{type.icon}</span>
|
||||
<span>{type.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-section">
|
||||
<label>Metric</label>
|
||||
<select value={slide.metric} onChange={e => onUpdate({ metric: e.target.value })}>
|
||||
{METRICS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>Start Date</label>
|
||||
<input type="date" value={slide.startDate} onChange={e => onUpdate({ startDate: e.target.value })} />
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>End Date</label>
|
||||
<input type="date" value={slide.endDate} onChange={e => onUpdate({ endDate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-row">
|
||||
<div className="editor-section">
|
||||
<label>District</label>
|
||||
<select value={slide.district} onChange={e => onUpdate({ district: e.target.value, museum: 'all' })}>
|
||||
<option value="all">All Districts</option>
|
||||
{districts.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="editor-section">
|
||||
<label>Museum</label>
|
||||
<select value={slide.museum} onChange={e => onUpdate({ museum: e.target.value })}>
|
||||
<option value="all">All Museums</option>
|
||||
{availableMuseums.map(m => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slide.chartType === 'comparison' && (
|
||||
<div className="editor-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={slide.showComparison}
|
||||
onChange={e => onUpdate({ showComparison: e.target.checked })}
|
||||
/>
|
||||
Show Year-over-Year Comparison
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="slide-preview-box">
|
||||
<h4>Preview</h4>
|
||||
<SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlidePreview({ slide, data, districts, districtMuseumMap }) {
|
||||
const filteredData = useMemo(() =>
|
||||
filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
}),
|
||||
[data, slide.startDate, slide.endDate, slide.district, slide.museum]
|
||||
);
|
||||
|
||||
const metrics = useMemo(() => calculateMetrics(filteredData), [filteredData]);
|
||||
const baseOptions = useMemo(() => createBaseOptions(false), []);
|
||||
|
||||
const getMetricValue = useCallback((rows, metric) => {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
return rows.reduce((s, r) => s + parseFloat(r[fieldMap[metric]] || 0), 0);
|
||||
}, []);
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const grouped = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.date) return;
|
||||
const weekStart = row.date.substring(0, 10);
|
||||
if (!grouped[weekStart]) grouped[weekStart] = [];
|
||||
grouped[weekStart].push(row);
|
||||
});
|
||||
|
||||
const sortedDates = Object.keys(grouped).sort();
|
||||
return {
|
||||
labels: sortedDates.map(d => d.substring(5)),
|
||||
datasets: [{
|
||||
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||
data: sortedDates.map(d => getMetricValue(grouped[d], slide.metric)),
|
||||
borderColor: chartColors.primary,
|
||||
backgroundColor: chartColors.primary + '20',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
};
|
||||
}, [filteredData, slide.metric, getMetricValue]);
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
const byMuseum = {};
|
||||
filteredData.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
if (!byMuseum[row.museum_name]) byMuseum[row.museum_name] = [];
|
||||
byMuseum[row.museum_name].push(row);
|
||||
});
|
||||
|
||||
const museums = Object.keys(byMuseum).sort();
|
||||
return {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
label: METRICS.find(m => m.id === slide.metric)?.label,
|
||||
data: museums.map(m => getMetricValue(byMuseum[m], slide.metric)),
|
||||
backgroundColor: chartColors.primary,
|
||||
borderRadius: 6
|
||||
}]
|
||||
};
|
||||
}, [filteredData, slide.metric, getMetricValue]);
|
||||
|
||||
if (slide.chartType === 'kpi-cards') {
|
||||
return (
|
||||
<div className="preview-kpis">
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompactCurrency(metrics.revenue)}</div>
|
||||
<div className="kpi-label">Revenue</div>
|
||||
</div>
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompact(metrics.visitors)}</div>
|
||||
<div className="kpi-label">Visitors</div>
|
||||
</div>
|
||||
<div className="preview-kpi">
|
||||
<div className="kpi-value">{formatCompact(metrics.tickets)}</div>
|
||||
<div className="kpi-label">Tickets</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (slide.chartType === 'museum-bar') {
|
||||
return (
|
||||
<div className="preview-chart">
|
||||
<Bar data={museumData} options={{ ...baseOptions, indexAxis: 'y' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="preview-chart">
|
||||
<Line data={trendData} options={baseOptions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewMode({ slides, data, districts, districtMuseumMap, currentSlide, setCurrentSlide, onExit }) {
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
setCurrentSlide(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
onExit();
|
||||
}
|
||||
}, [slides.length, setCurrentSlide, onExit]);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const slide = slides[currentSlide];
|
||||
|
||||
return (
|
||||
<div className="preview-fullscreen">
|
||||
<div className="preview-slide">
|
||||
<h1 className="preview-title">{slide?.title}</h1>
|
||||
<div className="preview-content">
|
||||
{slide && <SlidePreview slide={slide} data={data} districts={districts} districtMuseumMap={districtMuseumMap} />}
|
||||
</div>
|
||||
<div className="preview-footer">
|
||||
<span>{currentSlide + 1} / {slides.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-controls">
|
||||
<button onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))} disabled={currentSlide === 0}>←</button>
|
||||
<button onClick={() => setCurrentSlide(prev => Math.min(prev + 1, slides.length - 1))} disabled={currentSlide === slides.length - 1}>→</button>
|
||||
<button onClick={onExit}>Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for HTML export
|
||||
function generateSlideHTML(slide, index, data, districts, districtMuseumMap) {
|
||||
const chartType = slide.chartType;
|
||||
const canvasId = `chart-${index}`;
|
||||
|
||||
return `
|
||||
<div class="slide" id="slide-${index}">
|
||||
<h1 class="slide-title">${slide.title}</h1>
|
||||
<p class="slide-subtitle">${formatDateRange(slide.startDate, slide.endDate)}</p>
|
||||
${chartType === 'kpi-cards' ? generateKPIHTML(slide, data) : `<div class="chart-container"><canvas id="${canvasId}"></canvas></div>`}
|
||||
<div class="slide-number">Slide ${index + 1}</div>
|
||||
<div class="logo">
|
||||
<svg width="120" height="24" viewBox="0 0 120 24">
|
||||
<text x="0" y="18" fill="#64748b" font-family="system-ui" font-size="14" font-weight="600">HiHala Data</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateKPIHTML(slide, data) {
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
});
|
||||
const metrics = calculateMetrics(filtered);
|
||||
|
||||
return `
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">${formatCompactCurrency(metrics.revenue)}</div>
|
||||
<div class="kpi-label">Revenue</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">${formatCompact(metrics.visitors)}</div>
|
||||
<div class="kpi-label">Visitors</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value">${formatCompact(metrics.tickets)}</div>
|
||||
<div class="kpi-label">Tickets</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function generateChartScripts(slides, data, districts, districtMuseumMap) {
|
||||
return slides.map((slide, index) => {
|
||||
if (slide.chartType === 'kpi-cards') return '';
|
||||
|
||||
const filtered = filterDataByDateRange(data, slide.startDate, slide.endDate, {
|
||||
district: slide.district,
|
||||
museum: slide.museum
|
||||
});
|
||||
|
||||
const chartConfig = generateChartConfig(slide, filtered);
|
||||
|
||||
return `
|
||||
new Chart(document.getElementById('chart-${index}'), ${JSON.stringify(chartConfig)});
|
||||
`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function generateChartConfig(slide, data) {
|
||||
const fieldMap = { revenue: 'revenue_incl_tax', visitors: 'visits', tickets: 'tickets' };
|
||||
const field = fieldMap[slide.metric];
|
||||
|
||||
if (slide.chartType === 'museum-bar') {
|
||||
const byMuseum = {};
|
||||
data.forEach(row => {
|
||||
if (!row.museum_name) return;
|
||||
byMuseum[row.museum_name] = (byMuseum[row.museum_name] || 0) + parseFloat(row[field] || 0);
|
||||
});
|
||||
const museums = Object.keys(byMuseum).sort();
|
||||
|
||||
return {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: museums,
|
||||
datasets: [{
|
||||
data: museums.map(m => byMuseum[m]),
|
||||
backgroundColor: '#3b82f6',
|
||||
borderRadius: 6
|
||||
}]
|
||||
},
|
||||
options: { indexAxis: 'y', plugins: { legend: { display: false } } }
|
||||
};
|
||||
}
|
||||
|
||||
// Default: trend line
|
||||
const grouped = {};
|
||||
data.forEach(row => {
|
||||
if (!row.date) return;
|
||||
grouped[row.date] = (grouped[row.date] || 0) + parseFloat(row[field] || 0);
|
||||
});
|
||||
const dates = Object.keys(grouped).sort();
|
||||
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates.map(d => d.substring(5)),
|
||||
datasets: [{
|
||||
data: dates.map(d => grouped[d]),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59,130,246,0.1)',
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } } }
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
const opts = { month: 'short', day: 'numeric', year: 'numeric' };
|
||||
return `${s.toLocaleDateString('en-US', opts)} – ${e.toLocaleDateString('en-US', opts)}`;
|
||||
}
|
||||
|
||||
export default Slides;
|
||||
Reference in New Issue
Block a user