Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1070490ad2 | |||
| c858075232 |
+262
-149
@@ -2859,7 +2859,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
======================================== */
|
||||
|
||||
.report-page {
|
||||
max-width: 1400px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 100px;
|
||||
}
|
||||
@@ -2883,12 +2883,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-body {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 1fr;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
.report-body {}
|
||||
|
||||
.report-form-col {
|
||||
background: var(--surface);
|
||||
@@ -2897,13 +2892,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.report-preview-col {}
|
||||
|
||||
.report-preview-sticky {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
/* Generate button bar */
|
||||
.report-footer-bar {
|
||||
position: fixed;
|
||||
@@ -2915,7 +2903,8 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
padding: 12px 24px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
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 {
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rf-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.rf-col {
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.rf-section-title {
|
||||
font-size: 0.6875rem;
|
||||
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-col + .rf-col {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rf-field {
|
||||
@@ -2990,7 +2975,7 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
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 {
|
||||
display: grid;
|
||||
@@ -2998,29 +2983,6 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3059,6 +3021,54 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3102,121 +3112,224 @@ html[dir="rtl"] .exportable-chart-wrapper .chart-export-btn.visible {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Report Preview ── */
|
||||
.report-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-preview-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
/* ── Group labels & dividers ── */
|
||||
.rf-group-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rp-page {
|
||||
background: white;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,.08));
|
||||
overflow: hidden;
|
||||
aspect-ratio: 210 / 297;
|
||||
.rf-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
flex-direction: column;
|
||||
font-size: 7px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #0f172a;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rp-cover-top {
|
||||
display: flex;
|
||||
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;
|
||||
.rf-comparison-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: #0f172a;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.rp-cover-for, .rp-cover-contact, .rp-cover-period {
|
||||
font-size: 7px;
|
||||
color: #64748b;
|
||||
margin-bottom: 3px;
|
||||
/* ── Module cards ── */
|
||||
.rf-module {
|
||||
border: 1px solid var(--border);
|
||||
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; }
|
||||
|
||||
.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;
|
||||
.rf-module--on {
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.rp-page-title-small, .rp-page-num { font-size: 5px; color: #94a3b8; }
|
||||
|
||||
.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 {
|
||||
.rf-module-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rp-ph-row-label { font-size: 5.5px; color: #334155; flex: 1.5; }
|
||||
.rp-ph-row-val { flex: 1; height: 4px; background: #e2e8f0; border-radius: 2px; }
|
||||
.rp-ph-row-val--sm { flex: 0.8; }
|
||||
.rf-module-header:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.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) {
|
||||
.report-body { grid-template-columns: 1fr; }
|
||||
.report-preview-sticky { position: static; }
|
||||
.rf-module-badge {
|
||||
font-size: 0.6875rem;
|
||||
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; }
|
||||
/* H5: show only section count in footer on mobile, hide details */
|
||||
.report-footer-chip:not(.report-footer-chip--count),
|
||||
.report-footer-dot { display: none; }
|
||||
/* H4: larger touch targets for metric pills on mobile */
|
||||
.rf-metric-pill { padding: 8px 14px; }
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
||||
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
||||
};
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const trendResult = useMemo(() => {
|
||||
const group = (rows: MuseumRecord[], ps: string) => {
|
||||
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
||||
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 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) =>
|
||||
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 {
|
||||
tooltipLabels,
|
||||
data: {
|
||||
labels,
|
||||
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]);
|
||||
const trendData = trendResult.data;
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
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 } } } };
|
||||
return { chartOpts };
|
||||
}, [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 = [
|
||||
{ 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>)}
|
||||
</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 className="alt-chart-card">
|
||||
<div className="alt-chart-header">
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
||||
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
||||
}, [revenueField]);
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const trendResult = useMemo(() => {
|
||||
const group = (rows: MuseumRecord[], ps: string) => {
|
||||
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
||||
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 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) =>
|
||||
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;
|
||||
return {
|
||||
tooltipLabels,
|
||||
data: {
|
||||
labels,
|
||||
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]);
|
||||
const trendData = trendResult.data;
|
||||
|
||||
const museumData = useMemo(() => {
|
||||
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 } } };
|
||||
return { chartOpts, barHorizOpts, barNoLegend };
|
||||
}, [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(() => ({
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
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>)}
|
||||
</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 className="alt-chart-card">
|
||||
|
||||
@@ -2,51 +2,109 @@ import React from 'react';
|
||||
import {
|
||||
Document, Page, View, Text, Image, StyleSheet
|
||||
} from '@react-pdf/renderer';
|
||||
import { PdfTrendChart, PdfHBarChart } from './reportCharts';
|
||||
import { PdfTrendChart, PdfHBarChart, CHART_PALETTE } from './reportCharts';
|
||||
import {
|
||||
ReportData, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||
ReportData, MuseumDataRow, formatCurrency, formatPct, formatPeriodLabel, generateExecutiveSummary
|
||||
} 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({
|
||||
page: { fontFamily: 'Helvetica', fontSize: 9, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||
page: { fontFamily: 'Helvetica', fontSize: 10, color: '#0f172a', backgroundColor: '#ffffff' },
|
||||
|
||||
// ── Cover ──────────────────────────────────────────────
|
||||
coverPage: { flexDirection: 'column', padding: 0 },
|
||||
coverTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', paddingTop: 40, paddingRight: 50, paddingBottom: 0, paddingLeft: 50 },
|
||||
coverLogoBox: { width: 80, height: 40, justifyContent: 'center' },
|
||||
coverClientLogo: { width: 80, height: 40, objectFit: 'contain' as const },
|
||||
coverHiHala: { fontSize: 13, fontFamily: 'Helvetica-Bold', color: '#2563eb', letterSpacing: 0.5 },
|
||||
coverMiddle: { flex: 1, justifyContent: 'center', paddingHorizontal: 50, paddingTop: 80 },
|
||||
coverTitle: { fontSize: 28, fontFamily: 'Helvetica-Bold', marginBottom: 16, lineHeight: 1.2 },
|
||||
coverFor: { fontSize: 11, color: '#334155', marginBottom: 4 },
|
||||
coverContact: { fontSize: 10, color: '#64748b', marginBottom: 32 },
|
||||
coverPeriod: { fontSize: 10, color: '#64748b', fontFamily: 'Helvetica-Oblique', marginBottom: 6 },
|
||||
coverDate: { fontSize: 9, color: '#94a3b8' },
|
||||
coverBar: { height: 6, flex: 1 },
|
||||
contentPage: { paddingTop: 32, paddingRight: 44, paddingBottom: 48, paddingLeft: 44 },
|
||||
pageHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#e2e8f0', paddingBottom: 8, marginBottom: 24 },
|
||||
pageHeaderTitle: { fontSize: 8, color: '#94a3b8' },
|
||||
pageHeaderLogo: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: '#2563eb' },
|
||||
pageHeaderNum: { fontSize: 8, color: '#94a3b8' },
|
||||
pageFooter: { position: 'absolute', bottom: 20, left: 44, right: 44, flexDirection: 'row', justifyContent: 'space-between' },
|
||||
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 },
|
||||
summaryText: { fontSize: 9.5, color: '#334155', lineHeight: 1.6 },
|
||||
// colored header band
|
||||
coverHeader: { paddingTop: 56, paddingRight: 52, paddingBottom: 52, paddingLeft: 52 },
|
||||
coverHeaderTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 48 },
|
||||
coverBrand: { fontSize: 12, fontFamily: 'Helvetica-Bold', color: '#ffffff', letterSpacing: 0.8 },
|
||||
coverLogoBox: { width: 90, height: 44, justifyContent: 'flex-end', alignItems: 'flex-end' },
|
||||
coverClientLogo: { width: 90, height: 44, objectFit: 'contain' as const },
|
||||
coverTitle: { fontSize: 36, fontFamily: 'Helvetica-Bold', color: '#ffffff', lineHeight: 1.2 },
|
||||
// white body
|
||||
coverBody: { flex: 1, paddingTop: 44, paddingRight: 52, paddingBottom: 44, paddingLeft: 52, flexDirection: 'column' },
|
||||
coverClientName: { fontSize: 15, color: '#0f172a', fontFamily: 'Helvetica-Bold', marginBottom: 5 },
|
||||
coverContactName: { fontSize: 11, color: '#64748b', marginBottom: 32 },
|
||||
coverBodySpacer: { flex: 1 },
|
||||
coverPeriodRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 5 },
|
||||
coverPeriodDot: { width: 6, height: 6, borderRadius: 3, marginRight: 8 },
|
||||
coverPeriod: { fontSize: 12, color: '#334155', fontFamily: 'Helvetica-Oblique' },
|
||||
coverDate: { fontSize: 9, color: '#94a3b8', marginBottom: 20 },
|
||||
coverConfidential: { fontSize: 7.5, color: '#94a3b8', letterSpacing: 2, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e2e8f0' },
|
||||
|
||||
// ── Content pages ──────────────────────────────────────
|
||||
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 },
|
||||
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 6 },
|
||||
metricsRowAlt: { backgroundColor: '#f8fafc' },
|
||||
metricsLabel: { flex: 1.5, fontSize: 9, color: '#334155', fontFamily: 'Helvetica-Bold' },
|
||||
metricsValue: { flex: 1, fontSize: 9, color: '#0f172a', textAlign: 'right' },
|
||||
metricsChange: { flex: 0.8, fontSize: 8, textAlign: 'right' },
|
||||
metricsHeaderRow: { flexDirection: 'row', backgroundColor: '#f1f5f9', paddingTop: 5, paddingBottom: 5, marginBottom: 2, borderRadius: 3 },
|
||||
metricsHeaderLabel: { flex: 1.8, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', paddingLeft: 8 },
|
||||
metricsHeaderCell: { flex: 1, fontSize: 8, fontFamily: 'Helvetica-Bold', color: '#64748b', textAlign: 'right', paddingRight: 6 },
|
||||
metricsRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#f1f5f9', paddingVertical: 7 },
|
||||
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' },
|
||||
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' },
|
||||
metricsHeaderLabel: { flex: 1.5, fontSize: 7.5, fontFamily: 'Helvetica-Bold', color: '#64748b' },
|
||||
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', padding: 12, borderRadius: 4 },
|
||||
sectionGap: { marginBottom: 24 },
|
||||
legendRow: { flexDirection: 'row', marginBottom: 8 },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 16 },
|
||||
|
||||
// ── Trend chart ────────────────────────────────────────
|
||||
chartWrap: { marginBottom: 8, backgroundColor: '#f8fafc', paddingTop: 14, paddingRight: 14, paddingBottom: 14, paddingLeft: 14, borderRadius: 6, borderWidth: 1, borderColor: '#f1f5f9' },
|
||||
legendRow: { flexDirection: 'row', marginBottom: 10 },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', marginRight: 18 },
|
||||
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 {
|
||||
@@ -54,6 +112,16 @@ function pctChange(curr: number, prev: number): number {
|
||||
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; }
|
||||
function PageHeader({ title, page }: PageHeaderProps) {
|
||||
return (
|
||||
@@ -87,17 +155,24 @@ function SectionHeading({ title, color }: SectionProps) {
|
||||
interface Props { data: ReportData; }
|
||||
|
||||
export function ReportDocument({ data }: Props) {
|
||||
const { config: cfg, metrics, prevMetrics, trendLabels, trendCurrent, trendPrevious,
|
||||
museumBreakdown, museumVisitorBreakdown, channelBreakdown, pilgrimCapture, generatedAt } = data;
|
||||
const { config: cfg, metrics, prevMetrics, comparisonPeriodLabel,
|
||||
trendLabels, trendCurrent, trendPrevious,
|
||||
museumData, channelBreakdown, districtBreakdown,
|
||||
pilgrimCapture, generatedAt } = data;
|
||||
|
||||
const lang = cfg.language;
|
||||
const color = cfg.accentColor;
|
||||
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;
|
||||
|
||||
// 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 prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0 ? prevMetrics.revenue / prevMetrics.tickets : null;
|
||||
const prevAvgTicketPrice = prevMetrics && prevMetrics.tickets > 0
|
||||
? prevMetrics.revenue / prevMetrics.tickets : null;
|
||||
|
||||
const metricsRows = [
|
||||
{ label: T.revenue, curr: formatCurrency(metrics.revenue, cfg.includeVAT),
|
||||
@@ -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 (
|
||||
<Document title={cfg.title || 'HiHala Report'} author="HiHala Data">
|
||||
|
||||
{/* ── Cover ─────────────────────────────────────────── */}
|
||||
<Page size="A4" orientation={orientation} style={[S.page, S.coverPage]}>
|
||||
<View style={S.coverTop}>
|
||||
<Text style={S.coverHiHala}>HiHala Data</Text>
|
||||
{/* Colored header band */}
|
||||
<View style={[S.coverHeader, { backgroundColor: color }]}>
|
||||
<View style={S.coverHeaderTop}>
|
||||
<Text style={S.coverBrand}>HiHala Data</Text>
|
||||
{cfg.clientLogoBase64 && (
|
||||
<View style={S.coverLogoBox}>
|
||||
<Image src={cfg.clientLogoBase64} style={S.coverClientLogo} />
|
||||
</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 }]} />
|
||||
|
||||
{/* 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>
|
||||
</Page>
|
||||
|
||||
{/* ── Summary + Metrics + Trend ──────────────────────── */}
|
||||
<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 && (
|
||||
<View style={S.sectionGap}>
|
||||
@@ -163,7 +292,7 @@ export function ReportDocument({ data }: Props) {
|
||||
<View style={S.metricsHeaderRow}>
|
||||
<Text style={S.metricsHeaderLabel}> </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>}
|
||||
</View>
|
||||
{metricsRows.map((row, i) => (
|
||||
@@ -173,7 +302,7 @@ export function ReportDocument({ data }: Props) {
|
||||
{prevMetrics && <Text style={S.metricsValue}>{row.prev ?? '—'}</Text>}
|
||||
{prevMetrics && row.chg !== null && (
|
||||
<Text style={[S.metricsChange, row.chg >= 0 ? S.metricsChangeUp : S.metricsChangeDown]}>
|
||||
{formatPct(row.chg)}
|
||||
{row.chg >= 0 ? '+' : '-'}{formatPct(Math.abs(row.chg))}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -184,7 +313,7 @@ export function ReportDocument({ data }: Props) {
|
||||
|
||||
{cfg.showTrendChart && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.trend} color={color} />
|
||||
<SectionHeading title={trendTitle} color={color} />
|
||||
{cfg.includeComparison && (
|
||||
<View style={S.legendRow}>
|
||||
<View style={S.legendItem}>
|
||||
@@ -193,13 +322,13 @@ export function ReportDocument({ data }: Props) {
|
||||
</View>
|
||||
<View style={S.legendItem}>
|
||||
<View style={[S.legendDot, { backgroundColor: '#94a3b8' }]} />
|
||||
<Text style={S.legendLabel}>{prevYear}</Text>
|
||||
<Text style={S.legendLabel}>{comparisonPeriodLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={S.chartWrap}>
|
||||
<PdfTrendChart labels={trendLabels} current={trendCurrent}
|
||||
previous={trendPrevious} color={color} width={460} height={130} />
|
||||
previous={trendPrevious} color={color} width={chartW} height={155} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -207,37 +336,200 @@ export function ReportDocument({ data }: Props) {
|
||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||
</Page>
|
||||
|
||||
{(cfg.showMuseumBreakdown || cfg.showChannelBreakdown) && (
|
||||
{/* ── Museum Mini-Reports ────────────────────────────── */}
|
||||
{showMuseumPage && museumData.length > 0 && (
|
||||
<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}>
|
||||
<SectionHeading title={T.byMuseumRevenue} color={color} />
|
||||
<SectionHeading title={T.byChannelRevenue} color={color} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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}>
|
||||
<SectionHeading title={T.byMuseumVisitors} color={color} />
|
||||
<SectionHeading title={T.byDistrictRevenue} color={color} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{cfg.showChannelBreakdown && channelBreakdown.length > 0 && (
|
||||
<View style={S.sectionGap}>
|
||||
<SectionHeading title={T.byChannel} color={color} />
|
||||
<View style={S.chartWrap}>
|
||||
<PdfHBarChart items={channelBreakdown} color={color} width={460} />
|
||||
</View>
|
||||
</View>
|
||||
<PageFooter confidentiality={cfg.confidentiality} generatedAt={generatedAt} />
|
||||
</Page>
|
||||
)}
|
||||
|
||||
{/* ── 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 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} />
|
||||
</Page>
|
||||
)}
|
||||
@@ -255,11 +547,21 @@ const LABELS_EN = {
|
||||
keyMetrics: 'Key Metrics',
|
||||
inclVAT: 'Incl. VAT',
|
||||
exclVAT: 'Excl. VAT',
|
||||
change: 'vs Prior Year',
|
||||
trend: 'Revenue Trend',
|
||||
byMuseumRevenue: 'Revenue by Museum',
|
||||
byMuseumVisitors: 'Visitors by Museum',
|
||||
byChannel: 'Visitors by Channel',
|
||||
change: 'Change',
|
||||
comparedTo: 'vs.',
|
||||
trendRevenue: 'Revenue Trend',
|
||||
trendVisitors: 'Visitor Trend',
|
||||
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',
|
||||
visitors: 'Visitors',
|
||||
tickets: 'Tickets',
|
||||
@@ -277,11 +579,21 @@ const LABELS_AR = {
|
||||
keyMetrics: 'المؤشرات الرئيسية',
|
||||
inclVAT: 'شامل ضريبة القيمة المضافة',
|
||||
exclVAT: 'غير شامل ضريبة القيمة المضافة',
|
||||
change: 'مقابل العام السابق',
|
||||
trend: 'اتجاه الإيرادات',
|
||||
byMuseumRevenue: 'الإيرادات حسب المتحف',
|
||||
byMuseumVisitors: 'الزوار حسب المتحف',
|
||||
byChannel: 'الزوار حسب القناة',
|
||||
change: 'التغيّر',
|
||||
comparedTo: 'مقابل',
|
||||
trendRevenue: 'اتجاه الإيرادات',
|
||||
trendVisitors: 'اتجاه الزوار',
|
||||
trendTickets: 'اتجاه التذاكر',
|
||||
museumBreakdowns: 'تفاصيل المتاحف',
|
||||
byChannelRevenue: 'الإيرادات حسب القناة',
|
||||
byChannelVisitors: 'الزوار حسب القناة',
|
||||
byChannelTickets: 'التذاكر حسب القناة',
|
||||
byDistrictRevenue: 'الإيرادات حسب الحي',
|
||||
byDistrictVisitors: 'الزوار حسب الحي',
|
||||
byDistrictTickets: 'التذاكر حسب الحي',
|
||||
globalSummary: 'ملخص الأداء',
|
||||
museum: 'المتحف',
|
||||
total: 'الإجمالي',
|
||||
revenue: 'الإيرادات',
|
||||
visitors: 'الزوار',
|
||||
tickets: 'التذاكر',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import AltMultiSelect from '../shared/AltMultiSelect';
|
||||
import type { ReportConfig } from './reportHelpers';
|
||||
import type { ReportConfig, TrendMetric } from './reportHelpers';
|
||||
|
||||
interface Props {
|
||||
config: ReportConfig;
|
||||
@@ -9,10 +9,6 @@ interface Props {
|
||||
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 }) {
|
||||
return (
|
||||
<label className="rf-field">
|
||||
@@ -22,33 +18,130 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ left, right, value, onChange }: {
|
||||
left: string; right: string; value: boolean; onChange: (v: boolean) => void;
|
||||
// C1+C3: role="group" + aria-label + aria-pressed on every button
|
||||
function PillGroup({ options, value, onChange, label }: {
|
||||
options: Array<{ label: string; value: string }>;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rf-toggle">
|
||||
<button type="button" className={`rf-toggle-opt${!value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(false)}>{left}</button>
|
||||
<button type="button" className={`rf-toggle-opt${value ? ' rf-toggle-opt--on' : ''}`} onClick={() => onChange(true)}>{right}</button>
|
||||
<div className="rf-metric-pills" role="group" aria-label={label}>
|
||||
{options.map(opt => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className="rf-check-row">
|
||||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="rf-checkbox" />
|
||||
<span>{label}</span>
|
||||
<input ref={ref} type="checkbox" checked={checked}
|
||||
onChange={e => onChange(e.target.checked)} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 file = e.target.files?.[0];
|
||||
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();
|
||||
reader.onload = () => onChange({ clientLogoBase64: reader.result as string });
|
||||
reader.readAsDataURL(file);
|
||||
@@ -56,8 +149,12 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
||||
|
||||
return (
|
||||
<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">
|
||||
<input className="rf-input" type="text" value={cfg.title}
|
||||
@@ -65,51 +162,65 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
||||
placeholder="Q1 2025 Visitor Performance" />
|
||||
</Field>
|
||||
|
||||
<Field label="Prepared for (company)">
|
||||
<Field label="Prepared for">
|
||||
<input className="rf-input" type="text" value={cfg.clientName}
|
||||
onChange={e => onChange({ clientName: e.target.value })}
|
||||
placeholder="Acme Group" />
|
||||
</Field>
|
||||
|
||||
<Field label="Contact name (optional)">
|
||||
<Field label="Contact (optional)">
|
||||
<input className="rf-input" type="text" value={cfg.contactName}
|
||||
onChange={e => onChange({ contactName: e.target.value })}
|
||||
placeholder="Mohammed Al-..." />
|
||||
</Field>
|
||||
|
||||
<Field label="Client logo (PNG/JPG, max 2 MB)">
|
||||
<div className="rf-branding-row">
|
||||
<div className="rf-field">
|
||||
<span className="rf-label">Accent color</span>
|
||||
<div className="rf-color-row">
|
||||
<input type="color" value={cfg.accentColor}
|
||||
onChange={e => onChange({ accentColor: e.target.value })}
|
||||
className="rf-color-input"
|
||||
aria-label="Report accent color" />
|
||||
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rf-field">
|
||||
<span className="rf-label">Logo (PNG/JPG, max 2 MB)</span>
|
||||
<div className="rf-logo-row">
|
||||
<button type="button" className="rf-upload-btn" onClick={() => logoInputRef.current?.click()}>
|
||||
{cfg.clientLogoBase64 ? 'Change logo' : 'Upload logo'}
|
||||
{/* 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 && (
|
||||
<>
|
||||
<img src={cfg.clientLogoBase64} alt="preview" className="rf-logo-preview" />
|
||||
<button type="button" className="rf-remove-btn" onClick={() => onChange({ clientLogoBase64: null })}>✕</button>
|
||||
{/* 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>
|
||||
</Field>
|
||||
|
||||
<Field label="Accent color">
|
||||
<div className="rf-color-row">
|
||||
<input type="color" value={cfg.accentColor}
|
||||
onChange={e => onChange({ accentColor: e.target.value })}
|
||||
className="rf-color-input" />
|
||||
<span className="rf-color-val">{cfg.accentColor}</span>
|
||||
{/* C2: inline logo error */}
|
||||
{logoError && <span className="rf-field-error" role="alert">{logoError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<SectionTitle>Data Selection</SectionTitle>
|
||||
<div className="rf-divider" />
|
||||
<h2 className="rf-group-label">Data</h2>
|
||||
|
||||
<div className="rf-date-row">
|
||||
<Field label="Start date">
|
||||
<Field label="Period start">
|
||||
<input className="rf-input" type="date" value={cfg.startDate}
|
||||
onChange={e => onChange({ startDate: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="End date">
|
||||
<Field label="Period end">
|
||||
<input className="rf-input" type="date" value={cfg.endDate}
|
||||
onChange={e => onChange({ endDate: e.target.value })} />
|
||||
</Field>
|
||||
@@ -127,44 +238,162 @@ export default function ReportForm({ config: cfg, onChange, allMuseums, allChann
|
||||
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 })} />
|
||||
<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>
|
||||
|
||||
<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 label="To">
|
||||
<input className="rf-input" type="date" value={cfg.comparisonEndDate}
|
||||
onChange={e => onChange({ comparisonEndDate: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Orientation">
|
||||
<Toggle left="Portrait" right="Landscape" value={cfg.orientation === 'landscape'}
|
||||
onChange={v => onChange({ orientation: v ? 'landscape' : 'portrait' })} />
|
||||
</Field>
|
||||
<div className="rf-divider" />
|
||||
<h2 className="rf-group-label">Format</h2>
|
||||
|
||||
<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 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>
|
||||
|
||||
{/* ── Right: content selection ── */}
|
||||
<div className="rf-col">
|
||||
<h2 className="rf-group-label">Report Sections</h2>
|
||||
|
||||
<ModuleCard title="Executive Summary"
|
||||
enabled={cfg.showExecutiveSummary} onToggle={v => onChange({ showExecutiveSummary: v })} />
|
||||
<ModuleCard title="Key Metrics Table"
|
||||
enabled={cfg.showMetricsTable} onToggle={v => onChange({ showMetricsTable: v })} />
|
||||
<ModuleCard title="Pilgrim Capture Rate"
|
||||
enabled={cfg.showPilgrimCapture} onToggle={v => onChange({ showPilgrimCapture: v })} />
|
||||
|
||||
<div className="rf-divider" />
|
||||
<h2 className="rf-group-label">Trend</h2>
|
||||
|
||||
<ModuleCard
|
||||
title="Trend Chart"
|
||||
enabled={cfg.showTrendChart}
|
||||
onToggle={v => onChange({ showTrendChart: v })}
|
||||
badge={cfg.showTrendChart
|
||||
? cfg.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import type { MuseumRecord } from '../../types';
|
||||
import { DEFAULT_CONFIG, computeReportData } from './reportHelpers';
|
||||
import type { ReportConfig } from './reportHelpers';
|
||||
import { ReportDocument } from './ReportDocument';
|
||||
import ReportForm from './ReportForm';
|
||||
import ReportPreview from './ReportPreview';
|
||||
import { getUniqueMuseums, getUniqueChannels } from '../../services/dataService';
|
||||
|
||||
interface Props {
|
||||
@@ -15,18 +14,44 @@ interface Props {
|
||||
export default function ReportPage({ data }: Props) {
|
||||
const [config, setConfig] = useState<ReportConfig>(DEFAULT_CONFIG);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const allMuseums = getUniqueMuseums(data);
|
||||
const allChannels = getUniqueChannels(data);
|
||||
// H8: memoize — these scan the full records array; re-running on every patch is wasteful
|
||||
const allMuseums = useMemo(() => getUniqueMuseums(data), [data]);
|
||||
const allChannels = useMemo(() => getUniqueChannels(data), [data]);
|
||||
|
||||
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 () => {
|
||||
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;
|
||||
}
|
||||
setGenerating(true);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const reportData = computeReportData(data, config);
|
||||
const blob = await pdf(<ReportDocument data={reportData} />).toBlob();
|
||||
@@ -44,7 +69,8 @@ export default function ReportPage({ data }: Props) {
|
||||
}
|
||||
} catch (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 {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -52,28 +78,58 @@ export default function ReportPage({ data }: Props) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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 className="report-body">
|
||||
<div className="report-form-col">
|
||||
<ReportForm config={config} onChange={patch} allMuseums={allMuseums} allChannels={allChannels} />
|
||||
</div>
|
||||
<div className="report-preview-col">
|
||||
<div className="report-preview-sticky">
|
||||
<ReportPreview config={config} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
className="report-generate-btn"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
aria-busy={generating}
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import React from 'react';
|
||||
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 {
|
||||
labels: string[];
|
||||
current: number[];
|
||||
@@ -10,10 +23,11 @@ interface TrendChartProps {
|
||||
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 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 h = height - padT - padB;
|
||||
|
||||
@@ -27,20 +41,38 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
|
||||
|
||||
return (
|
||||
<Svg width={width} height={height}>
|
||||
{gridLines.map(f => (
|
||||
<Line key={f}
|
||||
x1={padL} y1={sy(max * f).toFixed(1)}
|
||||
x2={width - padR} y2={sy(max * f).toFixed(1)}
|
||||
{/* Baseline */}
|
||||
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
|
||||
stroke="#cbd5e1" strokeWidth={0.75} />
|
||||
|
||||
{/* 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) && (
|
||||
<Polyline points={toPoints(previous)}
|
||||
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
|
||||
)}
|
||||
|
||||
{/* Current period line */}
|
||||
{current.some(v => v > 0) && (
|
||||
<Polyline points={toPoints(current)}
|
||||
stroke={color} strokeWidth={2.5} fill="none" />
|
||||
)}
|
||||
|
||||
{/* X-axis week labels */}
|
||||
{labels
|
||||
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
|
||||
.map((label) => {
|
||||
@@ -60,15 +92,15 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
|
||||
interface HBarChartProps {
|
||||
items: Array<{ name: string; value: number }>;
|
||||
color: string;
|
||||
usepalette?: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
|
||||
const barH = 16;
|
||||
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
|
||||
const barH = 17;
|
||||
const gap = 10;
|
||||
const labelW = 150;
|
||||
const valueW = 70;
|
||||
const barAreaW = width - labelW - valueW - 8;
|
||||
const labelW = 160;
|
||||
const barAreaW = width - labelW - 20;
|
||||
const max = Math.max(...items.map(i => i.value), 1);
|
||||
const totalH = items.length * (barH + gap);
|
||||
|
||||
@@ -77,13 +109,21 @@ export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
|
||||
{items.map((item, i) => {
|
||||
const y = i * (barH + gap);
|
||||
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 barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
|
||||
const isShort = bw < 48;
|
||||
return (
|
||||
<G key={item.name}>
|
||||
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8 }}>{shortName}</SvgText>
|
||||
<Rect x={labelW} y={y} width={bw} height={barH} fill={color} rx={3} />
|
||||
<SvgText x={labelW + bw + 4} y={y + barH - 4} fill="#64748b" style={{ fontSize: 8 }}>{valueStr}</SvgText>
|
||||
<G key={item.name + i}>
|
||||
<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={barColor} rx={3} />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 type { MuseumRecord, Metrics } from '../../types';
|
||||
|
||||
// ─── 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 {
|
||||
title: string;
|
||||
clientName: string;
|
||||
@@ -15,12 +20,30 @@ export interface ReportConfig {
|
||||
selectedChannels: string[];
|
||||
includeVAT: boolean;
|
||||
includeComparison: boolean;
|
||||
comparisonStartDate: string;
|
||||
comparisonEndDate: string;
|
||||
// Summary & metrics
|
||||
showExecutiveSummary: boolean;
|
||||
showMetricsTable: boolean;
|
||||
showTrendChart: boolean;
|
||||
showMuseumBreakdown: boolean;
|
||||
showChannelBreakdown: 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';
|
||||
confidentiality: 'Confidential' | 'Internal' | 'Public';
|
||||
orientation: 'portrait' | 'landscape';
|
||||
@@ -32,18 +55,29 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
||||
contactName: '',
|
||||
clientLogoBase64: null,
|
||||
accentColor: '#2563eb',
|
||||
startDate: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().slice(0, 10),
|
||||
endDate: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
startDate: _start,
|
||||
endDate: _end,
|
||||
selectedMuseums: [],
|
||||
selectedChannels: [],
|
||||
includeVAT: true,
|
||||
includeComparison: true,
|
||||
comparisonStartDate: shiftYear(_start),
|
||||
comparisonEndDate: shiftYear(_end),
|
||||
showExecutiveSummary: true,
|
||||
showMetricsTable: true,
|
||||
showTrendChart: true,
|
||||
showMuseumBreakdown: true,
|
||||
showChannelBreakdown: 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',
|
||||
confidentiality: 'Confidential',
|
||||
orientation: 'portrait',
|
||||
@@ -52,16 +86,30 @@ export const DEFAULT_CONFIG: ReportConfig = {
|
||||
// ─── computed report data ─────────────────────────────────────────
|
||||
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 {
|
||||
config: ReportConfig;
|
||||
metrics: Metrics;
|
||||
prevMetrics: Metrics | null;
|
||||
comparisonPeriodLabel: string;
|
||||
trendLabels: string[];
|
||||
trendCurrent: number[];
|
||||
trendPrevious: number[] | null;
|
||||
museumBreakdown: BreakdownItem[]; // revenue by museum
|
||||
museumVisitorBreakdown: BreakdownItem[]; // visitors by museum
|
||||
channelBreakdown: BreakdownItem[];
|
||||
museumData: MuseumDataRow[];
|
||||
museumBreakdown: DimensionBreakdown;
|
||||
channelBreakdown: DimensionBreakdown;
|
||||
districtBreakdown: DimensionBreakdown;
|
||||
pilgrimCapture: { current: number; previous: number | null } | null;
|
||||
generatedAt: string;
|
||||
}
|
||||
@@ -93,6 +141,12 @@ function estimatePilgrims(start: string, end: string): number | 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[] } {
|
||||
const s = new Date(start);
|
||||
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 values = labels.map((_, i) => {
|
||||
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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
const currRows = applyDimFilters(filterDataByDateRange(allData, cfg.startDate, cfg.endDate, {}), cfg);
|
||||
const metrics = calculateMetrics(currRows, cfg.includeVAT);
|
||||
|
||||
const prevStart = shiftYear(cfg.startDate);
|
||||
const prevEnd = shiftYear(cfg.endDate);
|
||||
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 comparisonPeriodLabel = cfg.includeComparison
|
||||
? formatPeriodLabel(cfg.comparisonStartDate, cfg.comparisonEndDate, cfg.language)
|
||||
: '';
|
||||
|
||||
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 trendLabels = Array.from({ length: maxLen }, (_, i) => `W${i + 1}`);
|
||||
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)
|
||||
: null;
|
||||
|
||||
const musG = groupByMuseum(currRows, cfg.includeVAT);
|
||||
const museumBreakdown: BreakdownItem[] = Object.entries(musG)
|
||||
.map(([name, g]) => ({ name, value: g.revenue }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 10);
|
||||
const currMuseumGroups = groupByMuseum(currRows, cfg.includeVAT);
|
||||
const prevMuseumGroups = cfg.includeComparison ? groupByMuseum(prevRows, cfg.includeVAT) : {};
|
||||
const museumData: MuseumDataRow[] = Object.entries(currMuseumGroups)
|
||||
.map(([name, curr]) => ({ name, curr, prev: prevMuseumGroups[name] ?? null }))
|
||||
.sort((a, b) => b.curr.revenue - a.curr.revenue);
|
||||
|
||||
const museumVisitorBreakdown: BreakdownItem[] = Object.entries(musG)
|
||||
.map(([name, g]) => ({ name, value: g.visitors }))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.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 museumBreakdown = makeDimensionBreakdown(currMuseumGroups);
|
||||
const channelBreakdown = makeDimensionBreakdown(groupByChannel(currRows, cfg.includeVAT), 20);
|
||||
const districtBreakdown = makeDimensionBreakdown(groupByDistrict(currRows, cfg.includeVAT));
|
||||
|
||||
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
|
||||
? {
|
||||
current: parseFloat(((metrics.visitors / currPilgrims) * 100).toFixed(2)),
|
||||
@@ -163,12 +222,14 @@ export function computeReportData(allData: MuseumRecord[], cfg: ReportConfig): R
|
||||
config: cfg,
|
||||
metrics,
|
||||
prevMetrics,
|
||||
comparisonPeriodLabel,
|
||||
trendLabels,
|
||||
trendCurrent,
|
||||
trendPrevious,
|
||||
museumData,
|
||||
museumBreakdown,
|
||||
museumVisitorBreakdown,
|
||||
channelBreakdown,
|
||||
districtBreakdown,
|
||||
pilgrimCapture,
|
||||
generatedAt: new Date().toLocaleDateString('en-GB'),
|
||||
};
|
||||
@@ -197,14 +258,14 @@ export function formatPeriodLabel(start: string, end: string, lang: 'en' | 'ar')
|
||||
|
||||
// ─── executive summary ────────────────────────────────────────────
|
||||
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 period = formatPeriodLabel(cfg.startDate, cfg.endDate, lang);
|
||||
const revenue = formatCurrency(metrics.revenue, cfg.includeVAT);
|
||||
const topChannel = channelBreakdown[0]?.name ?? '';
|
||||
const totalVisitors = channelBreakdown.reduce((s, i) => s + i.value, 0);
|
||||
const topPct = totalVisitors > 0 && channelBreakdown[0]
|
||||
? Math.round((channelBreakdown[0].value / totalVisitors) * 100)
|
||||
const topChannel = channelBreakdown.visitors[0]?.name ?? '';
|
||||
const totalVisitors = channelBreakdown.visitors.reduce((s, i) => s + i.value, 0);
|
||||
const topPct = totalVisitors > 0 && channelBreakdown.visitors[0]
|
||||
? Math.round((channelBreakdown.visitors[0].value / totalVisitors) * 100)
|
||||
: 0;
|
||||
const museumLabel = cfg.selectedMuseums.length > 0
|
||||
? 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.`;
|
||||
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||
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.`;
|
||||
return s;
|
||||
@@ -222,7 +283,7 @@ export function generateExecutiveSummary(data: ReportData): string {
|
||||
let s = `خلال ${period}، سجّلت ${museumLabel} ${metrics.visitors.toLocaleString()} زائراً وإيرادات بلغت ${revenue}.`;
|
||||
if (prevMetrics && prevMetrics.revenue > 0) {
|
||||
const chg = Math.round(((metrics.revenue - prevMetrics.revenue) / prevMetrics.revenue) * 100);
|
||||
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بالفترة ذاتها من العام الماضي.`;
|
||||
s += ` يمثّل ذلك تغيّراً بنسبة ${formatPct(chg)} في الإيرادات مقارنةً بـ${comparisonPeriodLabel}.`;
|
||||
}
|
||||
if (topChannel) s += ` كانت ${topChannel} أعلى القنوات أداءً بنسبة ${topPct}% من إجمالي الزوار.`;
|
||||
return s;
|
||||
@@ -231,8 +292,10 @@ export function generateExecutiveSummary(data: ReportData): string {
|
||||
|
||||
// ─── page count estimator ─────────────────────────────────────────
|
||||
export function estimatePageCount(cfg: ReportConfig): number {
|
||||
let pages = 2; // cover + first content page
|
||||
if (cfg.showMuseumBreakdown) pages += 1;
|
||||
if (cfg.showChannelBreakdown) pages += 1;
|
||||
let pages = 2; // cover + summary/metrics/trend
|
||||
if (cfg.showMuseumRevenue || cfg.showMuseumVisitors || cfg.showMuseumTickets) 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user