mad-lawsuit/resources/js/Pages/Admin/DocumentAccess.vue
friday-bot 04bd71aaaa Add document access statistics to admin dashboard
Records every successful document download and surfaces the counts in the
admin area, so the docket owner can see which filings are being pulled and
how often.

- document_accesses table + DocumentAccess model
- DocumentController@download records each successful download with IP and
  user agent
- documents:import-access-log backfills history from an archived nginx log
- Dashboard gains a 60-day downloads card and a most-requested list
- New Document Access page with a date-range filter and per-day trend

Client IPs were only captured from 2026-07-30, when the reverse proxy chain
started forwarding X-Forwarded-For correctly. Rows imported from the old
nginx log therefore have no IP, and the UI labels those periods as download
counts rather than unique visitors instead of showing a misleading number.
2026-07-30 13:14:01 -06:00

205 lines
9.1 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 };
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)));
</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>
</div>
</div>
<!-- Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 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>
<!-- 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>