Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1070490ad2 | |||
| c858075232 |
+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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
|
|||||||
@@ -128,7 +128,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 +142,36 @@ 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' });
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
data: {
|
||||||
{ 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 },
|
labels,
|
||||||
{ 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 },
|
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(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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
||||||
|
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[];
|
||||||
@@ -173,6 +192,18 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
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,
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
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 },
|
||||||
@@ -274,7 +305,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
{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>
|
</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">
|
||||||
|
|||||||
@@ -88,7 +88,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,18 +102,37 @@ 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;
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
data: {
|
||||||
{ 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] },
|
labels,
|
||||||
{ 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 },
|
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: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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const g = groupByMuseum(filteredData, includeVAT);
|
const g = groupByMuseum(filteredData, includeVAT);
|
||||||
@@ -172,6 +191,19 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
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,
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
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: {
|
||||||
@@ -250,7 +282,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
{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>
|
</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">
|
||||||
|
|||||||
@@ -2,51 +2,109 @@ import React from 'react';
|
|||||||
import {
|
import {
|
||||||
Document, Page, View, Text, Image, StyleSheet
|
Document, Page, View, Text, Image, StyleSheet
|
||||||
} 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';
|
||||||
|
|
||||||
|
// 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 },
|
// colored header band
|
||||||
coverLogoBox: { width: 80, height: 40, justifyContent: 'center' },
|
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||||
coverClientLogo: { width: 80, height: 40, objectFit: 'contain' as const },
|
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||||
coverHiHala: { fontSize: 13, fontFamily: 'Helvetica-Bold', color: '#2563eb', letterSpacing: 0.5 },
|
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||||
coverMiddle: { flex: 1, justifyContent: 'center', paddingHorizontal: 50, paddingTop: 80 },
|
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||||
coverTitle: { fontSize: 28, fontFamily: 'Helvetica-Bold', marginBottom: 16, lineHeight: 1.2 },
|
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||||
coverFor: { fontSize: 11, color: '#334155', marginBottom: 4 },
|
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
||||||
coverContact: { fontSize: 10, color: '#64748b', marginBottom: 32 },
|
// white body
|
||||||
coverPeriod: { fontSize: 10, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 6 },
|
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||||
coverDate: { fontSize: 9, color: '#94a3b8' },
|
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||||
coverBar: { height: 6, flex: 1 },
|
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||||
contentPage: { paddingTop: 32, paddingRight: 44, paddingBottom: 48, paddingLeft: 44 },
|
coverBodySpacer: { flex: 1 },
|
||||||
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 24 },
|
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
|
||||||
pageHeaderTitle: { fontSize: 8, color: '#94a3b8' },
|
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
|
||||||
pageHeaderLogo: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
|
||||||
pageHeaderNum: { fontSize: 8, color: '#94a3b8' },
|
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
|
||||||
pageFooter: { position: 'absolute', bottom: 20, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between' },
|
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
|
||||||
pageFooterText: { fontSize: 7, color: '#94a3b8' },
|
|
||||||
sectionHeading: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: '#ffffff', paddingTop: 5, paddingRight: 10, paddingBottom: 5, paddingLeft: 10, marginBottom: 14, borderRadius: 3 },
|
// ── Content pages ──────────────────────────────────────
|
||||||
summaryText: { fontSize: 9.5, color: '#334155', lineHeight: 1.6 },
|
contentPage: { paddingTop: 34, paddingRight: 44, paddingBottom: 54, paddingLeft: 44 },
|
||||||
|
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1.5, borderBottomColor: '#e2e8f0', paddingBottom: 10, marginBottom: 26 },
|
||||||
|
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', marginBottom: 10 },
|
||||||
sectionGap: { marginBottom: 24 },
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
|
||||||
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,6 +112,16 @@ function pctChange(curr: number, prev: number): number {
|
|||||||
return Math.round(((curr - prev) / prev) * 100);
|
return Math.round(((curr - prev) / prev) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function museumIntro(row: MuseumDataRow, lang: 'en' | 'ar', compLabel: string): string {
|
||||||
|
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; }
|
interface PageHeaderProps { title: string; page: number; }
|
||||||
function PageHeader({ title, page }: PageHeaderProps) {
|
function PageHeader({ title, page }: PageHeaderProps) {
|
||||||
return (
|
return (
|
||||||
@@ -87,29 +155,36 @@ 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;
|
trendLabels, trendCurrent, trendPrevious,
|
||||||
|
museumData, channelBreakdown, districtBreakdown,
|
||||||
|
pilgrimCapture, generatedAt } = data;
|
||||||
|
|
||||||
const lang = cfg.language;
|
const lang = cfg.language;
|
||||||
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;
|
||||||
|
|
||||||
|
// Chart width adapts to orientation
|
||||||
|
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),
|
||||||
prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null,
|
prev: prevMetrics ? formatCurrency(prevMetrics.revenue, cfg.includeVAT) : null,
|
||||||
chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null },
|
chg: prevMetrics ? pctChange(metrics.revenue, prevMetrics.revenue) : null },
|
||||||
{ label: T.visitors, curr: metrics.visitors.toLocaleString(),
|
{ label: T.visitors, curr: metrics.visitors.toLocaleString(),
|
||||||
prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null,
|
prev: prevMetrics ? prevMetrics.visitors.toLocaleString() : null,
|
||||||
chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null },
|
chg: prevMetrics ? pctChange(metrics.visitors, prevMetrics.visitors) : null },
|
||||||
{ label: T.tickets, curr: metrics.tickets.toLocaleString(),
|
{ label: T.tickets, curr: metrics.tickets.toLocaleString(),
|
||||||
prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null,
|
prev: prevMetrics ? prevMetrics.tickets.toLocaleString() : null,
|
||||||
chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null },
|
chg: prevMetrics ? pctChange(metrics.tickets, prevMetrics.tickets) : null },
|
||||||
{ label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false),
|
{ label: T.avgRev, curr: formatCurrency(metrics.avgRevPerVisitor, false),
|
||||||
prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null,
|
prev: prevMetrics ? formatCurrency(prevMetrics.avgRevPerVisitor, false) : null,
|
||||||
chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null },
|
chg: prevMetrics ? pctChange(metrics.avgRevPerVisitor, prevMetrics.avgRevPerVisitor) : null },
|
||||||
{ label: T.avgTicketPrice, curr: formatCurrency(avgTicketPrice, false),
|
{ label: T.avgTicketPrice, curr: formatCurrency(avgTicketPrice, false),
|
||||||
@@ -122,32 +197,86 @@ export function ReportDocument({ data }: Props) {
|
|||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const prevYear = parseInt(cfg.startDate.slice(0, 4)) - 1;
|
const trendTitle = cfg.trendMetric === 'visitors' ? T.trendVisitors
|
||||||
|
: cfg.trendMetric === 'tickets' ? T.trendTickets
|
||||||
|
: T.trendRevenue;
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* ── Cover ─────────────────────────────────────────── */}
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
||||||
<View style={S.coverTop}>
|
{/* Colored header band */}
|
||||||
<Text style={S.coverHiHala}>HiHala Data</Text>
|
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||||
{cfg.clientLogoBase64 && (
|
<View style={S.coverHeaderTop}>
|
||||||
<View style={S.coverLogoBox}>
|
<Text style={S.coverBrand}>HiHala Data</Text>
|
||||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
{cfg.clientLogoBase64 && (
|
||||||
</View>
|
<View style={S.coverLogoBox}>
|
||||||
|
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* White body */}
|
||||||
|
<View style={S.coverBody}>
|
||||||
|
{cfg.clientName && (
|
||||||
|
<Text style={S.coverClientName}>{T.preparedFor}: {cfg.clientName}</Text>
|
||||||
|
)}
|
||||||
|
{cfg.contactName && (
|
||||||
|
<Text style={S.coverContactName}>{T.attention}: {cfg.contactName}</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.coverBodySpacer} />
|
||||||
|
<View style={S.coverPeriodRow}>
|
||||||
|
<View style={[S.coverPeriodDot, { backgroundColor: color }]} />
|
||||||
|
<Text style={S.coverPeriod}>{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.coverMiddle}>
|
|
||||||
<Text style={S.coverTitle}>{cfg.title || T.defaultTitle}</Text>
|
|
||||||
{cfg.clientName && <Text style={S.coverFor}>{T.preparedFor}: {cfg.clientName}</Text>}
|
|
||||||
{cfg.contactName && <Text style={S.coverContact}>{T.attention}: {cfg.contactName}</Text>}
|
|
||||||
<Text style={S.coverPeriod}>{period}</Text>
|
|
||||||
<Text style={S.coverDate}>{T.generated}: {generatedAt}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[S.coverBar, { backgroundColor: color }]} />
|
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={2} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={mainPg} />
|
||||||
|
|
||||||
{cfg.showExecutiveSummary && (
|
{cfg.showExecutiveSummary && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
@@ -163,7 +292,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
<View style={S.metricsHeaderRow}>
|
<View style={S.metricsHeaderRow}>
|
||||||
<Text style={S.metricsHeaderLabel}> </Text>
|
<Text style={S.metricsHeaderLabel}> </Text>
|
||||||
<Text style={S.metricsHeaderCell}>{period}</Text>
|
<Text style={S.metricsHeaderCell}>{period}</Text>
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{prevYear}</Text>}
|
{prevMetrics && <Text style={S.metricsHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||||
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
{prevMetrics && <Text style={S.metricsHeaderCell}>{T.change}</Text>}
|
||||||
</View>
|
</View>
|
||||||
{metricsRows.map((row, i) => (
|
{metricsRows.map((row, i) => (
|
||||||
@@ -173,7 +302,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
{prevMetrics && <Text style={S.metricsValue}>{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)}
|
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -184,7 +313,7 @@ export function ReportDocument({ data }: Props) {
|
|||||||
|
|
||||||
{cfg.showTrendChart && (
|
{cfg.showTrendChart && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.trend} color={color} />
|
<SectionHeading title={trendTitle} color={color} />
|
||||||
{cfg.includeComparison && (
|
{cfg.includeComparison && (
|
||||||
<View style={S.legendRow}>
|
<View style={S.legendRow}>
|
||||||
<View style={S.legendItem}>
|
<View style={S.legendItem}>
|
||||||
@@ -193,13 +322,13 @@ export function ReportDocument({ data }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
<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}>{comparisonPeriodLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||||
previous={trendPrevious} color={color} width={460} height={130} />
|
previous={trendPrevious} color={color} width={chartW} height={155} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -207,36 +336,199 @@ export function ReportDocument({ data }: Props) {
|
|||||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && (
|
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||||
|
{showMuseumPage && museumData.length > 0 && (
|
||||||
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
<PageHeader title={cfg.title || T.defaultTitle} page={3} />
|
<PageHeader title={cfg.title || T.defaultTitle} page={museumPg} />
|
||||||
|
<SectionHeading title={T.museumBreakdowns} color={color} />
|
||||||
|
|
||||||
{cfg.showMuseumBreakdown && museumBreakdown.length > 0 && (
|
{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}>{row.name}</Text>
|
||||||
|
{hasPrev && (
|
||||||
|
<Text style={S.museumIntroText}>
|
||||||
|
{museumIntro(row, lang, comparisonPeriodLabel)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={S.miniTable}>
|
||||||
|
<View style={S.miniHeaderRow}>
|
||||||
|
<Text style={S.miniHeaderLabel}> </Text>
|
||||||
|
<Text style={S.miniHeaderCell}>{period}</Text>
|
||||||
|
{hasPrev && <Text style={S.miniHeaderCell}>{comparisonPeriodLabel}</Text>}
|
||||||
|
{hasPrev && <Text style={S.miniHeaderChangeCell}>{T.change}</Text>}
|
||||||
|
</View>
|
||||||
|
{mRows.map((mr, ri) => (
|
||||||
|
<View key={mr.label} style={[S.miniRow, ri % 2 === 1 ? S.miniRowAlt : {}]}>
|
||||||
|
<Text style={S.miniLabel}>{mr.label}</Text>
|
||||||
|
<Text style={S.miniValue}>{mr.curr}</Text>
|
||||||
|
{hasPrev && <Text style={S.miniValue}>{mr.prev ?? '—'}</Text>}
|
||||||
|
{hasPrev && mr.chg !== null && (
|
||||||
|
<Text style={[S.miniChange, mr.chg >= 0 ? S.miniChangeUp : S.miniChangeDown]}>
|
||||||
|
{mr.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(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]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={channelPg} />
|
||||||
|
|
||||||
|
{cfg.showChannelRevenue && channelBreakdown.revenue.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byMuseumRevenue} color={color} />
|
<SectionHeading title={T.byChannelRevenue} color={color} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={museumBreakdown} color={color} width={460} />
|
<PdfHBarChart items={channelBreakdown.revenue} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelVisitors && channelBreakdown.visitors.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelVisitors} color={color} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.visitors} color={color} usepalette width={chartW} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{cfg.showChannelTickets && channelBreakdown.tickets.length > 0 && (
|
||||||
|
<View style={S.sectionGap}>
|
||||||
|
<SectionHeading title={T.byChannelTickets} color={color} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={channelBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showMuseumBreakdown && museumVisitorBreakdown.length > 0 && (
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── District Breakdowns ────────────────────────────── */}
|
||||||
|
{showDistrictPage && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={districtPg} />
|
||||||
|
|
||||||
|
{cfg.showDistrictRevenue && districtBreakdown.revenue.length > 0 && (
|
||||||
<View style={S.sectionGap}>
|
<View style={S.sectionGap}>
|
||||||
<SectionHeading title={T.byMuseumVisitors} color={color} />
|
<SectionHeading title={T.byDistrictRevenue} color={color} />
|
||||||
<View style={S.chartWrap}>
|
<View style={S.chartWrap}>
|
||||||
<PdfHBarChart items={museumVisitorBreakdown} 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} />
|
||||||
|
<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} />
|
||||||
|
<View style={S.chartWrap}>
|
||||||
|
<PdfHBarChart items={districtBreakdown.tickets} color={color} usepalette width={chartW} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cfg.showChannelBreakdown && channelBreakdown.length > 0 && (
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
<View style={S.sectionGap}>
|
</Page>
|
||||||
<SectionHeading title={T.byChannel} color={color} />
|
)}
|
||||||
<View style={S.chartWrap}>
|
|
||||||
<PdfHBarChart items={channelBreakdown} color={color} width={460} />
|
{/* ── Global Performance Summary ─────────────────────── */}
|
||||||
|
{showSummaryPage && museumData.length > 0 && (
|
||||||
|
<Page size="A4" orientation={orientation} style={[S.page, S.contentPage]}>
|
||||||
|
<PageHeader title={cfg.title || T.defaultTitle} page={summaryPg} />
|
||||||
|
<SectionHeading title={T.globalSummary} color={color} />
|
||||||
|
|
||||||
|
<Text style={S.summarySubLabel}>
|
||||||
|
{period} — {T.comparedTo} {comparisonPeriodLabel}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={S.summaryHeaderRow}>
|
||||||
|
<Text style={S.summaryHeaderMuseum}>{T.museum}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={S.summaryHeaderMetric}>{T.revenue}</Text>
|
||||||
|
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={S.summaryHeaderMetric}>{T.visitors}</Text>
|
||||||
|
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={S.summaryHeaderMetric}>{T.tickets}</Text>
|
||||||
|
<Text style={S.summaryHeaderDelta}>Δ</Text>
|
||||||
|
</>}
|
||||||
|
</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}>{row.name.length > 30 ? row.name.slice(0, 30) + '…' : row.name}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={S.summaryMetric}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDelta}>—</Text>}
|
||||||
|
</>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
);
|
||||||
)}
|
})}
|
||||||
|
|
||||||
|
<View style={S.summaryTotalRow}>
|
||||||
|
<Text style={S.summaryMuseumTotal}>{T.total}</Text>
|
||||||
|
{cfg.showMuseumRevenue && <>
|
||||||
|
<Text style={S.summaryMetricTotal}>{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]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumVisitors && <>
|
||||||
|
<Text style={S.summaryMetricTotal}>{metrics.visitors.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.visitors, prevMetrics.visitors);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
{cfg.showMuseumTickets && <>
|
||||||
|
<Text style={S.summaryMetricTotal}>{metrics.tickets.toLocaleString()}</Text>
|
||||||
|
{prevMetrics ? (() => {
|
||||||
|
const c = pctChange(metrics.tickets, prevMetrics.tickets);
|
||||||
|
return <Text style={[S.summaryDeltaTotal, c >= 0 ? S.summaryDeltaUp : S.summaryDeltaDown]}>{c >= 0 ? '+' : '-'}{formatPct(Math.abs(c))}</Text>;
|
||||||
|
})() : <Text style={S.summaryDeltaTotal}>—</Text>}
|
||||||
|
</>}
|
||||||
|
</View>
|
||||||
|
|
||||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||||
</Page>
|
</Page>
|
||||||
@@ -255,11 +547,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 +579,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>
|
);
|
||||||
</label>
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
{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,115 +149,251 @@ 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}
|
||||||
onChange={e => onChange({ title: e.target.value })}
|
onChange={e => onChange({ title: e.target.value })}
|
||||||
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-logo-row">
|
<div className="rf-field">
|
||||||
<button type="button" className="rf-upload-btn" onClick={() => logoInputRef.current?.click()}>
|
<span className="rf-label">Accent color</span>
|
||||||
{cfg.clientLogoBase64 ? 'Change logo' : 'Upload logo'}
|
<div className="rf-color-row">
|
||||||
</button>
|
<input type="color" value={cfg.accentColor}
|
||||||
{cfg.clientLogoBase64 && (
|
onChange={e => onChange({ accentColor: e.target.value })}
|
||||||
<>
|
className="rf-color-input"
|
||||||
<img src={cfg.clientLogoBase64} alt="preview" className="rf-logo-preview" />
|
aria-label="Report accent color" />
|
||||||
<button type="button" className="rf-remove-btn" onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
<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">
|
||||||
|
{/* H6: descriptive aria-label on upload button */}
|
||||||
|
<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>
|
||||||
|
{cfg.clientLogoBase64 && (
|
||||||
|
<>
|
||||||
|
{/* M1: meaningful alt text */}
|
||||||
|
<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"
|
||||||
|
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
||||||
|
</div>
|
||||||
|
{/* C2: inline logo error */}
|
||||||
|
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Data</h2>
|
||||||
|
|
||||||
|
<div className="rf-date-row">
|
||||||
|
<Field label="Period start">
|
||||||
|
<input className="rf-input" type="date" value={cfg.startDate}
|
||||||
|
onChange={e => onChange({ startDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Period end">
|
||||||
|
<input className="rf-input" type="date" value={cfg.endDate}
|
||||||
|
onChange={e => onChange({ endDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Museums">
|
||||||
|
<AltMultiSelect value={cfg.selectedMuseums} options={allMuseums}
|
||||||
|
onChange={v => onChange({ selectedMuseums: v })}
|
||||||
|
allLabel="All museums" countLabel={n => `${n} museums`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Channels">
|
||||||
|
<AltMultiSelect value={cfg.selectedChannels} options={allChannels}
|
||||||
|
onChange={v => onChange({ selectedChannels: v })}
|
||||||
|
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">VAT</span>
|
||||||
|
<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 label="To">
|
||||||
|
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
|
||||||
|
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<input ref={logoInputRef} type="file" accept="image/png,image/jpeg"
|
|
||||||
style={{ display: 'none' }} onChange={handleLogoUpload} />
|
<div className="rf-divider" />
|
||||||
|
<h2 className="rf-group-label">Format</h2>
|
||||||
|
|
||||||
|
<div className="rf-field">
|
||||||
|
<span className="rf-label">Language</span>
|
||||||
|
<PillGroup
|
||||||
|
label="Language"
|
||||||
|
options={[{ label: 'English', value: 'en' }, { label: 'العربية', value: 'ar' }]}
|
||||||
|
value={cfg.language}
|
||||||
|
onChange={v => onChange({ language: v as 'en' | 'ar' })}
|
||||||
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Accent color">
|
{/* ── Right: content selection ── */}
|
||||||
<div className="rf-color-row">
|
<div className="rf-col">
|
||||||
<input type="color" value={cfg.accentColor}
|
<h2 className="rf-group-label">Report Sections</h2>
|
||||||
onChange={e => onChange({ accentColor: e.target.value })}
|
|
||||||
className="rf-color-input" />
|
<ModuleCard title="Executive Summary"
|
||||||
<span className="rf-color-val">{cfg.accentColor}</span>
|
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.trendMetric.charAt(0).toUpperCase() + cfg.trendMetric.slice(1)
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
{/* H7: PillGroup instead of <select> for full consistency */}
|
||||||
|
<PillGroup
|
||||||
|
label="Trend metric"
|
||||||
|
options={[
|
||||||
|
{ label: 'Revenue', value: 'revenue' },
|
||||||
|
{ label: 'Visitors', value: 'visitors' },
|
||||||
|
{ label: 'Tickets', value: 'tickets' },
|
||||||
|
]}
|
||||||
|
value={cfg.trendMetric}
|
||||||
|
onChange={v => onChange({ trendMetric: v as TrendMetric })}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SectionTitle>Data Selection</SectionTitle>
|
|
||||||
|
|
||||||
<div className="rf-date-row">
|
|
||||||
<Field label="Start date">
|
|
||||||
<input className="rf-input" type="date" value={cfg.startDate}
|
|
||||||
onChange={e => onChange({ startDate: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
<Field label="End date">
|
|
||||||
<input className="rf-input" type="date" value={cfg.endDate}
|
|
||||||
onChange={e => onChange({ endDate: e.target.value })} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Museums">
|
|
||||||
<AltMultiSelect value={cfg.selectedMuseums} options={allMuseums}
|
|
||||||
onChange={v => onChange({ selectedMuseums: v })}
|
|
||||||
allLabel="All museums" countLabel={n => `${n} museums`} clearLabel="Clear" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Channels">
|
|
||||||
<AltMultiSelect value={cfg.selectedChannels} options={allChannels}
|
|
||||||
onChange={v => onChange({ selectedChannels: v })}
|
|
||||||
allLabel="All channels" countLabel={n => `${n} channels`} clearLabel="Clear" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="VAT">
|
|
||||||
<Toggle left="Excl. VAT" right="Incl. VAT" value={cfg.includeVAT}
|
|
||||||
onChange={v => onChange({ includeVAT: v })} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<CheckRow label="Include previous year comparison"
|
|
||||||
checked={cfg.includeComparison} onChange={v => onChange({ includeComparison: v })} />
|
|
||||||
|
|
||||||
<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 label="Orientation">
|
|
||||||
<Toggle left="Portrait" right="Landscape" value={cfg.orientation === 'landscape'}
|
|
||||||
onChange={v => onChange({ orientation: v ? 'landscape' : 'portrait' })} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Confidentiality">
|
|
||||||
<select className="rf-input" value={cfg.confidentiality}
|
|
||||||
onChange={e => onChange({ confidentiality: e.target.value as ReportConfig['confidentiality'] })}>
|
|
||||||
<option value="Confidential">Confidential</option>
|
|
||||||
<option value="Internal">Internal</option>
|
|
||||||
<option value="Public">Public</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
</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,6 +1,19 @@
|
|||||||
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[];
|
||||||
@@ -10,10 +23,11 @@ interface TrendChartProps {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfTrendChart({ labels, current, previous, color, width = 460, height = 140 }: TrendChartProps) {
|
export function PdfTrendChart({ labels, current, previous, color, width = 470, height = 155 }: TrendChartProps) {
|
||||||
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
|
const allValues = [...current, ...(previous ?? [])].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 +41,38 @@ 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)}
|
|
||||||
stroke="#e2e8f0" strokeWidth={0.5} />
|
{/* 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} />
|
||||||
|
<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" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Current period 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={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 +92,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 +109,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,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
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 +20,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;
|
||||||
|
trendMetric: 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 +55,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,
|
||||||
|
trendMetric: '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 +86,30 @@ 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 ReportData {
|
export interface ReportData {
|
||||||
config: ReportConfig;
|
config: ReportConfig;
|
||||||
metrics: Metrics;
|
metrics: Metrics;
|
||||||
prevMetrics: Metrics | null;
|
prevMetrics: Metrics | null;
|
||||||
|
comparisonPeriodLabel: string;
|
||||||
trendLabels: string[];
|
trendLabels: string[];
|
||||||
trendCurrent: number[];
|
trendCurrent: number[];
|
||||||
trendPrevious: number[] | null;
|
trendPrevious: number[] | null;
|
||||||
museumBreakdown: BreakdownItem[]; // revenue by museum
|
museumData: MuseumDataRow[];
|
||||||
museumVisitorBreakdown: BreakdownItem[]; // visitors by museum
|
museumBreakdown: DimensionBreakdown;
|
||||||
channelBreakdown: BreakdownItem[];
|
channelBreakdown: DimensionBreakdown;
|
||||||
|
districtBreakdown: DimensionBreakdown;
|
||||||
pilgrimCapture: { current: number; previous: number | null } | null;
|
pilgrimCapture: { current: number; previous: number | null } | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -93,6 +141,12 @@ function estimatePilgrims(start: string, end: string): number | null {
|
|||||||
return has ? Math.round(total) : null;
|
return has ? Math.round(total) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
|
function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { labels: string[]; values: number[] } {
|
||||||
const s = new Date(start);
|
const s = new Date(start);
|
||||||
const acc: Record<number, MuseumRecord[]> = {};
|
const acc: Record<number, MuseumRecord[]> = {};
|
||||||
@@ -107,24 +161,33 @@ function buildTrend(rows: MuseumRecord[], start: string, cfg: ReportConfig): { l
|
|||||||
const labels = Array.from({ length: maxK }, (_, i) => `W${i + 1}`);
|
const labels = Array.from({ length: maxK }, (_, i) => `W${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((s, r) => s + getMetricVal(r, cfg.trendMetric, cfg.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 comparisonPeriodLabel = cfg.includeComparison
|
||||||
|
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||||
|
: '';
|
||||||
|
|
||||||
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
const currTrend = buildTrend(currRows, cfg.startDate, cfg);
|
||||||
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, prevStart, cfg) : null;
|
const prevTrend = cfg.includeComparison ? buildTrend(prevRows, cfg.comparisonStartDate, cfg) : null;
|
||||||
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
const maxLen = Math.max(currTrend.labels.length, prevTrend?.values.length ?? 0);
|
||||||
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
const trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
||||||
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
const trendCurrent = Array.from({ length: maxLen }, (_, i) => currTrend.values[i] ?? 0);
|
||||||
@@ -132,24 +195,20 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
|||||||
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
? Array.from({ length: maxLen }, (_, i) => prevTrend.values[i] ?? 0)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
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 +222,14 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
|||||||
config: cfg,
|
config: cfg,
|
||||||
metrics,
|
metrics,
|
||||||
prevMetrics,
|
prevMetrics,
|
||||||
|
comparisonPeriodLabel,
|
||||||
trendLabels,
|
trendLabels,
|
||||||
trendCurrent,
|
trendCurrent,
|
||||||
trendPrevious,
|
trendPrevious,
|
||||||
|
museumData,
|
||||||
museumBreakdown,
|
museumBreakdown,
|
||||||
museumVisitorBreakdown,
|
|
||||||
channelBreakdown,
|
channelBreakdown,
|
||||||
|
districtBreakdown,
|
||||||
pilgrimCapture,
|
pilgrimCapture,
|
||||||
generatedAt: new Date().toLocaleDateString('en-GB'),
|
generatedAt: new Date().toLocaleDateString('en-GB'),
|
||||||
};
|
};
|
||||||
@@ -197,14 +258,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 +275,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 +283,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 +292,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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user