Compare commits

...

2 Commits

Author SHA1 Message Date
fahed 1070490ad2 feat(charts): show actual dates in trend chart tooltips
Deploy HiHala Dashboard / deploy (push) Successful in 11s
Replace opaque W1/D1/month abbreviation tooltip titles with human-readable
period labels (e.g. "Week 1 · 1 Apr – 7 Apr", "1 April 2025", "April 2025")
in both Dashboard and Comparison trend charts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 10:37:05 +03:00
fahed c858075232 refactor(report): full UX audit + accessibility pass
Deploy HiHala Dashboard / deploy (push) Successful in 11s
UI/UX redesign:
- Module cards with master toggle + badge state for all report sections
- BreakdownModule with indeterminate checkbox and metric pill sub-toggles
- PillGroup replaces all text toggles and <select> (Language, VAT,
  Confidentiality, Trend metric, Orientation) for full visual consistency
- Visual orientation picker (portrait/landscape card buttons)
- Comparison period in accent-tinted block, revealed contextually
- Footer meta strip: section count, date range, orientation, comparison flag
- Removed generic subtitle copy

Accessibility (audit findings C1–C3, H2, H6, L1–L2):
- aria-pressed on all PillGroup and orientation buttons
- role="group" + aria-label on every pill group and orientation row
- aria-hidden on decorative module badges and footer separator dots
- :focus-visible on rf-metric-pill, rf-orient-btn, rf-upload-btn, rf-remove-btn
- aria-label on upload/remove logo buttons
- Semantic <h2> elements replace <div> group labels
- alert() replaced with inline role="alert" error messages in footer + logo field
- aria-live="polite" sr-only region for PDF generation status
- aria-busy on generate button during PDF creation

Dark mode & theming (H1):
- All rgba(37,99,235,...) hard-codes replaced with color-mix(in srgb,
  var(--accent) N%, transparent) so tints follow the accent token in dark mode
- rf-module-header:hover uses var(--hover) instead of rgba(0,0,0,0.02)

Performance (H8):
- getUniqueMuseums/getUniqueChannels wrapped in useMemo([data])

PDF fixes:
- ▲/▼ Unicode glyphs (outside Helvetica Latin-1 range) replaced with +/- prefix
- Chart width adapts to orientation via CHART_W constant
- Y-axis labels added to trend chart (padL 38pt)

Responsive (H4–H5):
- rf-metric-pill touch target increased to 8px/14px on mobile
- Mobile footer shows section count only; period/orientation details hide

Cleanup (M3):
- Removed dead CSS: rf-toggle, rf-toggle-opt, rf-section-title,
  rf-check-h-group, rf-inline-row (7 rules)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 09:41:38 +03:00
8 changed files with 1318 additions and 442 deletions
+262 -149
View File
@@ -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; }
} }
/* ======================================== /* ========================================
+39 -8
View File
@@ -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">
+40 -8
View File
@@ -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">
+401 -89
View File
@@ -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: 'التذاكر',
+344 -115
View File
@@ -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>
); );
} }
+68 -12
View File
@@ -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 ? (
<> <>
+58 -18
View File
@@ -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>
); );
})} })}
+106 -43
View File
@@ -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;
} }