Compare commits
10 Commits
648365348f
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dd512444fb | |||
| 4f51280d1c | |||
| 89689c5979 | |||
| 49bda53598 | |||
| 2888936d54 | |||
| 131868a280 | |||
| 7365bc808b | |||
| 26bb69c76c | |||
| 1070490ad2 | |||
| c858075232 |
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+262
-149
@@ -2859,7 +2859,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.report-page {
|
.report-page {
|
||||||
max-width: 1400px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 32px 24px 100px;
|
padding: 32px 24px 100px;
|
||||||
}
|
}
|
||||||
@@ -2883,12 +2883,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-body {
|
.report-body {}
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 420px 1fr;
|
|
||||||
gap: 32px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-form-col {
|
.report-form-col {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -2897,13 +2892,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-preview-col {}
|
|
||||||
|
|
||||||
.report-preview-sticky {
|
|
||||||
position: sticky;
|
|
||||||
top: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generate button bar */
|
/* Generate button bar */
|
||||||
.report-footer-bar {
|
.report-footer-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2915,7 +2903,8 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
@@ -2944,27 +2933,23 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
|
|
||||||
/* ── Report Form ── */
|
/* ── Report Form ── */
|
||||||
.report-form {
|
.report-form {
|
||||||
padding: 20px;
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-col {
|
||||||
|
padding: 20px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rf-section-title {
|
.rf-col + .rf-col {
|
||||||
font-size: 0.6875rem;
|
border-left: 1px solid var(--border);
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-muted);
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rf-section-title:first-child {
|
|
||||||
border-top: none;
|
|
||||||
margin-top: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rf-field {
|
.rf-field {
|
||||||
@@ -2990,7 +2975,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.1); }
|
.rf-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||||
|
|
||||||
.rf-date-row {
|
.rf-date-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2998,29 +2983,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rf-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rf-toggle-opt {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rf-toggle-opt--on {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--text-inverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rf-check-row {
|
.rf-check-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3059,6 +3021,54 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
font-family: var(--alt-mono-font, monospace);
|
font-family: var(--alt-mono-font, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rf-orient-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-btn--on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-page {
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-page--portrait {
|
||||||
|
width: 18px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-orient-page--landscape {
|
||||||
|
width: 26px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.rf-logo-row {
|
.rf-logo-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3102,121 +3112,224 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Report Preview ── */
|
/* ── Group labels & dividers ── */
|
||||||
.report-preview {
|
.rf-group-label {
|
||||||
display: flex;
|
font-size: 0.6875rem;
|
||||||
flex-direction: column;
|
font-weight: 700;
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-preview-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-page {
|
.rf-divider {
|
||||||
background: white;
|
height: 1px;
|
||||||
border: 1px solid var(--border);
|
background: var(--border);
|
||||||
border-radius: 6px;
|
margin: 2px 0;
|
||||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,.08));
|
}
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 210 / 297;
|
/* ── Branding row (accent color + logo side by side) ── */
|
||||||
|
.rf-branding-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-branding-row .rf-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Comparison block ── */
|
||||||
|
.rf-comparison-block {
|
||||||
|
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
font-size: 7px;
|
gap: 10px;
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-cover-top {
|
.rf-comparison-label {
|
||||||
display: flex;
|
font-size: 0.6875rem;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-brand { font-weight: 700; color: #2563eb; font-size: 8px; }
|
|
||||||
.rp-brand-small { font-weight: 700; font-size: 6px; }
|
|
||||||
|
|
||||||
.rp-client-logo {
|
|
||||||
height: 20px;
|
|
||||||
max-width: 50px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-cover-body {
|
|
||||||
padding: 24px 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-cover-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 8px;
|
text-transform: uppercase;
|
||||||
color: #0f172a;
|
letter-spacing: 0.07em;
|
||||||
line-height: 1.3;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-cover-for, .rp-cover-contact, .rp-cover-period {
|
/* ── Module cards ── */
|
||||||
font-size: 7px;
|
.rf-module {
|
||||||
color: #64748b;
|
border: 1px solid var(--border);
|
||||||
margin-bottom: 3px;
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-cover-bar { height: 4px; margin-top: auto; width: calc(100% + 32px); margin-left: -16px; }
|
.rf-module--on {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
.rp-placeholder-text { color: #cbd5e1; font-style: italic; }
|
|
||||||
|
|
||||||
.rp-page--content { padding: 12px 16px; }
|
|
||||||
|
|
||||||
.rp-page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-page-title-small, .rp-page-num { font-size: 5px; color: #94a3b8; }
|
.rf-module-header {
|
||||||
|
|
||||||
.rp-section { margin-bottom: 10px; }
|
|
||||||
|
|
||||||
.rp-section-heading {
|
|
||||||
color: white;
|
|
||||||
font-size: 6px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rp-placeholder-lines { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.rp-ph-line { height: 4px; background: #e2e8f0; border-radius: 2px; }
|
|
||||||
|
|
||||||
.rp-ph-table { display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
|
|
||||||
.rp-ph-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 10px;
|
||||||
padding: 2px 0;
|
padding: 9px 12px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-ph-row-label { font-size: 5.5px; color: #334155; flex: 1.5; }
|
.rf-module-header:hover {
|
||||||
.rp-ph-row-val { flex: 1; height: 4px; background: #e2e8f0; border-radius: 2px; }
|
background: var(--hover);
|
||||||
.rp-ph-row-val--sm { flex: 0.8; }
|
}
|
||||||
|
|
||||||
.rp-ph-chart { height: 40px; background: #f8fafc; border-radius: 3px; border: 1px solid #e2e8f0; }
|
.rf-module-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
.rf-module-badge {
|
||||||
.report-body { grid-template-columns: 1fr; }
|
font-size: 0.6875rem;
|
||||||
.report-preview-sticky { position: static; }
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-module-badge--on {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-module-body {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-module-note {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metric pill toggles ── */
|
||||||
|
.rf-metric-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-metric-pill {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-metric-pill--on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-metric-pill:hover:not(.rf-metric-pill--on) {
|
||||||
|
border-color: var(--text-muted);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer meta strip ── */
|
||||||
|
.report-footer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer-chip {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer-chip--compare {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer-dot {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
opacity: 0.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H2: focus-visible on all custom interactive elements */
|
||||||
|
.rf-metric-pill:focus-visible,
|
||||||
|
.rf-orient-btn:focus-visible,
|
||||||
|
.rf-upload-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rf-remove-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H3: H2 elements used as group labels reset browser heading defaults */
|
||||||
|
h2.rf-group-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H3: H2 badge-on buttons inside module cards (badge is aria-hidden, no h2 styling needed there) */
|
||||||
|
|
||||||
|
/* H3: rf-remove-btn touch target — min 36×36 */
|
||||||
|
.rf-remove-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* C2: inline error messages */
|
||||||
|
.rf-field-error {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--danger);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-footer-error {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.rf-two-col { grid-template-columns: 1fr; }
|
||||||
|
.rf-col + .rf-col { border-left: none; border-top: 1px solid var(--border); }
|
||||||
.report-page { padding: 20px 16px 90px; }
|
.report-page { padding: 20px 16px 90px; }
|
||||||
|
/* H5: show only section count in footer on mobile, hide details */
|
||||||
|
.report-footer-chip:not(.report-footer-chip--count),
|
||||||
|
.report-footer-dot { display: none; }
|
||||||
|
/* H4: larger touch targets for metric pills on mobile */
|
||||||
|
.rf-metric-pill { padding: 8px 14px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|||||||
+11
-3
@@ -7,7 +7,7 @@ const Dashboard = lazy(() => import('./components/Dashboard'));
|
|||||||
const Report = lazy(() => import('./components/Report'));
|
const Report = lazy(() => import('./components/Report'));
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
import LoadingSkeleton from './components/shared/LoadingSkeleton';
|
||||||
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels } from './services/dataService';
|
import { fetchData, getCacheStatus, refreshData, getUniqueMuseums, getUniqueChannels, fetchMuseumTranslations } from './services/dataService';
|
||||||
import { fetchSeasons } from './services/seasonsService';
|
import { fetchSeasons } from './services/seasonsService';
|
||||||
import { parseAllowed } from './services/usersService';
|
import { parseAllowed } from './services/usersService';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
@@ -59,6 +59,7 @@ function App() {
|
|||||||
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
const [includeVAT, setIncludeVAT] = useState<boolean>(true);
|
||||||
const [dataSource, setDataSource] = useState<string>('museums');
|
const [dataSource, setDataSource] = useState<string>('museums');
|
||||||
const [seasons, setSeasons] = useState<Season[]>([]);
|
const [seasons, setSeasons] = useState<Season[]>([]);
|
||||||
|
const [museumTranslations, setMuseumTranslations] = useState<Record<string, string>>({});
|
||||||
const [theme, setTheme] = useState<string>(() => {
|
const [theme, setTheme] = useState<string>(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('hihala_theme') || 'light';
|
return localStorage.getItem('hihala_theme') || 'light';
|
||||||
@@ -118,6 +119,11 @@ function App() {
|
|||||||
setSeasons(s);
|
setSeasons(s);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMuseumTranslations = useCallback(async () => {
|
||||||
|
const t = await fetchMuseumTranslations();
|
||||||
|
setMuseumTranslations(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check auth on mount
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/auth/check', { credentials: 'include' })
|
fetch('/auth/check', { credentials: 'include' })
|
||||||
@@ -131,6 +137,7 @@ function App() {
|
|||||||
setAllowedChannels(parseAllowed(d.allowedChannels));
|
setAllowedChannels(parseAllowed(d.allowedChannels));
|
||||||
loadData();
|
loadData();
|
||||||
loadSeasons();
|
loadSeasons();
|
||||||
|
loadMuseumTranslations();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setAuthenticated(false));
|
.catch(() => setAuthenticated(false));
|
||||||
@@ -145,6 +152,7 @@ function App() {
|
|||||||
setAllowedChannels(parseAllowed(rawChannels));
|
setAllowedChannels(parseAllowed(rawChannels));
|
||||||
loadData();
|
loadData();
|
||||||
loadSeasons();
|
loadSeasons();
|
||||||
|
loadMuseumTranslations();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
@@ -327,8 +335,8 @@ function App() {
|
|||||||
<main>
|
<main>
|
||||||
<Suspense fallback={<LoadingSkeleton />}>
|
<Suspense fallback={<LoadingSkeleton />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/" element={<Dashboard data={data} seasons={seasons} includeVAT={includeVAT} setIncludeVAT={setIncludeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
|
||||||
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} />} />
|
<Route path="/comparison" element={<Comparison data={data} seasons={seasons} includeVAT={includeVAT} allowedMuseums={allowedMuseums} allowedChannels={allowedChannels} museumTranslations={museumTranslations} />} />
|
||||||
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
{userRole === 'admin' && <Route path="/settings" element={<Settings onSeasonsChange={loadSeasons} allMuseums={allMuseumsList} allChannels={allChannelsList} />} />}
|
||||||
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
{userRole === 'admin' && <Route path="/report" element={<Report data={data} />} />}
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
getUniqueChannels, getUniqueMuseums, getUniqueDistricts,
|
||||||
umrahData
|
umrahData
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import type { LC } from '../lib/locale';
|
import type { LC } from '../lib/locale';
|
||||||
@@ -22,6 +22,7 @@ interface Props {
|
|||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
allowedMuseums: string[] | null;
|
allowedMuseums: string[] | null;
|
||||||
allowedChannels: string[] | null;
|
allowedChannels: string[] | null;
|
||||||
|
museumTranslations?: Record<string, string>;
|
||||||
lang?: 'en' | 'ar';
|
lang?: 'en' | 'ar';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +66,9 @@ function PeriodCard({ role, hint, start, end, variant, onChange, availableYears,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── main page ────────────────────────────────────────────────────
|
// ─── main page ────────────────────────────────────────────────────
|
||||||
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels }: Props) {
|
export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
|
||||||
const { lang: activeLang, setLanguage } = useLanguage();
|
const { lang: activeLang, setLanguage } = useLanguage();
|
||||||
|
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
|
||||||
const L = activeLang === 'ar' ? AR : EN;
|
const L = activeLang === 'ar' ? AR : EN;
|
||||||
const curr = currentMonth();
|
const curr = currentMonth();
|
||||||
const [currStart, setCurrStart] = useState(curr.start);
|
const [currStart, setCurrStart] = useState(curr.start);
|
||||||
@@ -78,6 +80,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||||
const [metric, setMetric] = useState('revenue');
|
const [metric, setMetric] = useState('revenue');
|
||||||
const [gran, setGran] = useState('week');
|
const [gran, setGran] = useState('week');
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
|
|
||||||
const perm = useMemo(() => {
|
const perm = useMemo(() => {
|
||||||
if (!allowedMuseums || !allowedChannels) return [];
|
if (!allowedMuseums || !allowedChannels) return [];
|
||||||
@@ -128,7 +131,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -142,17 +145,56 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const cs0 = new Date(currStart);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(cs0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
|
const museumList = (selMuseums.length > 0 ? selMuseums : museums)
|
||||||
|
.filter(museum => currData.some(r => r.museum_name === museum));
|
||||||
|
const multiMuseum = museumList.length >= 2;
|
||||||
|
const museumDatasets = museumList.map((museum, idx) => {
|
||||||
|
const mg = group(currData.filter(r => r.museum_name === museum), currStart);
|
||||||
return {
|
return {
|
||||||
|
label: tr(museum),
|
||||||
|
data: labels.map((_,i) => mg[i+1]||0),
|
||||||
|
borderColor: chartPalette[idx % chartPalette.length],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: gran==='week' ? 3 : 1,
|
||||||
|
pointBackgroundColor: chartPalette[idx % chartPalette.length],
|
||||||
|
_isMuseumLine: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tooltipLabels,
|
||||||
|
multiMuseum,
|
||||||
|
data: {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
||||||
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
|
...museumDatasets,
|
||||||
|
{ label: multiMuseum ? `Total · ${periodLabel(currStart,currEnd)}` : periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'15', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?4:2, pointBackgroundColor:TOTAL_COLOR },
|
||||||
]
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L, selMuseums, museums]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||||
@@ -160,19 +202,58 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); });
|
all.forEach(m => { pb[m]=getVal(prevData.filter(r => r.museum_name===m), metric); cb[m]=getVal(currData.filter(r => r.museum_name===m), metric); });
|
||||||
const active = all.filter(m => pb[m]>0 || cb[m]>0);
|
const active = all.filter(m => pb[m]>0 || cb[m]>0);
|
||||||
return {
|
return {
|
||||||
labels: active,
|
labels: active.map(tr),
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 },
|
{ label:periodLabel(prevStart,prevEnd), data:active.map(m => pb[m]), backgroundColor:chartColors.muted+'cc', borderRadius:4 },
|
||||||
{ label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
|
{ label:periodLabel(currStart,currEnd), data:active.map(m => cb[m]), backgroundColor:chartColors.primary, borderRadius:4 },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal]);
|
}, [data, prevData, currData, prevStart, prevEnd, currStart, currEnd, metric, getVal, activeLang, museumTranslations]);
|
||||||
|
|
||||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||||
const { chartOpts } = useMemo(() => {
|
const { chartOpts } = useMemo(() => {
|
||||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
||||||
return { chartOpts };
|
return { chartOpts };
|
||||||
}, [baseOpts]);
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: {
|
||||||
|
padding: 14,
|
||||||
|
font: { size: 11, weight: 'bold' as const },
|
||||||
|
usePointStyle: true,
|
||||||
|
generateLabels: (chart: any) =>
|
||||||
|
chart.data.datasets.map((ds: any, i: number) => {
|
||||||
|
const color: string = ds.borderColor || '#64748b';
|
||||||
|
const pill = document.createElement('canvas');
|
||||||
|
pill.width = 10; pill.height = 10;
|
||||||
|
const pCtx = pill.getContext('2d');
|
||||||
|
if (pCtx) {
|
||||||
|
pCtx.strokeStyle = color;
|
||||||
|
pCtx.lineWidth = 1;
|
||||||
|
pCtx.beginPath();
|
||||||
|
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
|
||||||
|
pCtx.stroke();
|
||||||
|
}
|
||||||
|
return { text: ds.label, fillStyle: color, strokeStyle: color,
|
||||||
|
fontColor: color, lineWidth: 0, pointStyle: pill,
|
||||||
|
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const metricOpts = [
|
const metricOpts = [
|
||||||
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
||||||
@@ -247,7 +328,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
<div className="alt-filter-sep" />
|
<div className="alt-filter-sep" />
|
||||||
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selDistricts} options={districts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||||
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selChannels} options={channels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||||
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selMuseums} options={museums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
|
||||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,9 +353,11 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
|
<div className="alt-ctrl-sep" />
|
||||||
|
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
groupByMuseum, groupByChannel, groupByDistrict,
|
groupByMuseum, groupByChannel, groupByDistrict,
|
||||||
umrahData,
|
umrahData,
|
||||||
} from '../services/dataService';
|
} from '../services/dataService';
|
||||||
import { chartColors, chartPalette, createBaseOptions } from '../config/chartConfig';
|
import { chartColors, chartPalette, createBaseOptions, TOTAL_COLOR } from '../config/chartConfig';
|
||||||
import type { MuseumRecord, Season } from '../types';
|
import type { MuseumRecord, Season } from '../types';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { EN, AR } from '../lib/locale';
|
import { EN, AR } from '../lib/locale';
|
||||||
@@ -22,12 +22,14 @@ interface Props {
|
|||||||
setIncludeVAT: (v: boolean) => void;
|
setIncludeVAT: (v: boolean) => void;
|
||||||
allowedMuseums: string[] | null;
|
allowedMuseums: string[] | null;
|
||||||
allowedChannels: string[] | null;
|
allowedChannels: string[] | null;
|
||||||
|
museumTranslations?: Record<string, string>;
|
||||||
lang?: 'en' | 'ar';
|
lang?: 'en' | 'ar';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── main page ────────────────────────────────────────────────────
|
// ─── main page ────────────────────────────────────────────────────
|
||||||
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels }: Props) {
|
export default function DashboardDemo({ data, seasons: _seasons, includeVAT, setIncludeVAT, allowedMuseums, allowedChannels, museumTranslations = {} }: Props) {
|
||||||
const { lang: activeLang, setLanguage } = useLanguage();
|
const { lang: activeLang, setLanguage } = useLanguage();
|
||||||
|
const tr = (name: string) => (activeLang === 'ar' && museumTranslations[name]) ? museumTranslations[name] : name;
|
||||||
const L = activeLang === 'ar' ? AR : EN;
|
const L = activeLang === 'ar' ? AR : EN;
|
||||||
const curr = currentMonth();
|
const curr = currentMonth();
|
||||||
const [start, setStart] = useState(curr.start);
|
const [start, setStart] = useState(curr.start);
|
||||||
@@ -37,6 +39,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
const [selMuseums, setSelMuseums] = useState<string[]>([]);
|
||||||
const [metric, setMetric] = useState('revenue');
|
const [metric, setMetric] = useState('revenue');
|
||||||
const [gran, setGran] = useState('week');
|
const [gran, setGran] = useState('week');
|
||||||
|
const [showLabels, setShowLabels] = useState(false);
|
||||||
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
const [museumChartType, setMuseumChartType] = useState<'bar'|'pie'>('bar');
|
||||||
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
const [channelChartType, setChannelChartType] = useState<'bar'|'pie'>('pie');
|
||||||
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
const [districtChartType, setDistrictChartType] = useState<'bar'|'pie'>('pie');
|
||||||
@@ -88,7 +91,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
||||||
}, [revenueField]);
|
}, [revenueField]);
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -102,25 +105,64 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const s0 = new Date(start);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(s0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(s0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(s0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(s0.getFullYear(), s0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(s0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
const prevYear = parseInt(start.slice(0,4))-1;
|
const prevYear = parseInt(start.slice(0,4))-1;
|
||||||
|
const museumList = (selMuseums.length > 0 ? selMuseums : allMuseums)
|
||||||
|
.filter(museum => filteredData.some(r => r.museum_name === museum));
|
||||||
|
const multiMuseum = museumList.length >= 2;
|
||||||
|
const museumDatasets = museumList.map((museum, idx) => {
|
||||||
|
const mg = group(filteredData.filter(r => r.museum_name === museum), start);
|
||||||
return {
|
return {
|
||||||
|
label: tr(museum),
|
||||||
|
data: labels.map((_,i) => mg[i+1]||0),
|
||||||
|
borderColor: chartPalette[idx % chartPalette.length],
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
tension: 0.4,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: gran==='week' ? 3 : 1,
|
||||||
|
pointBackgroundColor: chartPalette[idx % chartPalette.length],
|
||||||
|
_isMuseumLine: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tooltipLabels,
|
||||||
|
multiMuseum,
|
||||||
|
data: {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
||||||
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
...museumDatasets,
|
||||||
|
{ label: multiMuseum ? `Total ${start.slice(0,4)}` : start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:TOTAL_COLOR, backgroundColor: multiMuseum ? 'transparent' : TOTAL_COLOR+'18', borderWidth:2.5, tension:0.4, fill: !multiMuseum, pointRadius:gran==='week'?3:1, pointBackgroundColor:TOTAL_COLOR },
|
||||||
]
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L, selMuseums, allMuseums]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const g = groupByMuseum(filteredData, includeVAT);
|
const g = groupByMuseum(filteredData, includeVAT);
|
||||||
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
|
const getM = (d: typeof g[string]) => metric==='visitors' ? d.visitors : metric==='tickets' ? d.tickets : d.revenue;
|
||||||
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
|
const entries = Object.entries(g).sort((a,b) => getM(b[1]) - getM(a[1]));
|
||||||
return { labels:entries.map(([k]) => k), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
|
return { labels:entries.map(([k]) => tr(k)), datasets:[{ label:metric, data:entries.map(([,v]) => getM(v)), backgroundColor:chartPalette, borderRadius:4 }] };
|
||||||
}, [filteredData, includeVAT, metric]);
|
}, [filteredData, includeVAT, metric, activeLang, museumTranslations]);
|
||||||
|
|
||||||
const channelData = useMemo(() => {
|
const channelData = useMemo(() => {
|
||||||
const g = groupByChannel(filteredData, includeVAT);
|
const g = groupByChannel(filteredData, includeVAT);
|
||||||
@@ -165,13 +207,53 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
|
const currCapture = currPilgrims ? currM.visitors/currPilgrims*100 : null;
|
||||||
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
const prevCapture = prevPilgrims ? prevM.visitors/prevPilgrims*100 : null;
|
||||||
|
|
||||||
const baseOpts = useMemo(() => createBaseOptions(false), []);
|
const baseOpts = useMemo(() => createBaseOptions(showLabels), [showLabels]);
|
||||||
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
|
const { chartOpts, barHorizOpts, barNoLegend } = useMemo(() => {
|
||||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:10, padding:10, font:{ size:11 } } } } };
|
||||||
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
const barHorizOpts: any = { ...chartOpts, indexAxis:'y', plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||||
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||||
return { chartOpts, barHorizOpts, barNoLegend };
|
return { chartOpts, barHorizOpts, barNoLegend };
|
||||||
}, [baseOpts]);
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'right' as const,
|
||||||
|
labels: {
|
||||||
|
padding: 14,
|
||||||
|
font: { size: 11, weight: 'bold' as const },
|
||||||
|
usePointStyle: true,
|
||||||
|
generateLabels: (chart: any) =>
|
||||||
|
chart.data.datasets.map((ds: any, i: number) => {
|
||||||
|
const color: string = ds.borderColor || '#64748b';
|
||||||
|
const pill = document.createElement('canvas');
|
||||||
|
pill.width = 10; pill.height = 10;
|
||||||
|
const pCtx = pill.getContext('2d');
|
||||||
|
if (pCtx) {
|
||||||
|
pCtx.strokeStyle = color;
|
||||||
|
pCtx.lineWidth = 1;
|
||||||
|
pCtx.beginPath();
|
||||||
|
pCtx.arc(5, 5, 4, 0, Math.PI * 2);
|
||||||
|
pCtx.stroke();
|
||||||
|
}
|
||||||
|
return { text: ds.label, fillStyle: color, strokeStyle: color,
|
||||||
|
fontColor: color, lineWidth: 0, pointStyle: pill,
|
||||||
|
hidden: !chart.isDatasetVisible(i), datasetIndex: i };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const pieOptions: any = useMemo(() => ({
|
const pieOptions: any = useMemo(() => ({
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -216,7 +298,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
<div className="alt-filter-sep" />
|
<div className="alt-filter-sep" />
|
||||||
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selDistricts} options={allDistricts} onChange={setSelDistricts} allLabel={L.allDistricts} countLabel={L.countDistricts} clearLabel={L.clearSel} />
|
||||||
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selChannels} options={allChannels} onChange={setSelChannels} allLabel={L.allChannels} countLabel={L.countChannels} clearLabel={L.clearSel} />
|
||||||
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} />
|
<AltMultiSelect value={selMuseums} options={allMuseums} onChange={setSelMuseums} allLabel={L.allMuseums} countLabel={L.countMuseums} clearLabel={L.clearSel} labelFn={activeLang === 'ar' ? tr : undefined} />
|
||||||
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
{hasFilters && <button type="button" className="alt-filter-reset" onClick={() => { setSelDistricts([]); setSelChannels([]); setSelMuseums([]); }}>{L.reset}</button>}
|
||||||
<div className="alt-filter-spacer" />
|
<div className="alt-filter-spacer" />
|
||||||
<div className="alt-vat-toggle">
|
<div className="alt-vat-toggle">
|
||||||
@@ -248,9 +330,11 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
{metricOpts.map(o => <button key={o.value} type="button" aria-pressed={metric===o.value} className={`alt-ctrl${metric===o.value?' alt-ctrl-on':''}`} onClick={() => setMetric(o.value)}>{o.label}</button>)}
|
||||||
<div className="alt-ctrl-sep" />
|
<div className="alt-ctrl-sep" />
|
||||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
|
<div className="alt-ctrl-sep" />
|
||||||
|
<button type="button" aria-pressed={showLabels} className={`alt-ctrl${showLabels?' alt-ctrl-on':''}`} onClick={() => setShowLabels(v => !v)}>{'Labels'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
|
|||||||
@@ -1,52 +1,118 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Document, Page, View, Text, Image, StyleSheet
|
Document, Page, View, Text, Image, StyleSheet, Font
|
||||||
} from '@react-pdf/renderer';
|
} from '@react-pdf/renderer';
|
||||||
import { PdfTrendChart, PdfHBarChart } from './reportCharts';
|
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||||
import {
|
import {
|
||||||
ReportData, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||||
} from './reportHelpers';
|
} from './reportHelpers';
|
||||||
|
|
||||||
|
Font.register({
|
||||||
|
family: 'IBMPlexArabic',
|
||||||
|
fonts: [
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Regular.woff2', fontWeight: 400 },
|
||||||
|
{ src: '/fonts/IBMPlexSansArabic-Bold.woff2', fontWeight: 700 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const TOTAL_LINE_COLOR = '#1e293b';
|
||||||
|
|
||||||
|
// A4 content width minus chart-wrap padding (14×2)
|
||||||
|
// Portrait: 595 - 44 - 44 - 28 = 479
|
||||||
|
// Landscape: 842 - 44 - 44 - 28 = 726
|
||||||
|
const CHART_W = { portrait: 479, landscape: 726 } as const;
|
||||||
|
|
||||||
const S = StyleSheet.create({
|
const S = StyleSheet.create({
|
||||||
page: { fontFamily: 'Helvetica', fontSize: 9, color: '#0f172a', backgroundColor: '#ffffff' },
|
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||||
|
|
||||||
|
// ── Cover ──────────────────────────────────────────────
|
||||||
coverPage: { flexDirection: 'column', padding: 0 },
|
coverPage: { flexDirection: 'column', padding: 0 },
|
||||||
coverTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingTop: 40, paddingRight: 50, paddingBottom: 0, paddingLeft: 50 },
|
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||||
coverLogoBox: { width: 80, height: 40, justifyContent: 'center' },
|
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||||
coverClientLogo: { width: 80, height: 40, objectFit: 'contain' as const },
|
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||||
coverHiHala: { fontSize: 13, fontFamily: 'Helvetica-Bold', color: '#2563eb', letterSpacing: 0.5 },
|
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
coverMiddle: { flex: 1, justifyContent: 'center', paddingHorizontal: 50, paddingTop: 80 },
|
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||||
coverTitle: { fontSize: 28, fontFamily: 'Helvetica-Bold', marginBottom: 16, lineHeight: 1.2 },
|
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
||||||
coverFor: { fontSize: 11, color: '#334155', marginBottom: 4 },
|
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||||
coverContact: { fontSize: 10, color: '#64748b', marginBottom: 32 },
|
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||||
coverPeriod: { fontSize: 10, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 6 },
|
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||||
coverDate: { fontSize: 9, color: '#94a3b8' },
|
coverBodySpacer: { flex: 1 },
|
||||||
coverBar: { height: 6, flex: 1 },
|
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
|
||||||
contentPage: { paddingTop: 32, paddingRight: 44, paddingBottom: 48, paddingLeft: 44 },
|
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
|
||||||
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 24 },
|
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
|
||||||
pageHeaderTitle: { fontSize: 8, color: '#94a3b8' },
|
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
|
||||||
pageHeaderLogo: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
|
||||||
pageHeaderNum: { fontSize: 8, color: '#94a3b8' },
|
|
||||||
pageFooter: { position: 'absolute', bottom: 20, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between' },
|
// ── Content pages ──────────────────────────────────────
|
||||||
pageFooterText: { fontSize: 7, color: '#94a3b8' },
|
contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 },
|
||||||
sectionHeading: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 5, paddingRight: 10, paddingBottom: 5, paddingLeft: 10, marginBottom: 14, borderRadius: 3 },
|
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 },
|
||||||
summaryText: { fontSize: 9.5, color: '#334155', lineHeight: 1.6 },
|
pageHeaderLogo: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
||||||
|
pageHeaderTitle: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageHeaderNum: { fontSize: 9, color: '#94a3b8' },
|
||||||
|
pageFooter: { position: 'absolute', bottom: 22, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: 1, borderTopColor: '#f1f5f9', paddingTop: 6 },
|
||||||
|
pageFooterText: { fontSize: 7.5, color: '#b0bec5' },
|
||||||
|
|
||||||
|
// ── Section headings ───────────────────────────────────
|
||||||
|
sectionHeading: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 8, paddingRight: 14, paddingBottom: 8, paddingLeft: 14, marginBottom: 16, borderRadius: 4 },
|
||||||
|
sectionGap: { marginBottom: 28 },
|
||||||
|
|
||||||
|
// ── Executive summary ──────────────────────────────────
|
||||||
|
summaryText: { fontSize: 10.5, color: '#334155', lineHeight: 1.7 },
|
||||||
|
|
||||||
|
// ── Key metrics table ──────────────────────────────────
|
||||||
metricsTable: { marginBottom: 8 },
|
metricsTable: { marginBottom: 8 },
|
||||||
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 5, paddingBottom: 5, marginBottom: 2, borderRadius: 3 },
|
||||||
metricsRowAlt: { backgroundColor: '#f8fafc' },
|
metricsHeaderLabel: { flex: 1.8, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 8 },
|
||||||
metricsLabel: { flex: 1.5, fontSize: 9, color: '#334155', fontFamily: 'Helvetica-Bold' },
|
metricsHeaderCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
metricsValue: { flex: 1, fontSize: 9, color: '#0f172a', textAlign: 'right' },
|
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 7 },
|
||||||
metricsChange: { flex: 0.8, fontSize: 8, textAlign: 'right' },
|
metricsRowAlt: { backgroundColor: '#fafbfd' },
|
||||||
|
metricsLabel: { flex: 1.8, fontSize: 10, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 8 },
|
||||||
|
metricsValue: { flex: 1, fontSize: 10, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
metricsChange: { flex: 0.8, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
metricsChangeUp: { color: '#059669' },
|
metricsChangeUp: { color: '#059669' },
|
||||||
metricsChangeDown: { color: '#dc2626' },
|
metricsChangeDown: { color: '#dc2626' },
|
||||||
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 2 },
|
|
||||||
metricsHeaderCell: { flex: 1, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right' },
|
// ── Trend chart ────────────────────────────────────────
|
||||||
metricsHeaderLabel: { flex: 1.5, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b' },
|
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||||
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', padding: 12, borderRadius: 4 },
|
legendRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10 },
|
||||||
sectionGap: { marginBottom: 24 },
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18, marginBottom: 4 },
|
||||||
legendRow: { flexDirection: 'row', marginBottom: 8 },
|
|
||||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 16 },
|
|
||||||
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
||||||
legendLabel: { fontSize: 7.5, color: '#64748b', marginLeft: 4 },
|
legendLabel: { fontSize: 8, color: '#64748b', marginLeft: 5 },
|
||||||
|
|
||||||
|
// ── Museum mini-reports ────────────────────────────────
|
||||||
|
museumBlock: { marginBottom: 20, borderLeftWidth: 3, paddingLeft: 12 },
|
||||||
|
museumBlockName: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#0f172a', marginBottom: 4 },
|
||||||
|
museumIntroText: { fontSize: 9.5, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 10 },
|
||||||
|
miniTable: { marginBottom: 4 },
|
||||||
|
miniHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 4, paddingBottom: 4, marginBottom: 1, borderRadius: 2 },
|
||||||
|
miniHeaderLabel: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 6 },
|
||||||
|
miniHeaderCell: { flex: 2, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniHeaderChangeCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 5 },
|
||||||
|
miniRowAlt: { backgroundColor: '#fafafa' },
|
||||||
|
miniLabel: { flex: 2, fontSize: 9.5, color: '#334155', fontFamily: 'Helvetica-Bold', paddingLeft: 6 },
|
||||||
|
miniValue: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChange: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
miniChangeUp: { color: '#059669' },
|
||||||
|
miniChangeDown: { color: '#dc2626' },
|
||||||
|
|
||||||
|
// ── Global summary table ───────────────────────────────
|
||||||
|
summarySubLabel: { fontSize: 9, color: '#64748b', marginBottom: 14 },
|
||||||
|
summaryHeaderRow: { flexDirection: 'row', backgroundColor: '#0f172a', paddingTop: 8, paddingBottom: 8, borderRadius: 4 },
|
||||||
|
summaryHeaderMuseum: { flex: 3, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingLeft: 10 },
|
||||||
|
summaryHeaderMetric: { flex: 2, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryHeaderDelta: { flex: 1, fontSize: 8.5, fontFamily: 'Helvetica-Bold', color: '#ffffff', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
||||||
|
summaryRowAlt: { backgroundColor: '#f8fafc' },
|
||||||
|
summaryTotalRow: { flexDirection: 'row', borderTopWidth: 2, borderTopColor: '#0f172a', paddingTop: 8, paddingBottom: 5, marginTop: 2 },
|
||||||
|
summaryMuseum: { flex: 3, fontSize: 9.5, color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMuseumTotal: { flex: 3, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', paddingLeft: 10 },
|
||||||
|
summaryMetric: { flex: 2, fontSize: 9.5, color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryMetricTotal: { flex: 2, fontSize: 9.5, fontFamily: 'Helvetica-Bold', color: '#0f172a', textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDelta: { flex: 1, fontSize: 9, textAlign: 'right', paddingRight: 6 },
|
||||||
|
summaryDeltaUp: { color: '#059669' },
|
||||||
|
summaryDeltaDown: { color: '#dc2626' },
|
||||||
|
summaryDeltaTotal: { flex: 1, fontSize: 9, fontFamily: 'Helvetica-Bold', textAlign: 'right', paddingRight: 6 },
|
||||||
});
|
});
|
||||||
|
|
||||||
function pctChange(curr: number, prev: number): number {
|
function pctChange(curr: number, prev: number): number {
|
||||||
@@ -54,12 +120,22 @@ function pctChange(curr: number, prev: number): number {
|
|||||||
return Math.round(((curr - prev) / prev) * 100);
|
return Math.round(((curr - prev) / prev) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageHeaderProps { title: string; page: number; }
|
function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string {
|
||||||
function PageHeader({ title, page }: PageHeaderProps) {
|
if (!row.prev) return '';
|
||||||
|
const revChg = pctChange(row.curr.revenue, row.prev.revenue);
|
||||||
|
const visChg = pctChange(row.curr.visitors, row.prev.visitors);
|
||||||
|
if (lang === 'en') {
|
||||||
|
return `Revenue ${revChg >= 0 ? 'up' : 'down'} ${Math.abs(revChg)}%, visitors ${visChg >= 0 ? 'up' : 'down'} ${Math.abs(visChg)}% vs ${compLabel}.`;
|
||||||
|
}
|
||||||
|
return `الإيرادات ${revChg >= 0 ? 'ارتفعت' : 'انخفضت'} ${Math.abs(revChg)}%، الزوار ${visChg >= 0 ? 'ارتفعوا' : 'انخفضوا'} ${Math.abs(visChg)}% مقارنةً بـ${compLabel}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageHeaderProps { title: string; page: number; isAr: boolean; arB: any; }
|
||||||
|
function PageHeader({ title, page, isAr, arB }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<View style={S.pageHeader}>
|
<View style={S.pageHeader}>
|
||||||
<Text style={S.pageHeaderLogo}>HiHala Data</Text>
|
<Text style={[S.pageHeaderLogo, arB]}>HiHala Data</Text>
|
||||||
<Text style={S.pageHeaderTitle}>{title}</Text>
|
<Text style={[S.pageHeaderTitle, isAr ? { fontFamily: 'IBMPlexArabic' } : {}]}>{title}</Text>
|
||||||
<Text style={S.pageHeaderNum}>{page}</Text>
|
<Text style={S.pageHeaderNum}>{page}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -75,11 +151,11 @@ function PageFooter({ confidentiality, generatedAt }: PageFooterProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionProps { title: string; color: string; }
|
interface SectionProps { title: string; color: string; arB: any; }
|
||||||
function SectionHeading({ title, color }: SectionProps) {
|
function SectionHeading({ title, color, arB }: SectionProps) {
|
||||||
return (
|
return (
|
||||||
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
<View style={[S.sectionHeading, { backgroundColor: color }]}>
|
||||||
<Text>{title}</Text>
|
<Text style={arB}>{title}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,17 +163,30 @@ function SectionHeading({ title, color }: SectionProps) {
|
|||||||
interface Props { data: ReportData; }
|
interface Props { data: ReportData; }
|
||||||
|
|
||||||
export function ReportDocument({ data }: Props) {
|
export function ReportDocument({ data }: Props) {
|
||||||
const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious,
|
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||||
museumBreakdown, museumVisitorBreakdown, channelBreakdown, pilgrimCapture, generatedAt } = data;
|
trendCharts,
|
||||||
|
museumData, channelBreakdown, districtBreakdown,
|
||||||
|
pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
const lang = cfg.language;
|
const lang = cfg.language;
|
||||||
|
const isAr = lang === 'ar';
|
||||||
const color = cfg.accentColor;
|
const color = cfg.accentColor;
|
||||||
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
const orientation = cfg.orientation === 'landscape' ? 'landscape' : 'portrait';
|
const isLandscape = cfg.orientation === 'landscape';
|
||||||
|
const orientation = isLandscape ? 'landscape' : 'portrait';
|
||||||
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
const T = lang === 'en' ? LABELS_EN : LABELS_AR;
|
||||||
|
|
||||||
|
// Arabic font overrides — Helvetica has no Arabic glyphs; cast as any so style arrays stay compatible
|
||||||
|
const arN: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 400 } : {};
|
||||||
|
const arB: any = isAr ? { fontFamily: 'IBMPlexArabic', fontWeight: 700 } : {};
|
||||||
|
// direction: 'rtl' flips flex-row children right-to-left; fontFamily cascades to elements without an explicit one
|
||||||
|
const arPageExtra: any = isAr ? { direction: 'rtl', fontFamily: 'IBMPlexArabic' } : {};
|
||||||
|
|
||||||
|
const chartW = isLandscape ? CHART_W.landscape : CHART_W.portrait;
|
||||||
|
|
||||||
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
const avgTicketPrice = metrics.tickets > 0 ? metrics.revenue / metrics.tickets : 0;
|
||||||
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0 ? prevMetrics.revenue / prevMetrics.tickets : null;
|
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0
|
||||||
|
? prevMetrics.revenue / prevMetrics.tickets : null;
|
||||||
|
|
||||||
const metricsRows = [
|
const metricsRows = [
|
||||||
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
|
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
|
||||||
@@ -122,55 +211,103 @@ export function ReportDocument({ data }: Props) {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1;
|
const showMuseumPage = cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets;
|
||||||
|
const showChannelPage = cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets;
|
||||||
|
const showDistrictPage = cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets;
|
||||||
|
const showSummaryPage = cfg.showGlobalSummary && cfg.includeComparison;
|
||||||
|
|
||||||
|
let pg = 1;
|
||||||
|
const mainPg = ++pg;
|
||||||
|
const museumPg = showMuseumPage ? ++pg : 0;
|
||||||
|
const channelPg = showChannelPage ? ++pg : 0;
|
||||||
|
const districtPg = showDistrictPage ? ++pg : 0;
|
||||||
|
const summaryPg = showSummaryPage ? ++pg : 0;
|
||||||
|
|
||||||
|
const museumMetricRows = (row: MuseumDataRow) => {
|
||||||
|
const rows = [];
|
||||||
|
if (cfg.showMuseumRevenue) rows.push({
|
||||||
|
label: T.revenue,
|
||||||
|
curr: formatCurrency(row.curr.revenue, cfg.includeVAT),
|
||||||
|
prev: row.prev ? formatCurrency(row.prev.revenue, cfg.includeVAT) : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.revenue, row.prev.revenue) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumVisitors) rows.push({
|
||||||
|
label: T.visitors,
|
||||||
|
curr: row.curr.visitors.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.visitors.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.visitors, row.prev.visitors) : null,
|
||||||
|
});
|
||||||
|
if (cfg.showMuseumTickets) rows.push({
|
||||||
|
label: T.tickets,
|
||||||
|
curr: row.curr.tickets.toLocaleString(),
|
||||||
|
prev: row.prev ? row.prev.tickets.toLocaleString() : null,
|
||||||
|
chg: row.prev ? pctChange(row.curr.tickets, row.prev.tickets) : null,
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||||
|
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
{/* ── Cover ─────────────────────────────────────────── */}
|
||||||
<View style={S.coverTop}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage, arPageExtra]}>
|
||||||
<Text style={S.coverHiHala}>HiHala Data</Text>
|
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||||
|
<View style={S.coverHeaderTop}>
|
||||||
|
<Text style={[S.coverBrand, arB]}>HiHala Data</Text>
|
||||||
{cfg.clientLogoBase64 && (
|
{cfg.clientLogoBase64 && (
|
||||||
<View style={S.coverLogoBox}>
|
<View style={S.coverLogoBox}>
|
||||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={S.coverMiddle}>
|
<Text style={[S.coverTitle, arB]}>{cfg.title || T.defaultTitle}</Text>
|
||||||
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
</View>
|
||||||
{cfg.clientName && <Text style={S.coverFor}>{T.preparedFor}: {cfg.clientName}</Text>}
|
|
||||||
{cfg.contactName && <Text style={S.coverContact}>{T.attention}: {cfg.contactName}</Text>}
|
<View style={S.coverBody}>
|
||||||
<Text style={S.coverPeriod}>{period}</Text>
|
{cfg.clientName && (
|
||||||
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
<Text style={[S.coverClientName, arB]}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<Text style={[S.coverContactName, arN]}>{T.attention}: {cfg.contactName}</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.coverBodySpacer} />
|
||||||
|
<View style={S.coverPeriodRow}>
|
||||||
|
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||||
|
<Text style={[S.coverPeriod, arN]}>{period}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
||||||
|
{cfg.confidentiality !== 'Public' && (
|
||||||
|
<Text style={S.coverConfidential}>{cfg.confidentiality.toUpperCase()}</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={[S.coverBar, { backgroundColor: color }]} />
|
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={2} />
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
{cfg.showExecutiveSummary && (
|
{cfg.showExecutiveSummary && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.execSummary} color={color} />
|
<SectionHeading title={T.execSummary} color={color} arB={arB} />
|
||||||
<Text style={S.summaryText}>{generateExecutiveSummary(data)}</Text>
|
<Text style={[S.summaryText, arN]}>{generateExecutiveSummary(data)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showMetricsTable && (
|
{cfg.showMetricsTable && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} />
|
<SectionHeading title={`${T.keyMetrics} — ${cfg.includeVAT ? T.inclVAT : T.exclVAT}`} color={color} arB={arB} />
|
||||||
<View style={S.metricsTable}>
|
<View style={S.metricsTable}>
|
||||||
<View style={S.metricsHeaderRow}>
|
<View style={S.metricsHeaderRow}>
|
||||||
<Text style={S.metricsHeaderLabel}> </Text>
|
<Text style={[S.metricsHeaderLabel, arB]}> </Text>
|
||||||
<Text style={S.metricsHeaderCell}>{period}</Text>
|
<Text style={[S.metricsHeaderCell, arB]}>{period}</Text>
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{prevYear}</Text>}
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
{prevMetrics && <Text style={[S.metricsHeaderCell, arB]}>{T.change}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{metricsRows.map((row, i) => (
|
{metricsRows.map((row, i) => (
|
||||||
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
<View key={row.label} style={[S.metricsRow, i % 2 === 1 ? S.metricsRowAlt : {}]}>
|
||||||
<Text style={S.metricsLabel}>{row.label}</Text>
|
<Text style={[S.metricsLabel, arB]}>{row.label}</Text>
|
||||||
<Text style={S.metricsValue}>{row.curr}</Text>
|
<Text style={[S.metricsValue, arN]}>{row.curr}</Text>
|
||||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
{prevMetrics && <Text style={[S.metricsValue, arN]}>{row.prev ?? '—'}</Text>}
|
||||||
{prevMetrics && row.chg !== null && (
|
{prevMetrics && row.chg !== null && (
|
||||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||||
{formatPct(row.chg)}
|
{formatPct(row.chg)}
|
||||||
@@ -182,61 +319,246 @@ export function ReportDocument({ data }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showTrendChart && (
|
{cfg.showTrendChart && trendCharts.map((tc, tci) => {
|
||||||
<View style={S.sectionGap}>
|
const trendTitle = tc.metric === 'visitors' ? T.trendVisitors
|
||||||
<SectionHeading title={T.trend} color={color} />
|
: tc.metric === 'tickets' ? T.trendTickets
|
||||||
{cfg.includeComparison && (
|
: T.trendRevenue;
|
||||||
|
return (
|
||||||
|
<View key={tci} style={S.sectionGap}>
|
||||||
|
<SectionHeading title={trendTitle} color={color} arB={arB} />
|
||||||
<View style={S.legendRow}>
|
<View style={S.legendRow}>
|
||||||
<View style={S.legendItem}>
|
{tc.museums.length >= 2 && tc.museums.map((m, i) => (
|
||||||
<View style={[S.legendDot, { backgroundColor: color }]} />
|
<View key={m.name} style={S.legendItem}>
|
||||||
<Text style={S.legendLabel}>{period}</Text>
|
<View style={[S.legendDot, { backgroundColor: CHART_PALETTE[i % CHART_PALETTE.length] }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{m.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={S.legendItem}>
|
||||||
|
<View style={[S.legendDot, { backgroundColor: TOTAL_LINE_COLOR }]} />
|
||||||
|
<Text style={[S.legendLabel, arN]}>{tc.museums.length >= 2 ? `Total · ${period}` : period}</Text>
|
||||||
|
</View>
|
||||||
|
{cfg.includeComparison && tc.previous && (
|
||||||
<View style={S.legendItem}>
|
<View style={S.legendItem}>
|
||||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||||
<Text style={S.legendLabel}>{prevYear}</Text>
|
<Text style={[S.legendLabel, arN]}>{comparisonPeriodLabel}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfTrendChart
|
||||||
|
labels={tc.labels}
|
||||||
|
current={tc.current}
|
||||||
|
previous={tc.previous}
|
||||||
|
color={TOTAL_LINE_COLOR}
|
||||||
|
width={chartW}
|
||||||
|
height={155}
|
||||||
|
series={tc.museums.length >= 2 ? tc.museums.map((m, i) => ({
|
||||||
|
label: m.name,
|
||||||
|
color: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||||
|
data: m.values,
|
||||||
|
})) : undefined}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||||
|
{showMuseumPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} isAr={isAr} arB={arB} />
|
||||||
|
<SectionHeading title={T.museumBreakdowns} color={color} arB={arB} />
|
||||||
|
|
||||||
|
{museumData.map((row, mi) => {
|
||||||
|
const mRows = museumMetricRows(row);
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.museumBlock, { borderLeftColor: CHART_PALETTE[mi % CHART_PALETTE.length] }]}>
|
||||||
|
<Text style={[S.museumBlockName, arB]}>{row.name}</Text>
|
||||||
|
{hasPrev && (
|
||||||
|
<Text style={[S.museumIntroText, arN]}>
|
||||||
|
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.miniTable}>
|
||||||
|
<View style={S.miniHeaderRow}>
|
||||||
|
<Text style={[S.miniHeaderLabel, arB]}> </Text>
|
||||||
|
<Text style={[S.miniHeaderCell, arB]}>{period}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderCell, arB]}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{hasPrev && <Text style={[S.miniHeaderChangeCell, arB]}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{mRows.map((mr, ri) => (
|
||||||
|
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||||
|
<Text style={[S.miniLabel, arB]}>{mr.label}</Text>
|
||||||
|
<Text style={[S.miniValue, arN]}>{mr.curr}</Text>
|
||||||
|
{hasPrev && <Text style={[S.miniValue, arN]}>{mr.prev ?? '—'}</Text>}
|
||||||
|
{hasPrev && mr.chg !== null && (
|
||||||
|
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||||
|
{formatPct(mr.chg)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Channel Breakdowns ─────────────────────────────── */}
|
||||||
|
{showChannelPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
|
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelRevenue} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelVisitors} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
previous={trendPrevious} color={color} width={460} height={130} />
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
</Page>
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
{(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && (
|
{/* ── District Breakdowns ────────────────────────────── */}
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
{showDistrictPage && (
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={3} />
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} isAr={isAr} arB={arB} />
|
||||||
|
|
||||||
{cfg.showMuseumBreakdown && museumBreakdown.length > 0 && (
|
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byMuseumRevenue} color={color} />
|
<SectionHeading title={T.byDistrictRevenue} color={color} arB={arB} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
|
<PdfHBarChart items={districtBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictVisitors && districtBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictVisitors} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showDistrictTickets && districtBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byDistrictTickets} color={color} arB={arB} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showMuseumBreakdown && museumVisitorBreakdown.length > 0 && (
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
<View style={S.sectionGap}>
|
</Page>
|
||||||
<SectionHeading title={T.byMuseumVisitors} color={color} />
|
|
||||||
<View style={S.chartWrap}>
|
|
||||||
<PdfHBarChart items={museumVisitorBreakdown} color={color} width={460} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showChannelBreakdown && channelBreakdown.length > 0 && (
|
{/* ── Global Performance Summary ─────────────────────── */}
|
||||||
<View style={S.sectionGap}>
|
{showSummaryPage && museumData.length > 0 && (
|
||||||
<SectionHeading title={T.byChannel} color={color} />
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage, arPageExtra]}>
|
||||||
<View style={S.chartWrap}>
|
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} isAr={isAr} arB={arB} />
|
||||||
<PdfHBarChart items={channelBreakdown} color={color} width={460} />
|
<SectionHeading title={T.globalSummary} color={color} arB={arB} />
|
||||||
|
|
||||||
|
<Text style={[S.summarySubLabel, arN]}>
|
||||||
|
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={S.summaryHeaderRow}>
|
||||||
|
<Text style={[S.summaryHeaderMuseum, arB]}>{T.museum}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.revenue}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.visitors}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryHeaderMetric, arB]}>{T.tickets}</Text>
|
||||||
|
<Text style={[S.summaryHeaderDelta, arB]}>Δ</Text>
|
||||||
|
</>}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{museumData.map((row, i) => {
|
||||||
|
const hasPrev = row.prev !== null;
|
||||||
|
return (
|
||||||
|
<View key={row.name} style={[S.summaryRow, i % 2 === 1 ? S.summaryRowAlt : {}]}>
|
||||||
|
<Text style={[S.summaryMuseum, arN]}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{formatCurrency(row.curr.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.revenue, row.prev!.revenue);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.visitors.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.visitors, row.prev!.visitors);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetric, arN]}>{row.curr.tickets.toLocaleString()}</Text>
|
||||||
|
{hasPrev && row.prev ? (() => {
|
||||||
|
const c = pctChange(row.curr.tickets, row.prev!.tickets);
|
||||||
|
return <Text style={[S.summaryDelta, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<View style={S.summaryTotalRow}>
|
||||||
|
<Text style={[S.summaryMuseumTotal, arB]}>{T.total}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{formatCurrency(metrics.revenue, cfg.includeVAT)}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.revenue, prevMetrics.revenue);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.visitors.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={[S.summaryMetricTotal, arB]}>{metrics.tickets.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{formatPct(c)}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
</Page>
|
</Page>
|
||||||
@@ -255,11 +577,21 @@ const LABELS_EN = {
|
|||||||
keyMetrics: 'Key Metrics',
|
keyMetrics: 'Key Metrics',
|
||||||
inclVAT: 'Incl. VAT',
|
inclVAT: 'Incl. VAT',
|
||||||
exclVAT: 'Excl. VAT',
|
exclVAT: 'Excl. VAT',
|
||||||
change: 'vs Prior Year',
|
change: 'Change',
|
||||||
trend: 'Revenue Trend',
|
comparedTo: 'vs.',
|
||||||
byMuseumRevenue: 'Revenue by Museum',
|
trendRevenue: 'Revenue Trend',
|
||||||
byMuseumVisitors: 'Visitors by Museum',
|
trendVisitors: 'Visitor Trend',
|
||||||
byChannel: 'Visitors by Channel',
|
trendTickets: 'Ticket Trend',
|
||||||
|
museumBreakdowns: 'Museum Breakdown',
|
||||||
|
byChannelRevenue: 'Revenue by Channel',
|
||||||
|
byChannelVisitors: 'Visitors by Channel',
|
||||||
|
byChannelTickets: 'Tickets by Channel',
|
||||||
|
byDistrictRevenue: 'Revenue by District',
|
||||||
|
byDistrictVisitors: 'Visitors by District',
|
||||||
|
byDistrictTickets: 'Tickets by District',
|
||||||
|
globalSummary: 'Performance Summary',
|
||||||
|
museum: 'Museum',
|
||||||
|
total: 'TOTAL',
|
||||||
revenue: 'Revenue',
|
revenue: 'Revenue',
|
||||||
visitors: 'Visitors',
|
visitors: 'Visitors',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
@@ -277,11 +609,21 @@ const LABELS_AR = {
|
|||||||
keyMetrics: 'المؤشرات الرئيسية',
|
keyMetrics: 'المؤشرات الرئيسية',
|
||||||
inclVAT: 'شامل ضريبة القيمة المضافة',
|
inclVAT: 'شامل ضريبة القيمة المضافة',
|
||||||
exclVAT: 'غير شامل ضريبة القيمة المضافة',
|
exclVAT: 'غير شامل ضريبة القيمة المضافة',
|
||||||
change: 'مقابل العام السابق',
|
change: 'التغيّر',
|
||||||
trend: 'اتجاه الإيرادات',
|
comparedTo: 'مقابل',
|
||||||
byMuseumRevenue: 'الإيرادات حسب المتحف',
|
trendRevenue: 'اتجاه الإيرادات',
|
||||||
byMuseumVisitors: 'الزوار حسب المتحف',
|
trendVisitors: 'اتجاه الزوار',
|
||||||
byChannel: 'الزوار حسب القناة',
|
trendTickets: 'اتجاه التذاكر',
|
||||||
|
museumBreakdowns: 'تفاصيل المتاحف',
|
||||||
|
byChannelRevenue: 'الإيرادات حسب القناة',
|
||||||
|
byChannelVisitors: 'الزوار حسب القناة',
|
||||||
|
byChannelTickets: 'التذاكر حسب القناة',
|
||||||
|
byDistrictRevenue: 'الإيرادات حسب الحي',
|
||||||
|
byDistrictVisitors: 'الزوار حسب الحي',
|
||||||
|
byDistrictTickets: 'التذاكر حسب الحي',
|
||||||
|
globalSummary: 'ملخص الأداء',
|
||||||
|
museum: 'المتحف',
|
||||||
|
total: 'الإجمالي',
|
||||||
revenue: 'الإيرادات',
|
revenue: 'الإيرادات',
|
||||||
visitors: 'الزوار',
|
visitors: 'الزوار',
|
||||||
tickets: 'التذاكر',
|
tickets: 'التذاكر',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import AltMultiSelect from '../shared/AltMultiSelect';
|
import AltMultiSelect from '../shared/AltMultiSelect';
|
||||||
import type { ReportConfig } from './reportHelpers';
|
import type { ReportConfig, TrendMetric } from './reportHelpers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: ReportConfig;
|
config: ReportConfig;
|
||||||
@@ -9,10 +9,6 @@ interface Props {
|
|||||||
allChannels: string[];
|
allChannels: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
||||||
return <div className="rf-section-title">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="rf-field">
|
<label className="rf-field">
|
||||||
@@ -22,33 +18,130 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ left, right, value, onChange }: {
|
// C1+C3: role="group" + aria-label + aria-pressed on every button
|
||||||
left: string; right: string; value: boolean; onChange: (v: boolean) => void;
|
function PillGroup({ options, value, onChange, label }: {
|
||||||
|
options: Array<{ label: string; value: string }>;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rf-toggle">
|
<div className="rf-metric-pills" role="group" aria-label={label}>
|
||||||
<button type="button" className={`rf-toggle-opt${!value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(false)}>{left}</button>
|
{options.map(opt => (
|
||||||
<button type="button" className={`rf-toggle-opt${value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(true)}>{right}</button>
|
<button key={opt.value} type="button"
|
||||||
|
className={`rf-metric-pill${value === opt.value ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={value === opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
function IndeterminateCheckbox({ checked, indeterminate, onChange, className }: {
|
||||||
|
checked: boolean; indeterminate: boolean; onChange: (v: boolean) => void; className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = indeterminate;
|
||||||
|
}, [indeterminate]);
|
||||||
return (
|
return (
|
||||||
<label className="rf-check-row">
|
<input ref={ref} type="checkbox" checked={checked}
|
||||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="rf-checkbox" />
|
onChange={e => onChange(e.target.checked)} className={className} />
|
||||||
<span>{label}</span>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// C1: aria-hidden badge (visual only), role/aria on header label provides the accessible name
|
||||||
|
function ModuleCard({ title, badge, enabled, onToggle, children }: {
|
||||||
|
title: string;
|
||||||
|
badge?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: (v: boolean) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${enabled ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<input type="checkbox" checked={enabled}
|
||||||
|
onChange={e => onToggle(e.target.checked)} className="rf-checkbox" />
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual state feedback, not part of checkbox label */}
|
||||||
|
<span className={`rf-module-badge${enabled ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge ?? (enabled ? 'Included' : 'Excluded')}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{enabled && children && (
|
||||||
|
<div className="rf-module-body">{children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetricPatch = { revenue?: boolean; visitors?: boolean; tickets?: boolean };
|
||||||
|
|
||||||
|
function BreakdownModule({ title, revenue, visitors, tickets, onChange }: {
|
||||||
|
title: string;
|
||||||
|
revenue: boolean; visitors: boolean; tickets: boolean;
|
||||||
|
onChange: (patch: MetricPatch) => void;
|
||||||
|
}) {
|
||||||
|
const anyOn = revenue || visitors || tickets;
|
||||||
|
const allOn = revenue && visitors && tickets;
|
||||||
|
const badge = anyOn
|
||||||
|
? [revenue && 'Revenue', visitors && 'Visitors', tickets && 'Tickets'].filter(Boolean).join(' · ')
|
||||||
|
: 'Excluded';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rf-module${anyOn ? ' rf-module--on' : ''}`}>
|
||||||
|
<label className="rf-module-header">
|
||||||
|
<IndeterminateCheckbox
|
||||||
|
checked={anyOn}
|
||||||
|
indeterminate={anyOn && !allOn}
|
||||||
|
onChange={v => onChange({ revenue: v, visitors: v, tickets: v })}
|
||||||
|
className="rf-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="rf-module-title">{title}</span>
|
||||||
|
{/* aria-hidden: badge is visual only */}
|
||||||
|
<span className={`rf-module-badge${anyOn ? ' rf-module-badge--on' : ''}`} aria-hidden="true">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{anyOn && (
|
||||||
|
<div className="rf-module-body">
|
||||||
|
{/* C1+C3: role="group" + aria-label + aria-pressed */}
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label={`${title} metrics to include`}>
|
||||||
|
{([
|
||||||
|
{ label: 'Revenue', on: revenue, key: 'revenue' as keyof MetricPatch },
|
||||||
|
{ label: 'Visitors', on: visitors, key: 'visitors' as keyof MetricPatch },
|
||||||
|
{ label: 'Tickets', on: tickets, key: 'tickets' as keyof MetricPatch },
|
||||||
|
]).map(({ label, on, key }) => (
|
||||||
|
<button key={label} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => onChange({ [key]: !on } as MetricPatch)}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
|
export default function ReportForm({ config: cfg, onChange, allMuseums, allChannels }: Props) {
|
||||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// C2: inline error instead of alert()
|
||||||
|
const [logoError, setLogoError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (file.size > 2 * 1024 * 1024) { alert('Logo must be under 2 MB'); return; }
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
setLogoError('File must be under 2 MB.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogoError(null);
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
@@ -56,8 +149,12 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="report-form">
|
<div className="report-form">
|
||||||
|
<div className="rf-two-col">
|
||||||
|
|
||||||
<SectionTitle>Client Info</SectionTitle>
|
{/* ── Left: setup ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
{/* M2: semantic h2 instead of div — visually identical via CSS */}
|
||||||
|
<h2 className="rf-group-label">Client</h2>
|
||||||
|
|
||||||
<Field label="Report title">
|
<Field label="Report title">
|
||||||
<input className="rf-input" type="text" value={cfg.title}
|
<input className="rf-input" type="text" value={cfg.title}
|
||||||
@@ -65,51 +162,65 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
|||||||
placeholder="Q1 2025 Visitor Performance" />
|
placeholder="Q1 2025 Visitor Performance" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Prepared for (company)">
|
<Field label="Prepared for">
|
||||||
<input className="rf-input" type="text" value={cfg.clientName}
|
<input className="rf-input" type="text" value={cfg.clientName}
|
||||||
onChange={e => onChange({ clientName: e.target.value })}
|
onChange={e => onChange({ clientName: e.target.value })}
|
||||||
placeholder="Acme Group" />
|
placeholder="Acme Group" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Contact name (optional)">
|
<Field label="Contact (optional)">
|
||||||
<input className="rf-input" type="text" value={cfg.contactName}
|
<input className="rf-input" type="text" value={cfg.contactName}
|
||||||
onChange={e => onChange({ contactName: e.target.value })}
|
onChange={e => onChange({ contactName: e.target.value })}
|
||||||
placeholder="Mohammed Al-..." />
|
placeholder="Mohammed Al-..." />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Client logo (PNG/JPG, max 2 MB)">
|
<div className="rf-branding-row">
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Accent color</span>
|
||||||
|
<div className="rf-color-row">
|
||||||
|
<input type="color" value={cfg.accentColor}
|
||||||
|
onChange={e => onChange({ accentColor: e.target.value })}
|
||||||
|
className="rf-color-input"
|
||||||
|
aria-label="Report accent color" />
|
||||||
|
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Logo (PNG/JPG, max 2 MB)</span>
|
||||||
<div className="rf-logo-row">
|
<div className="rf-logo-row">
|
||||||
<button type="button" className="rf-upload-btn" onClick={() => logoInputRef.current?.click()}>
|
{/* H6: descriptive aria-label on upload button */}
|
||||||
{cfg.clientLogoBase64 ? 'Change logo' : 'Upload logo'}
|
<button type="button" className="rf-upload-btn"
|
||||||
|
aria-label={cfg.clientLogoBase64 ? 'Change client logo' : 'Upload client logo'}
|
||||||
|
onClick={() => logoInputRef.current?.click()}>
|
||||||
|
{cfg.clientLogoBase64 ? 'Change' : 'Upload'}
|
||||||
</button>
|
</button>
|
||||||
{cfg.clientLogoBase64 && (
|
{cfg.clientLogoBase64 && (
|
||||||
<>
|
<>
|
||||||
<img src={cfg.clientLogoBase64} alt="preview" className="rf-logo-preview" />
|
{/* M1: meaningful alt text */}
|
||||||
<button type="button" className="rf-remove-btn" onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
<img src={cfg.clientLogoBase64} alt="Uploaded client logo" className="rf-logo-preview" />
|
||||||
|
{/* H6: descriptive aria-label on remove button */}
|
||||||
|
<button type="button" className="rf-remove-btn"
|
||||||
|
aria-label="Remove client logo"
|
||||||
|
onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
||||||
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
{/* C2: inline logo error */}
|
||||||
|
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
|
||||||
<Field label="Accent color">
|
</div>
|
||||||
<div className="rf-color-row">
|
|
||||||
<input type="color" value={cfg.accentColor}
|
|
||||||
onChange={e => onChange({ accentColor: e.target.value })}
|
|
||||||
className="rf-color-input" />
|
|
||||||
<span className="rf-color-val">{cfg.accentColor}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SectionTitle>Data Selection</SectionTitle>
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Data</h2>
|
||||||
|
|
||||||
<div className="rf-date-row">
|
<div className="rf-date-row">
|
||||||
<Field label="Start date">
|
<Field label="Period start">
|
||||||
<input className="rf-input" type="date" value={cfg.startDate}
|
<input className="rf-input" type="date" value={cfg.startDate}
|
||||||
onChange={e => onChange({ startDate: e.target.value })} />
|
onChange={e => onChange({ startDate: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="End date">
|
<Field label="Period end">
|
||||||
<input className="rf-input" type="date" value={cfg.endDate}
|
<input className="rf-input" type="date" value={cfg.endDate}
|
||||||
onChange={e => onChange({ endDate: e.target.value })} />
|
onChange={e => onChange({ endDate: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -127,44 +238,169 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
|||||||
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="VAT">
|
<div className="rf-field">
|
||||||
<Toggle left="Excl. VAT" right="Incl. VAT" value={cfg.includeVAT}
|
<span className="rf-label">VAT</span>
|
||||||
onChange={v => onChange({ includeVAT: v })} />
|
<PillGroup
|
||||||
|
label="VAT"
|
||||||
|
options={[{ label: 'Excl. VAT', value: 'excl' }, { label: 'Incl. VAT', value: 'incl' }]}
|
||||||
|
value={cfg.includeVAT ? 'incl' : 'excl'}
|
||||||
|
onChange={v => onChange({ includeVAT: v === 'incl' })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="rf-check-row">
|
||||||
|
<input type="checkbox" checked={cfg.includeComparison}
|
||||||
|
onChange={e => onChange({ includeComparison: e.target.checked })} className="rf-checkbox" />
|
||||||
|
<span>Include comparison period</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.includeComparison && (
|
||||||
|
<div className="rf-comparison-block">
|
||||||
|
<div className="rf-comparison-label" aria-hidden="true">vs. period</div>
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="From">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonStartDate}
|
||||||
|
onChange={e => onChange({ comparisonStartDate: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="To">
|
||||||
<CheckRow label="Include previous year comparison"
|
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
|
||||||
checked={cfg.includeComparison} onChange={v => onChange({ includeComparison: v })} />
|
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
|
||||||
|
|
||||||
<SectionTitle>Content Sections</SectionTitle>
|
|
||||||
|
|
||||||
<CheckRow label="Executive summary" checked={cfg.showExecutiveSummary} onChange={v => onChange({ showExecutiveSummary: v })} />
|
|
||||||
<CheckRow label="Key metrics table" checked={cfg.showMetricsTable} onChange={v => onChange({ showMetricsTable: v })} />
|
|
||||||
<CheckRow label="Revenue trend chart" checked={cfg.showTrendChart} onChange={v => onChange({ showTrendChart: v })} />
|
|
||||||
<CheckRow label="Breakdown by museum" checked={cfg.showMuseumBreakdown} onChange={v => onChange({ showMuseumBreakdown: v })} />
|
|
||||||
<CheckRow label="Breakdown by channel" checked={cfg.showChannelBreakdown} onChange={v => onChange({ showChannelBreakdown: v })} />
|
|
||||||
<CheckRow label="Pilgrim capture rate" checked={cfg.showPilgrimCapture} onChange={v => onChange({ showPilgrimCapture: v })} />
|
|
||||||
|
|
||||||
<SectionTitle>Presentation</SectionTitle>
|
|
||||||
|
|
||||||
<Field label="Language">
|
|
||||||
<Toggle left="English" right="العربية" value={cfg.language === 'ar'}
|
|
||||||
onChange={v => onChange({ language: v ? 'ar' : 'en' })} />
|
|
||||||
</Field>
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label="Orientation">
|
<div className="rf-divider" />
|
||||||
<Toggle left="Portrait" right="Landscape" value={cfg.orientation === 'landscape'}
|
<h2 className="rf-group-label">Format</h2>
|
||||||
onChange={v => onChange({ orientation: v ? 'landscape' : 'portrait' })} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Confidentiality">
|
<div className="rf-field">
|
||||||
<select className="rf-input" value={cfg.confidentiality}
|
<span className="rf-label">Language</span>
|
||||||
onChange={e => onChange({ confidentiality: e.target.value as ReportConfig['confidentiality'] })}>
|
<PillGroup
|
||||||
<option value="Confidential">Confidential</option>
|
label="Language"
|
||||||
<option value="Internal">Internal</option>
|
options={[{ label: 'English', value: 'en' }, { label: 'العربية', value: 'ar' }]}
|
||||||
<option value="Public">Public</option>
|
value={cfg.language}
|
||||||
</select>
|
onChange={v => onChange({ language: v as 'en' | 'ar' })}
|
||||||
</Field>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Orientation</span>
|
||||||
|
<div className="rf-orient-row" role="group" aria-label="Page orientation">
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'portrait' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'portrait'}
|
||||||
|
onClick={() => onChange({ orientation: 'portrait' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--portrait" aria-hidden="true" />
|
||||||
|
<span>Portrait</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
className={`rf-orient-btn${cfg.orientation === 'landscape' ? ' rf-orient-btn--on' : ''}`}
|
||||||
|
aria-pressed={cfg.orientation === 'landscape'}
|
||||||
|
onClick={() => onChange({ orientation: 'landscape' })}>
|
||||||
|
<div className="rf-orient-page rf-orient-page--landscape" aria-hidden="true" />
|
||||||
|
<span>Landscape</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Confidentiality</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Confidentiality"
|
||||||
|
options={[
|
||||||
|
{ label: 'Confidential', value: 'Confidential' },
|
||||||
|
{ label: 'Internal', value: 'Internal' },
|
||||||
|
{ label: 'Public', value: 'Public' },
|
||||||
|
]}
|
||||||
|
value={cfg.confidentiality}
|
||||||
|
onChange={v => onChange({ confidentiality: v as ReportConfig['confidentiality'] })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right: content selection ── */}
|
||||||
|
<div className="rf-col">
|
||||||
|
<h2 className="rf-group-label">Report Sections</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Executive Summary"
|
||||||
|
enabled={cfg.showExecutiveSummary} onToggle={v => onChange({ showExecutiveSummary: v })} />
|
||||||
|
<ModuleCard title="Key Metrics Table"
|
||||||
|
enabled={cfg.showMetricsTable} onToggle={v => onChange({ showMetricsTable: v })} />
|
||||||
|
<ModuleCard title="Pilgrim Capture Rate"
|
||||||
|
enabled={cfg.showPilgrimCapture} onToggle={v => onChange({ showPilgrimCapture: v })} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Trend</h2>
|
||||||
|
|
||||||
|
<ModuleCard
|
||||||
|
title="Trend Chart"
|
||||||
|
enabled={cfg.showTrendChart}
|
||||||
|
onToggle={v => onChange({ showTrendChart: v })}
|
||||||
|
badge={cfg.showTrendChart && cfg.trendMetrics.length
|
||||||
|
? cfg.trendMetrics.map(m => m.charAt(0).toUpperCase() + m.slice(1)).join(' · ')
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<div className="rf-metric-pills" role="group" aria-label="Trend metrics to include">
|
||||||
|
{(['revenue', 'visitors', 'tickets'] as TrendMetric[]).map(m => {
|
||||||
|
const on = cfg.trendMetrics.includes(m);
|
||||||
|
return (
|
||||||
|
<button key={m} type="button"
|
||||||
|
className={`rf-metric-pill${on ? ' rf-metric-pill--on' : ''}`}
|
||||||
|
aria-pressed={on}
|
||||||
|
onClick={() => {
|
||||||
|
const next = on
|
||||||
|
? cfg.trendMetrics.filter(x => x !== m)
|
||||||
|
: [...cfg.trendMetrics, m];
|
||||||
|
onChange({ trendMetrics: next.length ? next : [m] });
|
||||||
|
}}>
|
||||||
|
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ModuleCard>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Breakdowns</h2>
|
||||||
|
|
||||||
|
<BreakdownModule title="Museums"
|
||||||
|
revenue={cfg.showMuseumRevenue} visitors={cfg.showMuseumVisitors} tickets={cfg.showMuseumTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showMuseumRevenue: p.revenue ?? cfg.showMuseumRevenue,
|
||||||
|
showMuseumVisitors: p.visitors ?? cfg.showMuseumVisitors,
|
||||||
|
showMuseumTickets: p.tickets ?? cfg.showMuseumTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Channels"
|
||||||
|
revenue={cfg.showChannelRevenue} visitors={cfg.showChannelVisitors} tickets={cfg.showChannelTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showChannelRevenue: p.revenue ?? cfg.showChannelRevenue,
|
||||||
|
showChannelVisitors: p.visitors ?? cfg.showChannelVisitors,
|
||||||
|
showChannelTickets: p.tickets ?? cfg.showChannelTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<BreakdownModule title="Districts"
|
||||||
|
revenue={cfg.showDistrictRevenue} visitors={cfg.showDistrictVisitors} tickets={cfg.showDistrictTickets}
|
||||||
|
onChange={p => onChange({
|
||||||
|
showDistrictRevenue: p.revenue ?? cfg.showDistrictRevenue,
|
||||||
|
showDistrictVisitors: p.visitors ?? cfg.showDistrictVisitors,
|
||||||
|
showDistrictTickets: p.tickets ?? cfg.showDistrictTickets,
|
||||||
|
})} />
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Summary</h2>
|
||||||
|
|
||||||
|
<ModuleCard title="Global Performance Table"
|
||||||
|
enabled={cfg.showGlobalSummary} onToggle={v => onChange({ showGlobalSummary: v })}>
|
||||||
|
{!cfg.includeComparison && (
|
||||||
|
<p className="rf-module-note">
|
||||||
|
Enable a comparison period to show progression data.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ModuleCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { pdf } from '@react-pdf/renderer';
|
import { pdf } from '@react-pdf/renderer';
|
||||||
import type { MuseumRecord } from '../../types';
|
import type { MuseumRecord } from '../../types';
|
||||||
import { DEFAULT_CONFIG, computeReportData } from './reportHelpers';
|
import { DEFAULT_CONFIG, computeReportData } from './reportHelpers';
|
||||||
import type { ReportConfig } from './reportHelpers';
|
import type { ReportConfig } from './reportHelpers';
|
||||||
import { ReportDocument } from './ReportDocument';
|
import { ReportDocument } from './ReportDocument';
|
||||||
import ReportForm from './ReportForm';
|
import ReportForm from './ReportForm';
|
||||||
import ReportPreview from './ReportPreview';
|
|
||||||
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
|
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,18 +14,44 @@ interface Props {
|
|||||||
export default function ReportPage({ data }: Props) {
|
export default function ReportPage({ data }: Props) {
|
||||||
const [config, setConfig] = useState<ReportConfig>(DEFAULT_CONFIG);
|
const [config, setConfig] = useState<ReportConfig>(DEFAULT_CONFIG);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const allMuseums = getUniqueMuseums(data);
|
// H8: memoize — these scan the full records array; re-running on every patch is wasteful
|
||||||
const allChannels = getUniqueChannels(data);
|
const allMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||||
|
const allChannels = useMemo(() => getUniqueChannels(data), [data]);
|
||||||
|
|
||||||
const patch = useCallback((p: Partial<ReportConfig>) => setConfig(c => ({ ...c, ...p })), []);
|
const patch = useCallback((p: Partial<ReportConfig>) => setConfig(c => ({ ...c, ...p })), []);
|
||||||
|
|
||||||
|
// C2: auto-clear inline error after 6 s
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorMsg) return;
|
||||||
|
const t = setTimeout(() => setErrorMsg(null), 6000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [errorMsg]);
|
||||||
|
|
||||||
|
const sectionCount = useMemo(() => [
|
||||||
|
config.showExecutiveSummary,
|
||||||
|
config.showMetricsTable,
|
||||||
|
config.showPilgrimCapture,
|
||||||
|
config.showTrendChart,
|
||||||
|
config.showMuseumRevenue || config.showMuseumVisitors || config.showMuseumTickets,
|
||||||
|
config.showChannelRevenue || config.showChannelVisitors || config.showChannelTickets,
|
||||||
|
config.showDistrictRevenue || config.showDistrictVisitors || config.showDistrictTickets,
|
||||||
|
config.showGlobalSummary && config.includeComparison,
|
||||||
|
].filter(Boolean).length, [config]);
|
||||||
|
|
||||||
|
const periodLabel = config.startDate && config.endDate
|
||||||
|
? `${config.startDate.slice(0, 7)} to ${config.endDate.slice(0, 7)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (config.startDate > config.endDate) {
|
if (config.startDate > config.endDate) {
|
||||||
alert('End date must be after start date.');
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('End date must be after start date.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
|
setErrorMsg(null);
|
||||||
try {
|
try {
|
||||||
const reportData = computeReportData(data, config);
|
const reportData = computeReportData(data, config);
|
||||||
const blob = await pdf(<ReportDocument data={reportData} />).toBlob();
|
const blob = await pdf(<ReportDocument data={reportData} />).toBlob();
|
||||||
@@ -44,7 +69,8 @@ export default function ReportPage({ data }: Props) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF generation failed:', err);
|
console.error('PDF generation failed:', err);
|
||||||
alert('Failed to generate PDF. Please try again.');
|
// C2: inline error instead of alert()
|
||||||
|
setErrorMsg('Failed to generate PDF. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
@@ -52,28 +78,58 @@ export default function ReportPage({ data }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="report-page">
|
<div className="report-page">
|
||||||
|
{/* L2: aria-live region for screen reader status announcements */}
|
||||||
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
|
{generating ? 'Generating PDF, please wait.' : ''}
|
||||||
|
{errorMsg ? `Error: ${errorMsg}` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="report-header">
|
<div className="report-header">
|
||||||
<h1 className="report-title">Report Builder</h1>
|
<h1 className="report-title">Report Builder</h1>
|
||||||
<p className="report-sub">Configure and download a client-ready PDF report.</p>
|
{/* M5: removed generic filler subtitle */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="report-body">
|
<div className="report-body">
|
||||||
<div className="report-form-col">
|
<div className="report-form-col">
|
||||||
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
||||||
</div>
|
</div>
|
||||||
<div className="report-preview-col">
|
|
||||||
<div className="report-preview-sticky">
|
|
||||||
<ReportPreview config={config} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="report-footer-bar">
|
<div className="report-footer-bar">
|
||||||
|
<div className="report-footer-meta">
|
||||||
|
{/* H5: report-footer-chip--count stays visible on mobile; others hide */}
|
||||||
|
<span className="report-footer-chip report-footer-chip--count">
|
||||||
|
{sectionCount} section{sectionCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{periodLabel && (
|
||||||
|
<>
|
||||||
|
{/* L1: aria-hidden on decorative separators */}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">{periodLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip">
|
||||||
|
{config.orientation === 'landscape' ? 'Landscape' : 'Portrait'}
|
||||||
|
</span>
|
||||||
|
{config.includeComparison && (
|
||||||
|
<>
|
||||||
|
<span className="report-footer-dot" aria-hidden="true" />
|
||||||
|
<span className="report-footer-chip report-footer-chip--compare">With comparison</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* C2: inline error message */}
|
||||||
|
{errorMsg && (
|
||||||
|
<span className="report-footer-error" role="alert">{errorMsg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="report-generate-btn"
|
className="report-generate-btn"
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
|
aria-busy={generating}
|
||||||
>
|
>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
|
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
export const CHART_PALETTE = [
|
||||||
|
'#2563eb', '#0891b2', '#7c3aed', '#059669',
|
||||||
|
'#d97706', '#dc2626', '#db2777', '#f59e0b',
|
||||||
|
'#10b981', '#6366f1', '#0284c7', '#65a30d',
|
||||||
|
];
|
||||||
|
|
||||||
|
function fmtAxis(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
|
return String(Math.round(v));
|
||||||
|
}
|
||||||
|
|
||||||
interface TrendChartProps {
|
interface TrendChartProps {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
current: number[];
|
current: number[];
|
||||||
previous: number[] | null;
|
previous: number[] | null;
|
||||||
color: string;
|
color: string;
|
||||||
|
series?: Array<{ label: string; color: string; data: number[] }>;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfTrendChart({ labels, current, previous, color, width = 460, height = 140 }: TrendChartProps) {
|
export function PdfTrendChart({ labels, current, previous, color, series, width = 470, height = 155 }: TrendChartProps) {
|
||||||
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
|
const seriesValues = (series ?? []).flatMap(s => s.data);
|
||||||
|
const allValues = [...current, ...(previous ?? []), ...seriesValues].filter(v => v > 0);
|
||||||
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
|
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
|
||||||
const padL = 8, padR = 8, padT = 8, padB = 18; // padB=18 leaves room for x-axis labels
|
// padL wide enough for y-axis labels like "1.2M"
|
||||||
|
const padL = 38, padR = 8, padT = 10, padB = 20;
|
||||||
const w = width - padL - padR;
|
const w = width - padL - padR;
|
||||||
const h = height - padT - padB;
|
const h = height - padT - padB;
|
||||||
|
|
||||||
@@ -27,20 +43,44 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Svg width={width} height={height}>
|
<Svg width={width} height={height}>
|
||||||
{gridLines.map(f => (
|
{/* Baseline */}
|
||||||
<Line key={f}
|
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
|
||||||
x1={padL} y1={sy(max * f).toFixed(1)}
|
stroke="#cbd5e1" strokeWidth={0.75} />
|
||||||
x2={width - padR} y2={sy(max * f).toFixed(1)}
|
|
||||||
|
{/* Grid lines + Y-axis labels */}
|
||||||
|
{gridLines.map(f => {
|
||||||
|
const yPos = sy(max * f);
|
||||||
|
return (
|
||||||
|
<G key={f}>
|
||||||
|
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
|
||||||
stroke="#e2e8f0" strokeWidth={0.5} />
|
stroke="#e2e8f0" strokeWidth={0.5} />
|
||||||
))}
|
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
|
||||||
|
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
|
||||||
|
{fmtAxis(max * f)}
|
||||||
|
</SvgText>
|
||||||
|
</G>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Comparison line (dashed) */}
|
||||||
{previous && previous.some(v => v > 0) && (
|
{previous && previous.some(v => v > 0) && (
|
||||||
<Polyline points={toPoints(previous)}
|
<Polyline points={toPoints(previous)}
|
||||||
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Per-museum series */}
|
||||||
|
{(series ?? []).map(s => s.data.some(v => v > 0) && (
|
||||||
|
<Polyline key={s.label} points={toPoints(s.data)}
|
||||||
|
stroke={s.color} strokeWidth={1.5} fill="none" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Current period total line */}
|
||||||
{current.some(v => v > 0) && (
|
{current.some(v => v > 0) && (
|
||||||
<Polyline points={toPoints(current)}
|
<Polyline points={toPoints(current)}
|
||||||
stroke={color} strokeWidth={2.5} fill="none" />
|
stroke={color} strokeWidth={series && series.length >= 2 ? 2 : 2.5} fill="none" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* X-axis week labels */}
|
||||||
{labels
|
{labels
|
||||||
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
|
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
|
||||||
.map((label) => {
|
.map((label) => {
|
||||||
@@ -60,15 +100,15 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
|
|||||||
interface HBarChartProps {
|
interface HBarChartProps {
|
||||||
items: Array<{ name: string; value: number }>;
|
items: Array<{ name: string; value: number }>;
|
||||||
color: string;
|
color: string;
|
||||||
|
usepalette?: boolean;
|
||||||
width?: number;
|
width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
|
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
|
||||||
const barH = 16;
|
const barH = 17;
|
||||||
const gap = 10;
|
const gap = 10;
|
||||||
const labelW = 150;
|
const labelW = 160;
|
||||||
const valueW = 70;
|
const barAreaW = width - labelW - 20;
|
||||||
const barAreaW = width - labelW - valueW - 8;
|
|
||||||
const max = Math.max(...items.map(i => i.value), 1);
|
const max = Math.max(...items.map(i => i.value), 1);
|
||||||
const totalH = items.length * (barH + gap);
|
const totalH = items.length * (barH + gap);
|
||||||
|
|
||||||
@@ -77,13 +117,21 @@ export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
|
|||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
const y = i * (barH + gap);
|
const y = i * (barH + gap);
|
||||||
const bw = Math.max((item.value / max) * barAreaW, 2);
|
const bw = Math.max((item.value / max) * barAreaW, 2);
|
||||||
const shortName = item.name.length > 22 ? item.name.slice(0, 22) + '…' : item.name;
|
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
|
||||||
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
|
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
|
||||||
|
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
|
||||||
|
const isShort = bw < 48;
|
||||||
return (
|
return (
|
||||||
<G key={item.name}>
|
<G key={item.name + i}>
|
||||||
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8 }}>{shortName}</SvgText>
|
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
|
||||||
<Rect x={labelW} y={y} width={bw} height={barH} fill={color} rx={3} />
|
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
|
||||||
<SvgText x={labelW + bw + 4} y={y + barH - 4} fill="#64748b" style={{ fontSize: 8 }}>{valueStr}</SvgText>
|
{isShort ? (
|
||||||
|
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
|
||||||
|
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
|
||||||
|
) : (
|
||||||
|
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
|
||||||
|
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
|
||||||
|
)}
|
||||||
</G>
|
</G>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, umrahData } from '../../services/dataService';
|
import { filterDataByDateRange, calculateMetrics, groupByMuseum, groupByChannel, groupByDistrict, umrahData } from '../../services/dataService';
|
||||||
import { shiftYear } from '../../lib/dateHelpers';
|
import { shiftYear } from '../../lib/dateHelpers';
|
||||||
import type { MuseumRecord, Metrics } from '../../types';
|
import type { MuseumRecord, Metrics } from '../../types';
|
||||||
|
|
||||||
// ─── config ───────────────────────────────────────────────────────
|
// ─── config ───────────────────────────────────────────────────────
|
||||||
|
export type TrendMetric = 'revenue' | 'visitors' | 'tickets';
|
||||||
|
export type TrendGranularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
function inferGranularity(start: string, end: string): TrendGranularity {
|
||||||
|
const days = Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86400000);
|
||||||
|
if (days > 180) return 'month';
|
||||||
|
if (days >= 14) return 'week';
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
|
||||||
|
const _start = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10);
|
||||||
|
const _end = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
export interface ReportConfig {
|
export interface ReportConfig {
|
||||||
title: string;
|
title: string;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
@@ -15,12 +28,30 @@ export interface ReportConfig {
|
|||||||
selectedChannels: string[];
|
selectedChannels: string[];
|
||||||
includeVAT: boolean;
|
includeVAT: boolean;
|
||||||
includeComparison: boolean;
|
includeComparison: boolean;
|
||||||
|
comparisonStartDate: string;
|
||||||
|
comparisonEndDate: string;
|
||||||
|
// Summary & metrics
|
||||||
showExecutiveSummary: boolean;
|
showExecutiveSummary: boolean;
|
||||||
showMetricsTable: boolean;
|
showMetricsTable: boolean;
|
||||||
showTrendChart: boolean;
|
|
||||||
showMuseumBreakdown: boolean;
|
|
||||||
showChannelBreakdown: boolean;
|
|
||||||
showPilgrimCapture: boolean;
|
showPilgrimCapture: boolean;
|
||||||
|
// Trend chart
|
||||||
|
showTrendChart: boolean;
|
||||||
|
trendMetrics: TrendMetric[];
|
||||||
|
// Museum mini-reports
|
||||||
|
showMuseumRevenue: boolean;
|
||||||
|
showMuseumVisitors: boolean;
|
||||||
|
showMuseumTickets: boolean;
|
||||||
|
// Channel breakdowns
|
||||||
|
showChannelRevenue: boolean;
|
||||||
|
showChannelVisitors: boolean;
|
||||||
|
showChannelTickets: boolean;
|
||||||
|
// District breakdowns
|
||||||
|
showDistrictRevenue: boolean;
|
||||||
|
showDistrictVisitors: boolean;
|
||||||
|
showDistrictTickets: boolean;
|
||||||
|
// Global summary table
|
||||||
|
showGlobalSummary: boolean;
|
||||||
|
// Presentation
|
||||||
language: 'en' | 'ar';
|
language: 'en' | 'ar';
|
||||||
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
||||||
orientation: 'portrait' | 'landscape';
|
orientation: 'portrait' | 'landscape';
|
||||||
@@ -32,18 +63,29 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
|||||||
contactName: '',
|
contactName: '',
|
||||||
clientLogoBase64: null,
|
clientLogoBase64: null,
|
||||||
accentColor: '#2563eb',
|
accentColor: '#2563eb',
|
||||||
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10),
|
startDate: _start,
|
||||||
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10),
|
endDate: _end,
|
||||||
selectedMuseums: [],
|
selectedMuseums: [],
|
||||||
selectedChannels: [],
|
selectedChannels: [],
|
||||||
includeVAT: true,
|
includeVAT: true,
|
||||||
includeComparison: true,
|
includeComparison: true,
|
||||||
|
comparisonStartDate: shiftYear(_start),
|
||||||
|
comparisonEndDate: shiftYear(_end),
|
||||||
showExecutiveSummary: true,
|
showExecutiveSummary: true,
|
||||||
showMetricsTable: true,
|
showMetricsTable: true,
|
||||||
showTrendChart: true,
|
|
||||||
showMuseumBreakdown: true,
|
|
||||||
showChannelBreakdown: true,
|
|
||||||
showPilgrimCapture: true,
|
showPilgrimCapture: true,
|
||||||
|
showTrendChart: true,
|
||||||
|
trendMetrics: ['revenue'],
|
||||||
|
showMuseumRevenue: true,
|
||||||
|
showMuseumVisitors: true,
|
||||||
|
showMuseumTickets: false,
|
||||||
|
showChannelRevenue: false,
|
||||||
|
showChannelVisitors: true,
|
||||||
|
showChannelTickets: false,
|
||||||
|
showDistrictRevenue: false,
|
||||||
|
showDistrictVisitors: false,
|
||||||
|
showDistrictTickets: false,
|
||||||
|
showGlobalSummary: true,
|
||||||
language: 'en',
|
language: 'en',
|
||||||
confidentiality: 'Confidential',
|
confidentiality: 'Confidential',
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
@@ -52,16 +94,36 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
|||||||
// ─── computed report data ─────────────────────────────────────────
|
// ─── computed report data ─────────────────────────────────────────
|
||||||
export interface BreakdownItem { name: string; value: number; }
|
export interface BreakdownItem { name: string; value: number; }
|
||||||
|
|
||||||
|
export interface DimensionBreakdown {
|
||||||
|
revenue: BreakdownItem[];
|
||||||
|
visitors: BreakdownItem[];
|
||||||
|
tickets: BreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MuseumDataRow {
|
||||||
|
name: string;
|
||||||
|
curr: { revenue: number; visitors: number; tickets: number };
|
||||||
|
prev: { revenue: number; visitors: number; tickets: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendChart {
|
||||||
|
metric: TrendMetric;
|
||||||
|
labels: string[];
|
||||||
|
current: number[];
|
||||||
|
previous: number[] | null;
|
||||||
|
museums: Array<{ name: string; values: number[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReportData {
|
export interface ReportData {
|
||||||
config: ReportConfig;
|
config: ReportConfig;
|
||||||
metrics: Metrics;
|
metrics: Metrics;
|
||||||
prevMetrics: Metrics | null;
|
prevMetrics: Metrics | null;
|
||||||
trendLabels: string[];
|
comparisonPeriodLabel: string;
|
||||||
trendCurrent: number[];
|
trendCharts: TrendChart[];
|
||||||
trendPrevious: number[] | null;
|
museumData: MuseumDataRow[];
|
||||||
museumBreakdown: BreakdownItem[]; // revenue by museum
|
museumBreakdown: DimensionBreakdown;
|
||||||
museumVisitorBreakdown: BreakdownItem[]; // visitors by museum
|
channelBreakdown: DimensionBreakdown;
|
||||||
channelBreakdown: BreakdownItem[];
|
districtBreakdown: DimensionBreakdown;
|
||||||
pilgrimCapture: { current: number; previous: number | null } | null;
|
pilgrimCapture: { current: number; previous: number | null } | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -93,63 +155,91 @@ function estimatePilgrims(start: string, end: string): number | null {
|
|||||||
return has ? Math.round(total) : null;
|
return has ? Math.round(total) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
|
function getMetricVal(r: MuseumRecord, metric: TrendMetric, includeVAT: boolean): number {
|
||||||
|
if (metric === 'visitors') return r.visits || 0;
|
||||||
|
if (metric === 'tickets') return r.tickets || 0;
|
||||||
|
return (includeVAT ? r.revenue_gross : r.revenue_net) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
function buildTrend(rows: MuseumRecord[], start: string, metric: TrendMetric, includeVAT: boolean, gran: TrendGranularity): { labels: string[]; values: number[] } {
|
||||||
const s = new Date(start);
|
const s = new Date(start);
|
||||||
const acc: Record<number, MuseumRecord[]> = {};
|
const acc: Record<number, MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
if (!r.date) return;
|
if (!r.date) return;
|
||||||
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
const diff = Math.floor((new Date(r.date).getTime() - s.getTime()) / 86400000);
|
||||||
const key = Math.floor(diff / 7) + 1;
|
const key = gran === 'month' ? Math.floor(diff / 30) + 1 : gran === 'week' ? Math.floor(diff / 7) + 1 : diff + 1;
|
||||||
if (!acc[key]) acc[key] = [];
|
if (!acc[key]) acc[key] = [];
|
||||||
acc[key].push(r);
|
acc[key].push(r);
|
||||||
});
|
});
|
||||||
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
const maxK = Math.max(...Object.keys(acc).map(Number), 1);
|
||||||
const labels = Array.from({ length: maxK }, (_, i) => `W${i + 1}`);
|
const labels = Array.from({ length: maxK }, (_, i) => {
|
||||||
|
if (gran === 'month') return MONTH_SHORT[(s.getMonth() + i) % 12];
|
||||||
|
if (gran === 'week') return `W${i + 1}`;
|
||||||
|
return `${i + 1}`;
|
||||||
|
});
|
||||||
const values = labels.map((_, i) => {
|
const values = labels.map((_, i) => {
|
||||||
const group = acc[i + 1] || [];
|
const group = acc[i + 1] || [];
|
||||||
return group.reduce((s, r) => s + (cfg.includeVAT ? r.revenue_gross : r.revenue_net) || 0, 0);
|
return group.reduce((sum, r) => sum + getMetricVal(r, metric, includeVAT), 0);
|
||||||
});
|
});
|
||||||
return { labels, values };
|
return { labels, values };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeDimensionBreakdown(g: Record<string, { revenue: number; visitors: number; tickets: number }>, limit = 10): DimensionBreakdown {
|
||||||
|
const entries = Object.entries(g);
|
||||||
|
const sort = (key: 'revenue' | 'visitors' | 'tickets') =>
|
||||||
|
entries.map(([name, v]) => ({ name, value: v[key] })).sort((a, b) => b.value - a.value).slice(0, limit);
|
||||||
|
return { revenue: sort('revenue'), visitors: sort('visitors'), tickets: sort('tickets') };
|
||||||
|
}
|
||||||
|
|
||||||
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
|
export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): ReportData {
|
||||||
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
|
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
|
||||||
const metrics = calculateMetrics(currRows, cfg.includeVAT);
|
const metrics = calculateMetrics(currRows, cfg.includeVAT);
|
||||||
|
|
||||||
const prevStart = shiftYear(cfg.startDate);
|
|
||||||
const prevEnd = shiftYear(cfg.endDate);
|
|
||||||
const prevRows = cfg.includeComparison
|
const prevRows = cfg.includeComparison
|
||||||
? applyDimFilters(filterDataByDateRange(allData, prevStart, prevEnd, {}), cfg)
|
? applyDimFilters(filterDataByDateRange(allData, cfg.comparisonStartDate, cfg.comparisonEndDate, {}), cfg)
|
||||||
: [];
|
: [];
|
||||||
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
|
const prevMetrics = cfg.includeComparison ? calculateMetrics(prevRows, cfg.includeVAT) : null;
|
||||||
|
|
||||||
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
const comparisonPeriodLabel = cfg.includeComparison
|
||||||
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, prevStart, cfg) : null;
|
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||||
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
: '';
|
||||||
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
|
||||||
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
const gran = inferGranularity(cfg.startDate, cfg.endDate);
|
||||||
const trendPrevious = prevTrend
|
const museumNames = Object.keys(groupByMuseum(currRows, cfg.includeVAT))
|
||||||
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
.filter(name => currRows.some(r => r.museum_name === name));
|
||||||
|
|
||||||
|
const trendCharts: TrendChart[] = cfg.trendMetrics.map(metric => {
|
||||||
|
const currT = buildTrend(currRows, cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
const prevT = cfg.includeComparison
|
||||||
|
? buildTrend(prevRows, cfg.comparisonStartDate, metric, cfg.includeVAT, gran)
|
||||||
: null;
|
: null;
|
||||||
|
const maxLen = Math.max(currT.labels.length, prevT ? prevT.values.length : 0, 1);
|
||||||
|
const labels = Array.from({ length: maxLen }, (_, i) => currT.labels[i] ?? `${i + 1}`);
|
||||||
|
const current = Array.from({ length: maxLen }, (_, i) => currT.values[i] ?? 0);
|
||||||
|
const previous = prevT ? Array.from({ length: maxLen }, (_, i) => prevT.values[i] ?? 0) : null;
|
||||||
|
const museums = museumNames.map(name => {
|
||||||
|
const mt = buildTrend(currRows.filter(r => r.museum_name === name), cfg.startDate, metric, cfg.includeVAT, gran);
|
||||||
|
return { name, values: Array.from({ length: maxLen }, (_, i) => mt.values[i] ?? 0) };
|
||||||
|
}).filter(m => m.values.some(v => v > 0));
|
||||||
|
return { metric, labels, current, previous, museums };
|
||||||
|
});
|
||||||
|
|
||||||
const musG = groupByMuseum(currRows, cfg.includeVAT);
|
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
||||||
const museumBreakdown: BreakdownItem[] = Object.entries(musG)
|
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
|
||||||
.map(([name, g]) => ({ name, value: g.revenue }))
|
const museumData: MuseumDataRow[] = Object.entries(currMuseumGroups)
|
||||||
.sort((a, b) => b.value - a.value)
|
.map(([name, curr]) => ({ name, curr, prev: prevMuseumGroups[name] ?? null }))
|
||||||
.slice(0, 10);
|
.sort((a, b) => b.curr.revenue - a.curr.revenue);
|
||||||
|
|
||||||
const museumVisitorBreakdown: BreakdownItem[] = Object.entries(musG)
|
const museumBreakdown = makeDimensionBreakdown(currMuseumGroups);
|
||||||
.map(([name, g]) => ({ name, value: g.visitors }))
|
const channelBreakdown = makeDimensionBreakdown(groupByChannel(currRows, cfg.includeVAT), 20);
|
||||||
.sort((a, b) => b.value - a.value)
|
const districtBreakdown = makeDimensionBreakdown(groupByDistrict(currRows, cfg.includeVAT));
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
const chanG = groupByChannel(currRows, cfg.includeVAT);
|
|
||||||
const channelBreakdown: BreakdownItem[] = Object.entries(chanG)
|
|
||||||
.map(([name, g]) => ({ name, value: g.visitors }))
|
|
||||||
.sort((a, b) => b.value - a.value);
|
|
||||||
|
|
||||||
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
|
const currPilgrims = estimatePilgrims(cfg.startDate, cfg.endDate);
|
||||||
const prevPilgrims = cfg.includeComparison ? estimatePilgrims(prevStart, prevEnd) : null;
|
const prevPilgrims = cfg.includeComparison
|
||||||
|
? estimatePilgrims(cfg.comparisonStartDate, cfg.comparisonEndDate)
|
||||||
|
: null;
|
||||||
const pilgrimCapture = currPilgrims !== null
|
const pilgrimCapture = currPilgrims !== null
|
||||||
? {
|
? {
|
||||||
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
|
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
|
||||||
@@ -163,12 +253,12 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
metrics,
|
metrics,
|
||||||
prevMetrics,
|
prevMetrics,
|
||||||
trendLabels,
|
comparisonPeriodLabel,
|
||||||
trendCurrent,
|
trendCharts,
|
||||||
trendPrevious,
|
museumData,
|
||||||
museumBreakdown,
|
museumBreakdown,
|
||||||
museumVisitorBreakdown,
|
|
||||||
channelBreakdown,
|
channelBreakdown,
|
||||||
|
districtBreakdown,
|
||||||
pilgrimCapture,
|
pilgrimCapture,
|
||||||
generatedAt: new Date().toLocaleDateString('en-GB'),
|
generatedAt: new Date().toLocaleDateString('en-GB'),
|
||||||
};
|
};
|
||||||
@@ -197,14 +287,14 @@ export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar')
|
|||||||
|
|
||||||
// ─── executive summary ────────────────────────────────────────────
|
// ─── executive summary ────────────────────────────────────────────
|
||||||
export function generateExecutiveSummary(data: ReportData): string {
|
export function generateExecutiveSummary(data: ReportData): string {
|
||||||
const { config: cfg, metrics, prevMetrics, channelBreakdown } = data;
|
const { config: cfg, metrics, prevMetrics, channelBreakdown, comparisonPeriodLabel } = data;
|
||||||
const lang = cfg.language;
|
const lang = cfg.language;
|
||||||
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
const period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||||
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
||||||
const topChannel = channelBreakdown[0]?.name ?? '';
|
const topChannel = channelBreakdown.visitors[0]?.name ?? '';
|
||||||
const totalVisitors = channelBreakdown.reduce((s, i) => s + i.value, 0);
|
const totalVisitors = channelBreakdown.visitors.reduce((s, i) => s + i.value, 0);
|
||||||
const topPct = totalVisitors > 0 && channelBreakdown[0]
|
const topPct = totalVisitors > 0 && channelBreakdown.visitors[0]
|
||||||
? Math.round((channelBreakdown[0].value / totalVisitors) * 100)
|
? Math.round((channelBreakdown.visitors[0].value / totalVisitors) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const museumLabel = cfg.selectedMuseums.length > 0
|
const museumLabel = cfg.selectedMuseums.length > 0
|
||||||
? cfg.selectedMuseums.join(', ')
|
? cfg.selectedMuseums.join(', ')
|
||||||
@@ -214,7 +304,7 @@ export function generateExecutiveSummary(data: ReportData): string {
|
|||||||
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
|
let s = `During ${period}, ${museumLabel} recorded ${metrics.visitors.toLocaleString()} visitors and ${revenue} in revenue.`;
|
||||||
if (prevMetrics && prevMetrics.revenue > 0) {
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
s += ` This represents a ${formatPct(chg)} change in revenue versus the same period last year.`;
|
s += ` This represents a ${formatPct(chg)} change in revenue versus ${comparisonPeriodLabel}.`;
|
||||||
}
|
}
|
||||||
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
|
if (topChannel) s += ` The top-performing channel was ${topChannel} with ${topPct}% of total visitors.`;
|
||||||
return s;
|
return s;
|
||||||
@@ -222,7 +312,7 @@ export function generateExecutiveSummary(data: ReportData): string {
|
|||||||
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
|
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
|
||||||
if (prevMetrics && prevMetrics.revenue > 0) {
|
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||||
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||||
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بالفترة ذاتها من العام الماضي.`;
|
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
|
||||||
}
|
}
|
||||||
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
||||||
return s;
|
return s;
|
||||||
@@ -231,8 +321,10 @@ export function generateExecutiveSummary(data: ReportData): string {
|
|||||||
|
|
||||||
// ─── page count estimator ─────────────────────────────────────────
|
// ─── page count estimator ─────────────────────────────────────────
|
||||||
export function estimatePageCount(cfg: ReportConfig): number {
|
export function estimatePageCount(cfg: ReportConfig): number {
|
||||||
let pages = 2; // cover + first content page
|
let pages = 2; // cover + summary/metrics/trend
|
||||||
if (cfg.showMuseumBreakdown) pages += 1;
|
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) pages += 1;
|
||||||
if (cfg.showChannelBreakdown) pages += 1;
|
if (cfg.showChannelRevenue || cfg.showChannelVisitors || cfg.showChannelTickets) pages += 1;
|
||||||
|
if (cfg.showDistrictRevenue || cfg.showDistrictVisitors || cfg.showDistrictTickets) pages += 1;
|
||||||
|
if (cfg.showGlobalSummary && cfg.includeComparison) pages += 1;
|
||||||
return pages;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
// ─── multi-select ─────────────────────────────────────────────────
|
// ─── multi-select ─────────────────────────────────────────────────
|
||||||
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel }: {
|
export default function AltMultiSelect({ value, options, onChange, allLabel, countLabel, clearLabel, labelFn }: {
|
||||||
value: string[]; options: string[];
|
value: string[]; options: string[];
|
||||||
onChange: (vals: string[]) => void;
|
onChange: (vals: string[]) => void;
|
||||||
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
allLabel: string; countLabel: (n: number) => string; clearLabel: string;
|
||||||
|
labelFn?: (opt: string) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const display = labelFn ?? ((opt: string) => opt);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,7 +17,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
const toggle = (opt: string) => onChange(value.includes(opt) ? value.filter(v => v !== opt) : [...value, opt]);
|
||||||
const label = value.length === 0 ? allLabel : value.length === 1 ? value[0] : countLabel(value.length);
|
const label = value.length === 0 ? allLabel : value.length === 1 ? display(value[0]) : countLabel(value.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="altms">
|
<div ref={ref} className="altms">
|
||||||
@@ -32,7 +34,7 @@ export default function AltMultiSelect({ value, options, onChange, allLabel, cou
|
|||||||
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
<label key={opt} role="option" aria-selected={value.includes(opt)} className={`altms-option${value.includes(opt) ? ' altms-option--checked' : ''}`}>
|
||||||
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
<input type="checkbox" className="altms-check" checked={value.includes(opt)} onChange={() => toggle(opt)} aria-label={opt} />
|
||||||
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
<span className="altms-check-box">{value.includes(opt) && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4L3.5 6.5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}</span>
|
||||||
<span className="altms-opt-label">{opt}</span>
|
<span className="altms-opt-label">{display(opt)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ ChartJS.register(
|
|||||||
Annotation
|
Annotation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Used for the "Total" line in multi-museum trend charts — always distinct from chartPalette.
|
||||||
|
export const TOTAL_COLOR = '#1e293b';
|
||||||
|
|
||||||
export const chartColors = {
|
export const chartColors = {
|
||||||
primary: '#2563eb',
|
primary: '#2563eb',
|
||||||
secondary: '#7c3aed',
|
secondary: '#7c3aed',
|
||||||
@@ -113,7 +116,9 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
|||||||
titleFont: { size: 12 },
|
titleFont: { size: 12 },
|
||||||
bodyFont: { size: 11 },
|
bodyFont: { size: 11 },
|
||||||
rtl: false,
|
rtl: false,
|
||||||
textDirection: 'ltr'
|
textDirection: 'ltr',
|
||||||
|
usePointStyle: true,
|
||||||
|
boxPadding: 6,
|
||||||
},
|
},
|
||||||
datalabels: createDataLabelConfig(showDataLabels, {
|
datalabels: createDataLabelConfig(showDataLabels, {
|
||||||
color: theme.textPrimary,
|
color: theme.textPrimary,
|
||||||
@@ -134,6 +139,33 @@ export const createBaseOptions = (showDataLabels: boolean): any => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hover-dim + end-of-line name labels for multi-museum trend charts.
|
||||||
|
// Only activates for charts that have datasets marked with _isMuseumLine.
|
||||||
|
const trendLinePlugin = {
|
||||||
|
id: 'trendLineOverlay',
|
||||||
|
|
||||||
|
// ── hover dim ──────────────────────────────────────────────────
|
||||||
|
beforeDatasetDraw(chart: any, args: any) {
|
||||||
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
|
const active = chart.getActiveElements();
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (active[0].datasetIndex !== args.index) {
|
||||||
|
chart.ctx.save();
|
||||||
|
chart.ctx.globalAlpha = 0.15;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterDatasetDraw(chart: any, args: any) {
|
||||||
|
if (!chart.data.datasets.some((ds: any) => ds._isMuseumLine)) return;
|
||||||
|
const active = chart.getActiveElements();
|
||||||
|
if (active.length > 0 && active[0].datasetIndex !== args.index) {
|
||||||
|
chart.ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
ChartJS.register(trendLinePlugin);
|
||||||
|
|
||||||
export const lineDatasetDefaults = {
|
export const lineDatasetDefaults = {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
tension: 0.4,
|
tension: 0.4,
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ export let umrahData: UmrahData = {
|
|||||||
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
2025: { 1: 15222497, 2: 5443393, 3: 26643148, 4: 31591871 }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function fetchMuseumTranslations(): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const tables = await discoverTableIds();
|
||||||
|
if (!tables['Museums']) return {};
|
||||||
|
const rows = await fetchNocoDBTable<{ Name: string; NameAr: string }>(tables['Museums']);
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.Name && r.NameAr) map[r.Name] = r.NameAr;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
export async function fetchPilgrimStats(): Promise<UmrahData> {
|
||||||
try {
|
try {
|
||||||
const tables = await discoverTableIds();
|
const tables = await discoverTableIds();
|
||||||
|
|||||||
Reference in New Issue
Block a user