Takes in a file, process it and output a new file with additional columns.
This commit is contained in:
Stefan Lazic
2026-07-10 15:45:02 +01:00
parent 2f387a4c1c
commit a451060c1b
876 changed files with 169373 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
$exportRunUrl = (string) ($exportRunUrl ?? '?action=export-run');
$exportStatusUrl = (string) ($exportStatusUrl ?? '?action=export-status');
$exportDownloadUrl = (string) ($exportDownloadUrl ?? '?action=export-download');
function h(mixed $value): string
{
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function flashClasses(?array $flash): string
{
$type = (string) ($flash['type'] ?? 'info');
return match ($type) {
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
default => 'border-sky-200 bg-sky-50 text-sky-900',
};
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($appName) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind = {
config: {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
</head>
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
<div class="app-shell">
<?php require __DIR__ . '/partials/navbar.php'; ?>
<div class="mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8 lg:p-10">
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">Export</span>
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl">Export data</h1>
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
Download the imported data in the format you need.
</p>
</div>
</section>
<?php if ($flash !== null): ?>
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
<?= h($flash['message'] ?? '') ?>
</div>
<?php endif; ?>
<section class="rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8 lg:p-10">
<h2 class="text-2xl font-semibold tracking-tight">Export options</h2>
<?php if (!$hasData): ?>
<p class="mt-4 text-sm leading-6 text-slate-500">No imported data yet. Upload a workbook first to enable exports.</p>
<?php else: ?>
<div class="mt-6 rounded-md border border-slate-200/80 bg-slate-50/80 p-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-lg font-semibold text-slate-900">Full table</div>
<p class="mt-1 max-w-xl text-sm leading-6 text-slate-500">
Every imported row (<?= number_format($latestBatchRowCount) ?> total), exported as an Excel workbook.
</p>
</div>
<button
type="button"
id="exportButton"
class="inline-flex shrink-0 items-center justify-center rounded-md bg-sky-600 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
>
Export as Excel (.xlsx)
</button>
</div>
<div id="exportProgressWrap" class="mt-5 hidden">
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
<span id="exportProgressLabel">Starting export…</span>
<span id="exportProgressPercent">0%</span>
</div>
<div class="h-3 overflow-hidden rounded-md bg-slate-200">
<div id="exportProgressBar" class="h-full w-0 rounded-md bg-sky-500 transition-all duration-300"></div>
</div>
</div>
<div id="exportError" class="mt-4 hidden rounded-md border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-900"></div>
</div>
<?php endif; ?>
</div>
</section>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const exportButton = document.getElementById('exportButton');
if (!exportButton) {
return;
}
const progressWrap = document.getElementById('exportProgressWrap');
const progressBar = document.getElementById('exportProgressBar');
const progressLabel = document.getElementById('exportProgressLabel');
const progressPercent = document.getElementById('exportProgressPercent');
const errorBox = document.getElementById('exportError');
const runUrl = <?= json_encode($exportRunUrl, JSON_THROW_ON_ERROR) ?>;
const statusUrl = <?= json_encode($exportStatusUrl, JSON_THROW_ON_ERROR) ?>;
const downloadUrl = <?= json_encode($exportDownloadUrl, JSON_THROW_ON_ERROR) ?>;
const makeToken = () => (window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const setProgress = (percent, label) => {
progressBar.style.width = `${percent}%`;
progressPercent.textContent = `${percent}%`;
progressLabel.textContent = label;
};
const showError = (message) => {
errorBox.textContent = message;
errorBox.classList.remove('hidden');
};
exportButton.addEventListener('click', () => {
const token = makeToken();
exportButton.disabled = true;
errorBox.classList.add('hidden');
progressWrap.classList.remove('hidden');
setProgress(0, 'Starting export…');
let pollTimer = null;
const stopPolling = () => {
if (pollTimer !== null) {
window.clearInterval(pollTimer);
pollTimer = null;
}
};
const finish = () => {
stopPolling();
exportButton.disabled = false;
window.setTimeout(() => progressWrap.classList.add('hidden'), 1500);
};
pollTimer = window.setInterval(async () => {
try {
const response = await fetch(`${statusUrl}&token=${encodeURIComponent(token)}`, {
headers: { Accept: 'application/json' },
});
const data = await response.json();
if (data.status === 'running') {
setProgress(data.progressPercent ?? 0, data.message || 'Exporting…');
} else if (data.status === 'completed') {
setProgress(100, 'Export complete. Downloading…');
finish();
window.location.href = data.downloadUrl || `${downloadUrl}&token=${encodeURIComponent(token)}`;
} else if (data.status === 'error') {
finish();
progressWrap.classList.add('hidden');
showError(data.message || 'Export failed.');
}
} catch (error) {
finish();
progressWrap.classList.add('hidden');
showError('Lost connection while checking export progress.');
}
}, 700);
fetch(`${runUrl}&token=${encodeURIComponent(token)}`, {
headers: { Accept: 'application/json' },
}).catch(() => {
finish();
progressWrap.classList.add('hidden');
showError('Could not start the export.');
});
});
});
</script>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
$previewLimit = (int) ($previewLimit ?? 20);
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
$previewCount = count($rows ?? []);
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
function h(mixed $value): string
{
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function renderCell(mixed $value): string
{
if ($value === null || $value === '') {
return '<span class="text-slate-400">—</span>';
}
return h($value);
}
function flashClasses(?array $flash): string
{
$type = (string) ($flash['type'] ?? 'info');
return match ($type) {
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
default => 'border-sky-200 bg-sky-50 text-sky-900',
};
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($appName) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind = {
config: {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
<style>
th {
min-width: 140px;
}
</style>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
<div class="app-shell">
<?php require __DIR__ . '/partials/navbar.php'; ?>
<div class="mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8 lg:p-10">
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-3xl">
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">Stage 1</span>
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl"><?= h($appName) ?></h1>
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
Upload an Excel workbook, wipe the current imported dataset, process the rows, and review the stored results with derived fields.
</p>
</div>
<div class="rounded-md border border-slate-200/80 bg-slate-50/80 px-6 py-5 text-left lg:min-w-72 lg:text-right">
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Current imported rows</div>
<div class="mt-2 text-5xl font-semibold tracking-tight text-slate-900"><?= number_format($latestBatchRowCount) ?></div>
<div class="mt-2 text-sm text-slate-500">
Showing a preview of the latest <?= number_format(min($previewLimit, $previewCount)) ?> row(s).
</div>
</div>
</div>
</div>
</section>
<?php if ($flash !== null): ?>
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
<?= h($flash['message'] ?? '') ?>
</div>
<?php endif; ?>
<div class="grid gap-4 lg:grid-cols-12">
<section class="lg:col-span-8">
<div class="h-full rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8">
<div class="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h2 class="text-2xl font-semibold tracking-tight">Upload and import</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
The previous imported data is cleared before the new workbook is inserted.
</p>
</div>
<button
class="inline-flex items-center justify-center rounded-md border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
id="clearButton"
type="button"
>
Clear imported data
</button>
</div>
<form id="importForm" class="space-y-5" method="post" enctype="multipart/form-data">
<input type="hidden" name="action" value="import">
<div>
<label for="spreadsheet" class="mb-2 block text-sm font-semibold text-slate-700">Excel file</label>
<input
class="block w-full cursor-pointer rounded-md border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700 outline-none transition file:mr-4 file:rounded-md file:border-0 file:bg-slate-900 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:border-slate-300 focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
type="file"
id="spreadsheet"
name="spreadsheet"
accept=".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
required
>
<p class="mt-2 text-xs text-slate-500">Supported files: <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">.xls</code> and <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">.xlsx</code>.</p>
</div>
<div class="rounded-md border border-slate-200 bg-slate-50 p-4">
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
<span id="progressLabel">Ready to import</span>
<span id="progressPercent">0%</span>
</div>
<div class="h-3 overflow-hidden rounded-md bg-slate-200">
<div id="importProgress" class="h-full w-0 rounded-md bg-sky-500 transition-all duration-300"></div>
</div>
<div class="mt-3 flex flex-wrap justify-between gap-3 text-sm text-slate-500">
<span>Rows detected: <strong id="importTotalRows" class="text-slate-800">0</strong></span>
<span>Chunk size: <strong id="importChunkSize" class="text-slate-800"><?= number_format((int) app_config('import.chunk_size', 5)) ?></strong></span>
</div>
</div>
<div class="flex flex-wrap gap-3">
<button type="submit" class="inline-flex items-center justify-center rounded-md bg-sky-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:bg-sky-500">
Process and import
</button>
<button type="reset" class="inline-flex items-center justify-center rounded-md border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Reset form
</button>
<a class="inline-flex items-center justify-center rounded-md border border-sky-200 bg-sky-50 px-5 py-3 text-sm font-semibold text-sky-700 transition hover:bg-sky-100" href="<?= h($tableUrl) ?>">
Open full table
</a>
</div>
</form>
<div class="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">1</span>
Validate file
</div>
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">2</span>
Wipe existing rows
</div>
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">3</span>
Normalize and derive fields
</div>
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">4</span>
Store in PostgreSQL
</div>
<div class="rounded-md border border-sky-100 bg-sky-50 px-4 py-3 text-sm font-medium text-sky-900">
<span class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-sky-600 text-xs font-bold text-white">5</span>
Review preview table
</div>
</div>
<div id="statusAlert" class="mt-6 hidden rounded-md border px-4 py-3 text-sm font-medium" role="alert"></div>
</div>
</div>
</section>
<aside class="lg:col-span-4">
<div class="h-full rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8">
<h2 class="text-2xl font-semibold tracking-tight">Import rules in stage 1</h2>
<div class="mt-5 space-y-3">
<?php foreach ($requiredColumns as $column): ?>
<div class="flex items-center justify-between gap-4 rounded-md border border-slate-200 bg-slate-50 px-4 py-3">
<span class="text-sm font-medium text-slate-700"><?= h($column) ?></span>
<span class="inline-flex items-center rounded-md bg-white px-3 py-1 text-xs font-semibold text-slate-500 ring-1 ring-slate-200">Required</span>
</div>
<?php endforeach; ?>
</div>
<p class="mt-5 text-sm leading-6 text-slate-500">
The workbook header <strong class="text-slate-700">Revenue Type</strong> is ignored. All other columns are imported and the calculated columns are appended to the preview.
</p>
<?php if ($latestBatch !== null): ?>
<div class="mt-8 rounded-md border border-slate-200 bg-slate-50 p-4">
<div class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Last imported file</div>
<div class="mt-2 text-sm font-semibold text-slate-900"><?= h($latestBatch['source_filename'] ?? '') ?></div>
<div class="mt-1 text-sm text-slate-500"><?= h($latestBatch['created_at'] ?? '') ?></div>
</div>
<?php endif; ?>
</div>
</div>
</aside>
</div>
</div>
<div class="mx-auto w-full px-2 pb-6">
<section class="rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8">
<div class="mb-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-2xl font-semibold tracking-tight">Imported data preview</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
<?= $hasData ? 'Showing the latest 20 rows from the current batch.' : 'No imported data yet. Upload a workbook to populate the table.' ?>
</p>
</div>
<?php if ($hasData): ?>
<div class="text-sm text-slate-500">
Showing <strong class="text-slate-900"><?= number_format($previewCount) ?></strong> of <strong class="text-slate-900"><?= number_format($latestBatchRowCount) ?></strong> row(s)
</div>
<?php endif; ?>
</div>
<?php if ($hasData): ?>
<div class="overflow-x-auto rounded-md border border-slate-200">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
<tr>
<th class="whitespace-nowrap px-4 py-3">Row</th>
<?php foreach ($tableHeaders as $header): ?>
<th class="whitespace-nowrap px-4 py-3"><?= h($header) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 bg-white">
<?php foreach ($rows as $row): ?>
<tr class="hover:bg-slate-50/80">
<td class="whitespace-nowrap px-4 py-3 font-medium text-slate-500"><?= number_format((int) $row['row_number']) ?></td>
<?php foreach ($tableHeaders as $header): ?>
<td class="px-4 py-3 align-top text-slate-700"><?= renderCell($row['merged_row'][$header] ?? null) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($latestBatchRowCount > $previewLimit): ?>
<div class="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div class="text-sm text-slate-500">
Preview is limited to <?= number_format($previewLimit) ?> row(s). Open the full table for search and pagination.
</div>
<a class="inline-flex items-center justify-center rounded-md border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-semibold text-sky-700 transition hover:bg-sky-100" href="<?= h($tableUrl) ?>">
Go to full table
</a>
</div>
<?php endif; ?>
<?php else: ?>
<div class="rounded-3xl border border-dashed border-slate-300 bg-slate-50/70 p-10 text-center">
<div class="text-2xl font-semibold text-slate-900">No data loaded</div>
<p class="mt-3 text-sm leading-6 text-slate-500">Choose an Excel workbook and run the import to display the latest processed rows here.</p>
</div>
<?php endif; ?>
</div>
</section>
</div>
</div>
<script>
window.APP_CONFIG = {
importUrl: window.location.pathname + '?action=import',
processChunkUrl: window.location.pathname + '?action=process-chunk',
clearUrl: window.location.pathname + '?action=clear',
reloadUrl: window.location.pathname,
importChunkSize: <?= (int) app_config('import.chunk_size', 5) ?>
};
</script>
<script src="<?= h(asset_url('app.js')) ?>"></script>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
$currentView = (string) ($currentView ?? 'preview');
$homeUrl = (string) ($homeUrl ?? '?view=preview');
$tableUrl = (string) ($tableUrl ?? '?view=table');
$exportUrl = (string) ($exportUrl ?? '?view=export');
$appName = (string) ($appName ?? 'Spreadsheet Importer');
$previewActive = $currentView === 'preview';
$tableActive = $currentView === 'table';
$exportActive = $currentView === 'export';
?>
<nav class="app-navbar sticky top-0 z-30 border-b border-white/60 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl" aria-label="Primary">
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
<a href="<?= h($homeUrl) ?>" class="inline-flex items-center gap-3 self-start">
<span class="flex h-10 w-10 items-center justify-center rounded-md bg-sky-600 text-sm font-semibold text-white shadow-lg shadow-sky-500/25">W</span>
<div>
<div class="text-sm font-semibold uppercase tracking-[0.24em] text-slate-400">Warner</div>
<div class="text-lg font-semibold text-slate-900"><?= h($appName) ?></div>
</div>
</a>
<div class="flex flex-wrap items-center gap-2">
<ul class="flex flex-wrap gap-2">
<li>
<a
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
<?= $previewActive ? 'bg-slate-900 text-white shadow-lg shadow-slate-900/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
href="<?= h($homeUrl) ?>"
<?= $previewActive ? 'aria-current="page"' : '' ?>
>
Preview
</a>
</li>
<li>
<a
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
<?= $tableActive ? 'bg-sky-600 text-white shadow-lg shadow-sky-500/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
href="<?= h($tableUrl) ?>"
<?= $tableActive ? 'aria-current="page"' : '' ?>
>
Full table
</a>
</li>
<li>
<a
class="inline-flex items-center rounded-md px-4 py-2 text-sm font-semibold transition
<?= $exportActive ? 'bg-sky-600 text-white shadow-lg shadow-sky-500/20' : 'bg-white/90 text-slate-700 ring-1 ring-slate-200 hover:bg-slate-50' ?>"
href="<?= h($exportUrl) ?>"
<?= $exportActive ? 'aria-current="page"' : '' ?>
>
Export
</a>
</li>
</ul>
<?php if ($tableActive): ?>
<button
type="button"
id="blurToggleButton"
class="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-white/90 px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50"
aria-pressed="false"
title="Blur table data for privacy"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8Z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span data-blur-label>Blur data</span>
</button>
<?php endif; ?>
</div>
</div>
</nav>
+826
View File
@@ -0,0 +1,826 @@
<?php
declare(strict_types=1);
$latestBatchRowCount = (int) ($latestBatchRowCount ?? 0);
$hasData = $latestBatch !== null && $latestBatchRowCount > 0;
function h(mixed $value): string
{
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES, 'UTF-8');
}
function flashClasses(?array $flash): string
{
$type = (string) ($flash['type'] ?? 'info');
return match ($type) {
'success' => 'border-emerald-200 bg-emerald-50 text-emerald-900',
'danger' => 'border-rose-200 bg-rose-50 text-rose-900',
'warning' => 'border-amber-200 bg-amber-50 text-amber-900',
default => 'border-sky-200 bg-sky-50 text-sky-900',
};
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($appName) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind = {
config: {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/ag-grid-community/styles/ag-theme-quartz.css" rel="stylesheet">
<link href="<?= h(asset_url('styles.css')) ?>" rel="stylesheet">
<style>
.cell-flag-positive { background-color: #dcfce7; color: #166534; font-weight: 600; }
.cell-flag-negative { background-color: #fee2e2; color: #991b1b; font-weight: 600; }
.pin-toggle-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 4px; border: 1px solid transparent;
color: #94a3b8; background: transparent; cursor: pointer; transition: all .12s ease;
flex-shrink: 0;
}
.pin-toggle-btn:hover { background: #f1f5f9; color: #475569; }
.pin-toggle-btn.is-pinned { color: #fff; background: #0284c7; }
.pin-toggle-btn.is-pinned:hover { background: #0369a1; }
.pinnable-header {
display: flex; align-items: center; justify-content: space-between;
width: 100%; gap: 6px;
}
.pinnable-header__label {
overflow: hidden; text-overflow: ellipsis; white-space: break-spaces;
cursor: pointer; min-width: 0;
}
.pinnable-header__buttons {
display: flex; align-items: center; gap: 2px; flex-shrink: 0;
}
.pin-toggle-btn--header { width: 22px; height: 22px; }
.pin-toggle-btn--header svg { width: 11px; height: 11px; }
.filter-toggle-btn.is-active { color: #fff; background: #f59e0b; }
.filter-toggle-btn.is-active:hover { background: #d97706; }
#blurToggleButton.is-active { border-color: transparent; background: #0f172a; color: #fff; }
#blurToggleButton.is-active:hover { background: #1e293b; }
#fullDataGrid.is-privacy-blurred { filter: blur(7px); transition: filter .15s ease; }
.excel-filter { display: flex; flex-direction: column; width: 240px; padding: 10px; gap: 8px; font-family: inherit; }
.excel-filter__search-input {
width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 6px 10px; font-size: 13px; outline: none;
}
.excel-filter__search-input:focus { border-color: #38bdf8; box-shadow: 0 0 0 3px rgba(56,189,248,.15); }
.excel-filter__actions { display: flex; justify-content: space-between; }
.excel-filter__link { background: none; border: none; color: #0284c7; font-size: 12px; font-weight: 600; cursor: pointer; padding: 0; }
.excel-filter__link:hover { text-decoration: underline; }
.excel-filter__list { max-height: 220px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; padding: 4px; }
.excel-filter__item { display: flex; align-items: center; gap: 8px; padding: 4px 6px; font-size: 13px; border-radius: 6px; cursor: pointer; }
.excel-filter__item:hover { background: #f8fafc; }
.excel-filter__loading { padding: 8px 6px; font-size: 12px; color: #94a3b8; }
.excel-filter__footer { display: flex; justify-content: flex-end; }
.excel-filter__apply {
background: #0284c7; color: #fff; border: none; border-radius: 8px;
padding: 6px 14px; font-size: 13px; font-weight: 600; cursor: pointer;
}
.excel-filter__apply:hover { background: #0369a1; }
</style>
</head>
<body class="datatable-page min-h-screen bg-slate-100 text-slate-900 antialiased">
<div class="app-shell">
<?php require __DIR__ . '/partials/navbar.php'; ?>
<div class="hidden mx-auto max-w-7xl px-4 py-4 lg:px-8 lg:py-6">
<section class="mb-4 overflow-hidden rounded-md border border-white/70 bg-white/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="p-6 sm:p-8 lg:p-10">
<div class="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-3xl">
<span class="inline-flex items-center rounded-md bg-slate-900 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white">AG Grid</span>
<h1 class="mt-4 text-4xl font-semibold tracking-tight sm:text-5xl"><?= h($appName) ?></h1>
<p class="mt-4 max-w-2xl text-base leading-7 text-slate-600 sm:text-lg">
Browse the latest imported batch with server-side search, sorting, and column filters.
</p>
</div>
<div class="rounded-md border border-slate-200/80 bg-slate-50/80 px-6 py-5 text-left lg:min-w-72 lg:text-right">
<div class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">Rows in latest batch</div>
<div class="mt-2 text-5xl font-semibold tracking-tight text-slate-900"><?= number_format($latestBatchRowCount) ?></div>
<div class="mt-2 text-sm text-slate-500">Rows are loaded from the server only as needed.</div>
</div>
</div>
</div>
</section>
<?php if ($flash !== null): ?>
<div class="mb-4 rounded-md border px-4 py-3 text-sm font-medium shadow-sm <?= h(flashClasses($flash)) ?>">
<?= h($flash['message'] ?? '') ?>
</div>
<?php endif; ?>
</div>
<div class="mx-auto max-w-full px-2 py-6">
<div class="hidden mb-4 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 class="hidden text-2xl font-semibold tracking-tight">Imported data</h2>
<p class="hidden mt-2 text-sm leading-6 text-slate-500">
<?= $hasData ? 'AG Grid streams rows from the server and applies column filters without loading the whole batch into memory.' : 'No imported data yet. Upload a workbook to populate the grid.' ?>
</p>
</div>
</div>
<?php if ($hasData): ?>
<div class="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<div class="flex min-w-0 items-center overflow-hidden rounded-md border border-slate-200 bg-white shadow-sm">
<span class="px-4 py-2 text-sm font-semibold text-slate-500">Search</span>
<input
id="tableSearchInput"
type="search"
class="w-full min-w-0 border-0 bg-transparent px-0 py-2 pr-4 text-sm text-slate-700 outline-none placeholder:text-slate-400"
placeholder="Search imported rows"
autocomplete="off"
>
</div>
<div class="relative">
<button
class="inline-flex items-center rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50"
type="button"
id="columnVisibilityButton"
aria-expanded="false"
>
Columns
</button>
<div
id="columnVisibilityMenu"
class="invisible absolute left-0 z-20 mt-2 w-80 rounded-md border border-slate-200 bg-white p-4 opacity-0 shadow-[0_20px_60px_rgba(15,23,42,0.12)] transition duration-150"
>
<div class="mb-3">
<input
id="columnVisibilitySearch"
type="search"
class="w-full rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
placeholder="Filter columns"
autocomplete="off"
>
</div>
<div id="columnVisibilityItems" class="max-h-96 space-y-1 overflow-y-auto pr-1"></div>
</div>
</div>
</div>
<div class="flex flex-col items-start gap-2 lg:items-end">
<div class="flex items-center gap-4 text-xs font-medium text-slate-500">
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-md bg-emerald-200"></span>Y / N<span class="h-2.5 w-2.5 rounded-md bg-rose-200"></span></span>
<span class="flex items-center gap-1.5"><span class="h-2.5 w-2.5 rounded-md bg-emerald-200"></span>F / D<span class="h-2.5 w-2.5 rounded-md bg-rose-200"></span></span>
</div>
</div>
</div>
<div id="fullDataGrid" class="ag-theme-quartz datagrid-shell" style="height: 85vh; min-height: 620px; width: 100%;"></div>
<?php else: ?>
<div class="rounded-md border border-dashed border-slate-300 bg-slate-50/70 p-10 text-center">
<div class="text-2xl font-semibold text-slate-900">No data loaded</div>
<p class="mt-3 text-sm leading-6 text-slate-500">Choose an Excel workbook and run the import to display the latest processed rows here.</p>
</div>
<?php endif; ?>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ag-grid-community/dist/ag-grid-community.min.js"></script>
<script>
window.APP_CONFIG = {
tableDataUrl: window.location.pathname + '?view=table&action=grid-data'
};
document.addEventListener('DOMContentLoaded', () => {
const gridElement = document.getElementById('fullDataGrid');
if (!gridElement) {
return;
}
const searchInput = document.getElementById('tableSearchInput');
const visibilityButton = document.getElementById('columnVisibilityButton');
const visibilityMenu = document.getElementById('columnVisibilityMenu');
const visibilitySearch = document.getElementById('columnVisibilitySearch');
const visibilityItems = document.getElementById('columnVisibilityItems');
const blurToggleButton = document.getElementById('blurToggleButton');
const headerLabels = <?= json_encode(array_values($tableHeaders), JSON_THROW_ON_ERROR) ?>;
// Move these columns to appear right before "DSP" instead of their
// default import order.
['US/ex-US sale', 'Actual/Accrual'].forEach((header) => {
const fromIndex = headerLabels.indexOf(header);
if (fromIndex === -1) {
return;
}
headerLabels.splice(fromIndex, 1);
const dspIndex = headerLabels.indexOf('DSP');
headerLabels.splice(dspIndex === -1 ? headerLabels.length : dspIndex, 0, header);
});
let gridApi = null;
let currentSearch = searchInput ? searchInput.value.trim() : '';
let searchTimer = null;
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => {
switch (character) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
case '\'':
return '&#39;';
default:
return character;
}
});
const closeVisibilityMenu = () => {
if (!visibilityMenu || !visibilityButton) {
return;
}
visibilityMenu.classList.add('invisible', 'opacity-0', 'pointer-events-none');
visibilityMenu.classList.remove('visible', 'opacity-100');
visibilityButton.setAttribute('aria-expanded', 'false');
};
const openVisibilityMenu = () => {
if (!visibilityMenu || !visibilityButton) {
return;
}
visibilityMenu.classList.remove('invisible', 'opacity-0', 'pointer-events-none');
visibilityMenu.classList.add('visible', 'opacity-100');
visibilityButton.setAttribute('aria-expanded', 'true');
};
const toggleVisibilityMenu = () => {
if (!visibilityMenu || visibilityMenu.classList.contains('invisible')) {
openVisibilityMenu();
return;
}
closeVisibilityMenu();
};
const currencyHeaderPattern = /usd/i;
const flagHeaderPattern = /flag$/i;
const FLAG_POSITIVE = new Set(['Y', 'F']);
const FLAG_NEGATIVE = new Set(['N', 'D']);
const formatCurrency = (value) => {
if (value === null || value === undefined || value === '') {
return '';
}
const number = Number(value);
if (!Number.isFinite(number)) {
return String(value);
}
const formatted = Math.abs(number).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return number < 0 ? `(${formatted})` : formatted;
};
// --- Pinned columns ---------------------------------------------------
// Each data column gets a pin toggle in its header (see PinnableHeader
// component below). Clicking it sets that column's AG Grid `pinned`
// state to 'left' or null via applyColumnState. This is native AG Grid
// column pinning, so it works correctly with horizontal scroll,
// resizing, and column reordering out of the box.
const pinnedColumnIds = new Set();
const isColumnPinned = (colId) => pinnedColumnIds.has(colId);
const toggleColumnPin = (colId) => {
if (!gridApi) {
return;
}
const willPin = !isColumnPinned(colId);
if (willPin) {
pinnedColumnIds.add(colId);
} else {
pinnedColumnIds.delete(colId);
}
gridApi.applyColumnState({
state: [{ colId, pinned: willPin ? 'left' : null }],
defaultState: { pinned: null },
});
// Refresh the header so the pin icon reflects the new state.
gridApi.refreshHeader();
};
const pinIconSvg = '<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor"><path d="M14.7 2.5a1 1 0 0 1 1.4 0l5.4 5.4a1 1 0 0 1 0 1.4l-1.5 1.5a1 1 0 0 1-1.4 0l-.3-.3-2.8 2.8.6 3.6a1 1 0 0 1-.27.9l-.9.9a1 1 0 0 1-1.42 0l-3.3-3.3-4.6 4.6a1 1 0 0 1-1.42-1.42l4.6-4.6-3.3-3.3a1 1 0 0 1 0-1.42l.9-.9a1 1 0 0 1 .9-.27l3.6.6 2.8-2.8-.3-.3a1 1 0 0 1 0-1.4Z"/></svg>';
const filterIconSvg = '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 .8 1.6l-6.3 8.2v6a1 1 0 0 1-.5.87l-3 1.7A1 1 0 0 1 9.5 21v-8.2L3.2 4.6A1 1 0 0 1 3 4Z"/></svg>';
// Custom header component: renders the default header label/sort icon
// plus a pin toggle button. Registered per-column via headerComponent.
class PinnableHeader {
init(params) {
this.params = params;
this.eGui = document.createElement('div');
this.eGui.className = 'pinnable-header';
this.eLabel = document.createElement('span');
this.eLabel.className = 'pinnable-header__label';
this.eLabel.textContent = params.displayName;
this.eButton = document.createElement('button');
this.eButton.type = 'button';
this.eButton.className = 'pin-toggle-btn pin-toggle-btn--header';
this.eButton.innerHTML = pinIconSvg;
this.updateButtonState();
this.onClick = (event) => {
event.stopPropagation();
toggleColumnPin(params.column.getColId());
};
this.eButton.addEventListener('click', this.onClick);
this.eGui.appendChild(this.eLabel);
this.eButtons = document.createElement('div');
this.eButtons.className = 'pinnable-header__buttons';
// A custom headerComponent replaces AG Grid's built-in header
// entirely, including the filter funnel icon it would normally
// add — so for any column that has a filter, add our own
// funnel button that opens that column's filter popup.
if (params.column.getColDef().filter) {
this.eFilterButton = document.createElement('button');
this.eFilterButton.type = 'button';
this.eFilterButton.className = 'pin-toggle-btn pin-toggle-btn--header filter-toggle-btn';
this.eFilterButton.innerHTML = filterIconSvg;
this.updateFilterButtonState();
this.onFilterClick = (event) => {
event.stopPropagation();
params.api.showColumnFilter(params.column.getColId());
};
this.eFilterButton.addEventListener('click', this.onFilterClick);
this.eButtons.appendChild(this.eFilterButton);
}
this.eButtons.appendChild(this.eButton);
this.eGui.appendChild(this.eButtons);
// Allow clicking the label to sort, same as a default header.
this.onLabelClick = () => params.progressSort();
this.eLabel.addEventListener('click', this.onLabelClick);
}
updateButtonState() {
const pinned = isColumnPinned(this.params.column.getColId());
this.eButton.classList.toggle('is-pinned', pinned);
this.eButton.title = pinned ? 'Unpin column' : 'Pin column to left';
}
updateFilterButtonState() {
if (!this.eFilterButton) {
return;
}
const isActive = this.params.column.isFilterActive();
this.eFilterButton.classList.toggle('is-active', isActive);
this.eFilterButton.title = isActive ? 'Filter active — click to edit' : 'Filter this column';
}
getGui() {
return this.eGui;
}
refresh() {
this.updateButtonState();
this.updateFilterButtonState();
return true;
}
destroy() {
this.eButton.removeEventListener('click', this.onClick);
this.eLabel.removeEventListener('click', this.onLabelClick);
if (this.eFilterButton) {
this.eFilterButton.removeEventListener('click', this.onFilterClick);
}
}
}
// --- Excel-style "select values" filter --------------------------------
// AG Grid Community doesn't ship the Set Filter (that's Enterprise-only),
// so this is a hand-rolled equivalent. It needs a matching backend
// endpoint: same tableDataUrl, with distinctColumn / distinctSearch
// params, returning { values: [...] }. The grid-data endpoint also
// needs to treat a filterModel entry of { filterType: 'set', values }
// as an IN-list match rather than a text "contains" match.
class ExcelStyleFilter {
init(params) {
this.params = params;
this.selected = new Set();
this.values = [];
this.eGui = document.createElement('div');
this.eGui.className = 'excel-filter';
this.eGui.innerHTML = `
<div class="excel-filter__search">
<input type="search" placeholder="Search values" class="excel-filter__search-input">
</div>
<div class="excel-filter__actions">
<button type="button" data-action="select-all" class="excel-filter__link">Select all</button>
<button type="button" data-action="clear" class="excel-filter__link">Clear</button>
</div>
<div class="excel-filter__list"></div>
<div class="excel-filter__footer">
<button type="button" data-action="apply" class="excel-filter__apply">Apply</button>
</div>
`;
this.eList = this.eGui.querySelector('.excel-filter__list');
this.eSearch = this.eGui.querySelector('.excel-filter__search-input');
this.eSearch.addEventListener('input', () => {
window.clearTimeout(this._searchTimer);
this._searchTimer = window.setTimeout(() => this.loadValues(this.eSearch.value.trim()), 200);
});
this.eGui.querySelector('[data-action="select-all"]').addEventListener('click', () => {
this.values.forEach((value) => this.selected.add(value));
this.renderList();
});
this.eGui.querySelector('[data-action="clear"]').addEventListener('click', () => {
this.selected.clear();
this.renderList();
});
this.eGui.querySelector('[data-action="apply"]').addEventListener('click', () => {
this.params.filterChangedCallback();
});
this.loadValues('');
}
getGui() {
return this.eGui;
}
async loadValues(searchTerm) {
this.eList.innerHTML = '<div class="excel-filter__loading">Loading values…</div>';
try {
const url = new URL(window.APP_CONFIG.tableDataUrl, window.location.origin);
url.searchParams.set('distinctColumn', this.params.colDef.colId);
url.searchParams.set('distinctSearch', searchTerm ?? '');
const response = await fetch(url.toString(), { headers: { Accept: 'application/json' } });
const payload = await response.json();
this.values = Array.isArray(payload.values) ? payload.values : [];
this.renderList();
} catch (error) {
console.error(error);
this.eList.innerHTML = '<div class="excel-filter__loading">Could not load values</div>';
}
}
renderList() {
this.eList.innerHTML = this.values.map((value) => `
<label class="excel-filter__item">
<input type="checkbox" value="${escapeHtml(value)}" ${this.selected.has(value) ? 'checked' : ''}>
<span>${value === '' ? '(blank)' : escapeHtml(value)}</span>
</label>
`).join('');
this.eList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
if (event.target.checked) {
this.selected.add(event.target.value);
} else {
this.selected.delete(event.target.value);
}
});
});
}
isFilterActive() {
return this.selected.size > 0;
}
getModel() {
return this.isFilterActive()
? { filterType: 'set', values: Array.from(this.selected) }
: null;
}
setModel(model) {
this.selected = new Set(model?.values ?? []);
this.renderList();
}
doesFilterPass() {
return true;
}
}
// ======================================================================
// FEATURE TOGGLES — flip these to `false` to switch a feature off
// without deleting any of the code below.
// ======================================================================
const ENABLE_CURRENCY_FORMATTING = true; // controls Net Value USD formatting (2dp, brackets for negatives)
const ENABLE_FLAG_COLOURS = true; // controls green/red cell colours on *Flag columns
const columnDefs = [
// {
// colId: 'row_number',
// headerName: 'Row',
// field: 'row_number',
// width: 110,
// pinned: 'left',
// lockPinned: true,
// sortable: true,
// filter: 'agNumberColumnFilter',
// floatingFilter: false,
// resizable: true,
// sort: 'asc',
// suppressHeaderMenuButton: false,
// suppressMovable: true,
// },
...headerLabels.map((header) => {
const isCurrency = currencyHeaderPattern.test(header);
const isFlag = flagHeaderPattern.test(header);
const columnDef = {
colId: header,
headerName: header,
headerComponent: PinnableHeader,
valueGetter: (params) => (params.data ? (params.data[header] ?? '') : ''),
minWidth: isCurrency ? 140 : 160,
sortable: true,
resizable: true,
suppressHeaderMenuButton: false,
hide: header === 'x',
};
if (isCurrency) {
columnDef.filter = 'agNumberColumnFilter';
columnDef.floatingFilter = false;
// --- NET VALUE USD FORMATTING ---------------------------
// Set ENABLE_CURRENCY_FORMATTING (above) to false to show
// the raw numeric value instead of the 2dp/bracket format.
if (ENABLE_CURRENCY_FORMATTING) {
columnDef.valueFormatter = (params) => formatCurrency(params.value);
columnDef.cellClass = 'text-right font-mono tabular-nums';
}
// --- END NET VALUE USD FORMATTING -----------------------
} else {
columnDef.filter = ExcelStyleFilter;
columnDef.floatingFilter = false;
}
// --- FLAG COLOURS (Y/F green, N/D red) ----------------------
// Set ENABLE_FLAG_COLOURS (above) to false to disable the
// green/red cell backgrounds on columns ending in "Flag".
if (isFlag && ENABLE_FLAG_COLOURS) {
columnDef.cellClassRules = {
'cell-flag-positive': (params) => FLAG_POSITIVE.has(String(params.value).trim().toUpperCase()),
'cell-flag-negative': (params) => FLAG_NEGATIVE.has(String(params.value).trim().toUpperCase()),
};
}
// --- END FLAG COLOURS ----------------------------------------
return columnDef;
}),
];
const datasource = {
getRows: async (params) => {
const url = new URL(window.APP_CONFIG.tableDataUrl, window.location.origin);
url.searchParams.set('startRow', String(params.startRow ?? 0));
url.searchParams.set('endRow', String(params.endRow ?? ((params.startRow ?? 0) + 100)));
url.searchParams.set('search', currentSearch);
url.searchParams.set('sortModel', JSON.stringify(params.sortModel ?? []));
url.searchParams.set('filterModel', JSON.stringify(params.filterModel ?? {}));
try {
const response = await fetch(url.toString(), {
headers: {
Accept: 'application/json',
},
});
const payload = await response.json();
if (!response.ok || payload.error) {
throw new Error(payload.error || `Request failed (${response.status})`);
}
const rows = Array.isArray(payload.rows) ? payload.rows : [];
const lastRow = Number.isFinite(Number(payload.lastRow)) ? Number(payload.lastRow) : rows.length;
params.successCallback(rows, lastRow);
} catch (error) {
console.error(error);
params.failCallback();
}
},
};
const setDatasource = () => {
if (!gridApi) {
return;
}
if (typeof gridApi.setGridOption === 'function') {
gridApi.setGridOption('datasource', datasource);
return;
}
if (typeof gridApi.setDatasource === 'function') {
gridApi.setDatasource(datasource);
}
};
const refreshGrid = () => {
if (!gridApi) {
return;
}
setDatasource();
if (typeof gridApi.refreshInfiniteCache === 'function') {
gridApi.refreshInfiniteCache();
return;
}
if (typeof gridApi.purgeInfiniteCache === 'function') {
gridApi.purgeInfiniteCache();
}
};
const updateVisibilityMenu = () => {
if (!visibilityItems) {
return;
}
const items = columnDefs
.filter((column) => column.colId !== 'row_number')
.map((column) => `
<label class="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-50">
<input class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500" type="checkbox" data-col-id="${escapeHtml(column.colId)}" ${column.hide ? '' : 'checked'}>
<span class="min-w-0 flex-1 truncate">${escapeHtml(column.headerName ?? column.colId)}</span>
</label>
`);
visibilityItems.innerHTML = items.join('');
visibilityItems.querySelectorAll('input[type="checkbox"][data-col-id]').forEach((checkbox) => {
checkbox.addEventListener('change', function () {
if (!gridApi) {
return;
}
const colId = this.getAttribute('data-col-id');
gridApi.applyColumnState({
state: [
{
colId,
hide: !this.checked,
},
],
applyOrder: false,
});
});
});
};
const updateVisibilitySearch = () => {
if (!visibilitySearch || !visibilityItems) {
return;
}
const filter = visibilitySearch.value.trim().toLowerCase();
visibilityItems.querySelectorAll('label').forEach((item) => {
const text = item.textContent.trim().toLowerCase();
item.classList.toggle('hidden', filter !== '' && !text.includes(filter));
});
};
if (searchInput) {
searchInput.addEventListener('input', function () {
window.clearTimeout(searchTimer);
currentSearch = this.value.trim();
searchTimer = window.setTimeout(() => refreshGrid(), 180);
});
}
if (visibilityButton) {
visibilityButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleVisibilityMenu();
});
}
if (visibilityMenu) {
visibilityMenu.addEventListener('click', (event) => event.stopPropagation());
}
if (visibilitySearch) {
visibilitySearch.addEventListener('input', updateVisibilitySearch);
}
if (blurToggleButton) {
const BLUR_STORAGE_KEY = 'warner:tableBlurred';
const setBlurred = (blurred) => {
gridElement.classList.toggle('is-privacy-blurred', blurred);
blurToggleButton.setAttribute('aria-pressed', String(blurred));
blurToggleButton.classList.toggle('is-active', blurred);
const label = blurToggleButton.querySelector('[data-blur-label]');
if (label) {
label.textContent = blurred ? 'Unblur data' : 'Blur data';
}
try {
window.localStorage.setItem(BLUR_STORAGE_KEY, blurred ? '1' : '0');
} catch (error) {
// Ignore storage errors (e.g. private browsing).
}
};
let initiallyBlurred = false;
try {
initiallyBlurred = window.localStorage.getItem(BLUR_STORAGE_KEY) === '1';
} catch (error) {
initiallyBlurred = false;
}
setBlurred(initiallyBlurred);
blurToggleButton.addEventListener('click', () => {
setBlurred(!gridElement.classList.contains('is-privacy-blurred'));
});
}
document.addEventListener('click', (event) => {
if (visibilityMenu && visibilityButton && !visibilityMenu.contains(event.target) && !visibilityButton.contains(event.target)) {
closeVisibilityMenu();
}
});
const gridOptions = {
columnDefs,
defaultColDef: {
sortable: true,
resizable: true,
filter: true,
floatingFilter: false,
suppressHeaderMenuButton: false,
minWidth: 140,
},
rowModelType: 'infinite',
cacheBlockSize: 100,
maxBlocksInCache: 5,
infiniteInitialRowCount: 1,
pagination: true,
paginationPageSize: 100,
animateRows: false,
datasource,
getRowId: (params) => String(params.data?.row_number ?? ''),
onGridReady: (params) => {
gridApi = params.api;
setDatasource();
updateVisibilityMenu();
refreshGrid();
},
// Refreshes header icons (funnel active-state) whenever any filter
// changes, including from the custom Excel-style filter popup.
onFilterChanged: () => {
if (gridApi) {
gridApi.refreshHeader();
}
},
};
agGrid.createGrid(gridElement, gridOptions);
});
</script>
</body>
</html>