feat: build local skills registry
This commit is contained in:
184
components/audits/audit-detail.tsx
Normal file
184
components/audits/audit-detail.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Globe } from "lucide-react";
|
||||
|
||||
type UsedSkill = {
|
||||
name: string;
|
||||
purpose?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type LeadContext = {
|
||||
_id: Id<"leads">;
|
||||
companyName?: string;
|
||||
websiteDomain?: string;
|
||||
websiteUrl?: string;
|
||||
city?: string;
|
||||
niche?: string;
|
||||
};
|
||||
|
||||
type SkillAwareAudit = {
|
||||
_id: Id<"audits">;
|
||||
slug: string;
|
||||
checkedDomain: string;
|
||||
status: "draft" | "approved" | "published" | "deactivated";
|
||||
checkedPages: string[];
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
usedSkills?: UsedSkill[];
|
||||
internalSummary?: string | null;
|
||||
};
|
||||
|
||||
type AuditDetailResult = {
|
||||
audit: SkillAwareAudit;
|
||||
lead: LeadContext | null;
|
||||
} | null;
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
function getStatusLabel(status: SkillAwareAudit["status"]) {
|
||||
return statusText[status] ?? "Unbekannt";
|
||||
}
|
||||
|
||||
function leadSummary(lead: LeadContext | null | undefined) {
|
||||
if (!lead) {
|
||||
return "Kein Lead-Kontext gespeichert";
|
||||
}
|
||||
|
||||
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
|
||||
let leadDomain = lead.websiteDomain ?? "—";
|
||||
|
||||
if (!leadDomain && lead.websiteUrl) {
|
||||
try {
|
||||
leadDomain = new URL(lead.websiteUrl).hostname;
|
||||
} catch {
|
||||
leadDomain = lead.websiteUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
|
||||
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{leadDomain}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
|
||||
const result = useQuery(api.audits.getDetail, {
|
||||
id: id as Id<"audits">,
|
||||
}) as AuditDetailResult | undefined;
|
||||
const audit = result?.audit;
|
||||
const lead = result?.lead;
|
||||
|
||||
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
|
||||
|
||||
if (result === null) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit nicht gefunden</CardTitle>
|
||||
<CardDescription>
|
||||
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (audit === undefined) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit wird geladen...</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Audit-Detail</CardDescription>
|
||||
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
|
||||
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
{audit.checkedDomain}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
|
||||
<p>{audit.checkedPages.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
|
||||
<div className="text-sm">{leadSummary(lead)}</div>
|
||||
</div>
|
||||
{audit.internalSummary ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Interne Notiz</p>
|
||||
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verwendete Skills</CardTitle>
|
||||
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usedSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
|
||||
) : (
|
||||
<ul className="grid gap-2">
|
||||
{usedSkills.map((skill, index) => (
|
||||
<li
|
||||
className="rounded-md border p-2 text-sm"
|
||||
key={`${skill.name}-${index}`}
|
||||
>
|
||||
<p className="font-medium">{skill.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.purpose ?? "Keine Zweckbeschreibung"}
|
||||
</p>
|
||||
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
|
||||
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
|
||||
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
|
||||
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
components/audits/audits-board.tsx
Normal file
134
components/audits/audits-board.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useQuery } from "convex/react";
|
||||
import { FunctionReturnType } from "convex/server";
|
||||
import { Files, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
|
||||
type AuditRow = NonNullable<AuditsListResult>[number];
|
||||
|
||||
const statusText: Record<string, string> = {
|
||||
draft: "Entwurf",
|
||||
approved: "Freigegeben",
|
||||
published: "Veröffentlicht",
|
||||
deactivated: "Deaktiviert",
|
||||
};
|
||||
|
||||
const fallbackStatus = "Unbekannt";
|
||||
|
||||
function formatPageCount(pages: AuditRow["checkedPages"]) {
|
||||
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
|
||||
}
|
||||
|
||||
function getStatusLabel(status: AuditRow["status"]) {
|
||||
return statusText[status] ?? fallbackStatus;
|
||||
}
|
||||
|
||||
function AuditsBoardLoading() {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
|
||||
</header>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid gap-2 p-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<Skeleton className="h-20 rounded-md" key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditsBoard() {
|
||||
const audits = useQuery(api.audits.list, { limit: 100 });
|
||||
const rows = useMemo(() => {
|
||||
if (!audits) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}, [audits]);
|
||||
|
||||
if (audits === undefined) {
|
||||
return <AuditsBoardLoading />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<article className="rounded-lg border p-4">
|
||||
<h2 className="text-sm font-medium">Noch keine Audits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
|
||||
Zeilen.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
|
||||
</header>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>Slug</span>
|
||||
<span>Domain</span>
|
||||
<span>Status</span>
|
||||
<span>Seitenanzahl</span>
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((audit: AuditRow) => (
|
||||
<article
|
||||
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
||||
key={audit._id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{audit.slug}</p>
|
||||
</div>
|
||||
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
|
||||
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Files className="size-3.5" />
|
||||
{formatPageCount(audit.checkedPages)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
|
||||
href={`/dashboard/audits/${audit._id}`}
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
Öffnen
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user