FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
163 lines
7.3 KiB
TypeScript
163 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useState } from "react";
|
|
import { Building2, Pause, Play, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
import { CompanyStatusBadge } from "@/components/ui/badge";
|
|
import { useCompanies, useDeleteCompany, useSetCompanyPaused } from "@/hooks/use-companies";
|
|
import { useRunAnyCompanyNow } from "@/hooks/use-monitoring-runs";
|
|
import { FREQUENCY_LABELS } from "@/lib/types";
|
|
import { formatDateTime } from "@/lib/format";
|
|
|
|
export default function CompaniesPage() {
|
|
const { data: companies, isLoading } = useCompanies();
|
|
const setPaused = useSetCompanyPaused();
|
|
const deleteCompany = useDeleteCompany();
|
|
const runNow = useRunAnyCompanyNow();
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
|
|
const companyList = companies ?? [];
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-slate-900">Companies</h1>
|
|
<p className="mt-1 text-sm text-slate-600">
|
|
Everything you're currently monitoring.
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href="/companies/new"
|
|
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden /> Add company
|
|
</Link>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<p className="mt-8 text-sm text-slate-500">Loading…</p>
|
|
) : companyList.length === 0 ? (
|
|
<div className="mt-8 rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center">
|
|
<Building2 className="mx-auto h-8 w-8 text-slate-400" aria-hidden />
|
|
<p className="mt-3 text-sm text-slate-600">No companies yet.</p>
|
|
<Link
|
|
href="/companies/new"
|
|
className="focus-ring mt-4 inline-block rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
|
|
>
|
|
Add your first company
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
|
<table className="w-full min-w-[720px] text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-4 py-3">Company</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
<th className="px-4 py-3">Frequency</th>
|
|
<th className="px-4 py-3">Last checked</th>
|
|
<th className="px-4 py-3">Next check</th>
|
|
<th className="px-4 py-3">Alerts</th>
|
|
<th className="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{companyList.map((company) => (
|
|
<tr key={company.id}>
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
href={`/companies/${company.id}`}
|
|
className="font-medium text-slate-900 hover:text-brand-600"
|
|
>
|
|
{company.name}
|
|
</Link>
|
|
{company.official_website && (
|
|
<div className="text-xs text-slate-500">{company.official_website}</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<CompanyStatusBadge status={company.status} />
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">
|
|
{company.monitor_configuration
|
|
? FREQUENCY_LABELS[company.monitor_configuration.frequency_type]
|
|
: "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">
|
|
{formatDateTime(company.monitor_configuration?.last_run)}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">
|
|
{formatDateTime(company.monitor_configuration?.next_run)}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">{company.unresolved_alert_count}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
title={
|
|
company.status === "active" ? "Pause monitoring" : "Resume monitoring"
|
|
}
|
|
onClick={() =>
|
|
setPaused.mutate({
|
|
companyId: company.id,
|
|
paused: company.status === "active",
|
|
})
|
|
}
|
|
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
|
|
>
|
|
{company.status === "active" ? (
|
|
<Pause className="h-4 w-4" aria-hidden />
|
|
) : (
|
|
<Play className="h-4 w-4" aria-hidden />
|
|
)}
|
|
<span className="sr-only">
|
|
{company.status === "active" ? "Pause" : "Resume"} {company.name}
|
|
</span>
|
|
</button>
|
|
<button
|
|
title="Run now"
|
|
onClick={() => runNow.mutate(company.id)}
|
|
disabled={runNow.isPending}
|
|
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100 disabled:opacity-60"
|
|
>
|
|
<RefreshCw className="h-4 w-4" aria-hidden />
|
|
<span className="sr-only">Run now for {company.name}</span>
|
|
</button>
|
|
{pendingDeleteId === company.id ? (
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<span className="text-slate-600">Delete?</span>
|
|
<button
|
|
onClick={() => deleteCompany.mutate(company.id)}
|
|
className="focus-ring rounded-md bg-red-600 px-2 py-1 font-semibold text-white hover:bg-red-700"
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
onClick={() => setPendingDeleteId(null)}
|
|
className="focus-ring rounded-md px-2 py-1 text-slate-600 hover:bg-slate-100"
|
|
>
|
|
No
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
title="Delete company"
|
|
onClick={() => setPendingDeleteId(company.id)}
|
|
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-red-50 hover:text-red-600"
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
<span className="sr-only">Delete {company.name}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|