290 lines
17 KiB
PHP
290 lines
17 KiB
PHP
<?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>
|