mad-lawsuit/resources/js/Pages/Admin/DocumentAccess.vue
friday-bot 041fb91182 Add unique visitor total and CSV export to document access
Surfaces distinct visitors for the selected range as a headline figure, and
lets the current view be exported for use in filings without retyping.

Unique visitors are counted across the range as a whole rather than summed
per document, so one person pulling five filings counts once. Where visitor
IPs were never recorded the CSV leaves the cell blank instead of writing 0,
which would otherwise read as "nobody downloaded this".
2026-07-30 13:49:10 -06:00

231 lines
10 KiB
Vue

<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
interface DocumentRow {
id: number;
title: string;
downloads: number;
unique_visitors: number;
last_accessed: string;
}
interface DailyRow {
day: string;
downloads: number;
}
interface Props {
documents: DocumentRow[];
daily: DailyRow[];
filters: { from: string; to: string; include_bots: boolean };
totals: { downloads: number; documents_touched: number; unique_visitors: number };
ip_data_starts: string;
range_predates_ip_data: boolean;
}
const props = defineProps<Props>();
const from = ref(props.filters.from);
const to = ref(props.filters.to);
const includeBots = ref(props.filters.include_bots);
const apply = () => {
router.get(
route('admin.document-access.index'),
{ from: from.value, to: to.value, include_bots: includeBots.value },
{ preserveState: true, preserveScroll: true },
);
};
const preset = (days: number) => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - days);
from.value = start.toISOString().slice(0, 10);
to.value = end.toISOString().slice(0, 10);
apply();
};
// Scale the inline bar chart to the busiest day in range.
const peak = computed(() => Math.max(1, ...props.daily.map((d) => d.downloads)));
// Keep the CSV in step with whatever range is currently on screen.
const exportUrl = computed(() => {
const params = new URLSearchParams({
from: from.value,
to: to.value,
include_bots: includeBots.value ? '1' : '0',
});
return `${route('admin.document-access.export')}?${params.toString()}`;
});
</script>
<template>
<div class="min-h-screen bg-gray-100">
<Head title="Document Access" />
<header class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">Document Access</h1>
<p class="text-sm text-gray-600 mt-1">
How often each document has been downloaded
</p>
</div>
<Link
:href="route('admin.dashboard')"
class="px-4 py-2 text-sm text-gray-700 hover:text-gray-900 transition-colors"
>
&larr; Back to Dashboard
</Link>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Filters -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">From</label>
<input
v-model="from"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">To</label>
<input
v-model="to"
type="date"
class="border-gray-300 rounded-lg shadow-sm text-sm"
/>
</div>
<button
@click="apply"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Apply
</button>
<div class="flex gap-2">
<button
v-for="p in [30, 60, 90]"
:key="p"
@click="preset(p)"
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
>
Last {{ p }}d
</button>
</div>
<label class="flex items-center gap-2 ml-auto text-sm text-gray-700">
<input
v-model="includeBots"
type="checkbox"
@change="apply"
class="rounded border-gray-300"
/>
Include crawlers
</label>
<a
:href="exportUrl"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
>
Export CSV
</a>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Total downloads</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.downloads }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Documents downloaded</p>
<p class="text-3xl font-bold text-gray-900 mt-2">{{ props.totals.documents_touched }}</p>
</div>
<div class="bg-white rounded-lg shadow p-6">
<p class="text-sm font-medium text-gray-600">Unique visitors</p>
<p class="text-3xl font-bold text-gray-900 mt-2">
{{ props.totals.unique_visitors === 0 ? '—' : props.totals.unique_visitors }}
</p>
<p v-if="props.range_predates_ip_data" class="text-xs text-gray-500 mt-1">
Only counted from {{ props.ip_data_starts }}
</p>
</div>
</div>
<!-- Caveat: unique visitors are not knowable before IP logging began -->
<div
v-if="props.range_predates_ip_data"
class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6"
>
<p class="text-sm text-amber-900">
<span class="font-semibold">Downloads, not unique visitors.</span>
Visitor IP addresses were only recorded from
{{ props.ip_data_starts }} onward. For any earlier period the same
person downloading a document ten times counts as ten downloads, and
the unique-visitor column will read 0.
</p>
</div>
<!-- Daily trend -->
<div v-if="props.daily.length > 0" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-xl font-bold text-gray-900 mb-4">Downloads per day</h2>
<div class="flex items-end gap-1 h-40 overflow-x-auto">
<div
v-for="d in props.daily"
:key="d.day"
class="flex-1 min-w-[8px] bg-blue-500 hover:bg-blue-600 rounded-t transition-colors"
:style="{ height: (d.downloads / peak) * 100 + '%' }"
:title="`${d.day}: ${d.downloads} download${d.downloads !== 1 ? 's' : ''}`"
/>
</div>
<div class="flex justify-between text-xs text-gray-500 mt-2">
<span>{{ props.daily[0]?.day }}</span>
<span>peak {{ peak }}/day</span>
<span>{{ props.daily[props.daily.length - 1]?.day }}</span>
</div>
</div>
<!-- Per-document table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">By document</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Document</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Downloads</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique visitors</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Last accessed</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="doc in props.documents" :key="doc.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-medium text-gray-900">{{ doc.title }}</td>
<td class="px-6 py-4 text-sm text-gray-900 text-right">{{ doc.downloads }}</td>
<td class="px-6 py-4 text-sm text-right" :class="doc.unique_visitors === 0 ? 'text-gray-400' : 'text-gray-900'">
{{ doc.unique_visitors === 0 ? '—' : doc.unique_visitors }}
</td>
<td class="px-6 py-4 text-sm text-gray-600 text-right">{{ doc.last_accessed }}</td>
</tr>
<tr v-if="props.documents.length === 0">
<td colspan="4" class="px-6 py-8 text-center text-gray-500">
No downloads recorded in this date range.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
</template>