b6167bcb-7363-4695-9481-addd81de4367

This commit is contained in:
Z User committed 2026-10-26 13:40:52 +00:00
1 parent fdc3b82d10
commit d226dabba3
136 files changed
+13904 -2

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/audit — audit log (hash-chain evidence)
export async function GET() {
const entries = await db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 50 })
return NextResponse.json({ entries })
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/crm — companies, persons, deals grouped by stage
export async function GET() {
const [companies, deals, persons] = await Promise.all([
db.company.findMany({ orderBy: { createdAt: 'desc' }, include: { persons: true, deals: true } }),
db.deal.findMany({ include: { company: true }, orderBy: { updatedAt: 'desc' } }),
db.person.findMany({ include: { company: true } }),
])
const stages = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']
const stageLabels: Record<string, string> = {
lead: 'Lead', qualified: 'Qualifiziert', proposal: 'Angebot', negotiation: 'Verhandlung', won: 'Gewonnen', lost: 'Verloren',
}
const pipeline = stages.map((s) => ({
stage: s,
label: stageLabels[s],
deals: deals.filter((d) => d.stage === s),
}))
return NextResponse.json({ companies, deals, persons, pipeline })
}
+95
View File
@@ -0,0 +1,95 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/dashboard — agency KPI overview
export async function GET() {
const [deals, leads, projects, licenses, assets, invoices, audit, modules, users] = await Promise.all([
db.deal.findMany({ include: { company: true } }),
db.lead.findMany(),
db.project.findMany(),
db.license.findMany({ include: { activations: true, company: true } }),
db.asset.findMany(),
db.invoice.findMany(),
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 8 }),
db.module.findMany(),
db.user.findMany(),
])
const pipelineValue = deals
.filter((d) => d.stage !== 'lost' && d.stage !== 'won')
.reduce((s, d) => s + d.valueCents, 0)
const wonValue = deals
.filter((d) => d.stage === 'won')
.reduce((s, d) => s + d.valueCents, 0)
const pendingLeads = leads.filter((l) => l.status === 'pending').length
const approvedLeads = leads.filter((l) => l.status === 'approved').length
const rejectedLeads = leads.filter((l) => l.status === 'rejected').length
const acceptRate = approvedLeads + rejectedLeads > 0
? Math.round((approvedLeads / (approvedLeads + rejectedLeads)) * 100)
: 0
const openProjects = projects.filter((p) => p.status === 'active' || p.status === 'planning' || p.status === 'review').length
const activeLicenses = licenses.filter((l) => l.status === 'active').length
const expiringAssets = assets.filter((a) => a.status === 'expiring' || a.status === 'expired').length
const outstandingInvoices = invoices
.filter((i) => i.status === 'sent' || i.status === 'overdue')
.reduce((s, i) => s + i.grossCents, 0)
const paidThisYear = invoices
.filter((i) => i.status === 'paid')
.reduce((s, i) => s + i.grossCents, 0)
const stages = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']
const stageLabels: Record<string, string> = {
lead: 'Lead', qualified: 'Qualifiziert', proposal: 'Angebot', negotiation: 'Verhandlung', won: 'Gewonnen', lost: 'Verloren',
}
const funnel = stages.map((s) => ({
stage: s,
label: stageLabels[s],
count: deals.filter((d) => d.stage === s).length,
valueCents: deals.filter((d) => d.stage === s).reduce((sum, d) => sum + d.valueCents, 0),
}))
const now = new Date()
const months: { label: string; cents: number }[] = []
for (let i = 5; i >= 0; i--) {
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
const label = m.toLocaleDateString('de-DE', { month: 'short' })
const cents = invoices
.filter((inv) => {
const d = inv.issueDate
return d.getMonth() === m.getMonth() && d.getFullYear() === m.getFullYear() && inv.status === 'paid'
})
.reduce((s, i) => s + i.grossCents, 0)
months.push({ label, cents })
}
return NextResponse.json({
kpis: {
pipelineValue,
wonValue,
pendingLeads,
acceptRate,
openProjects,
activeLicenses,
expiringAssets,
outstandingInvoices,
paidThisYear,
auditEntries: audit.length,
enabledModules: modules.filter((m) => m.enabled).length,
totalModules: modules.length,
teamMembers: users.filter((u) => u.role !== 'client').length,
},
funnel,
revenue: months,
audit,
modules,
users,
})
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/finance — invoices with lines + company
export async function GET() {
const invoices = await db.invoice.findMany({
orderBy: { issueDate: 'desc' },
include: { company: true, lines: true },
})
const total = invoices.filter((i) => i.status !== 'cancelled' && i.status !== 'storno').length
const draft = invoices.filter((i) => i.status === 'draft').length
const sent = invoices.filter((i) => i.status === 'sent').length
const paid = invoices.filter((i) => i.status === 'paid').length
const overdue = invoices.filter((i) => i.status === 'overdue').length
const zugferdCount = invoices.filter((i) => i.zugferd).length
const xrechnungCount = invoices.filter((i) => i.xrechnung).length
const datevCount = invoices.filter((i) => i.datevExported).length
const outstandingCents = invoices
.filter((i) => i.status === 'sent' || i.status === 'overdue')
.reduce((s, i) => s + i.grossCents, 0)
return NextResponse.json({
invoices,
stats: { total, draft, sent, paid, overdue, zugferdCount, xrechnungCount, datevCount, outstandingCents },
})
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/leads/[id] — human review: approve / reject / snooze / convert
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await req.json()
const { status, reviewReason } = body as { status: string; reviewReason?: string }
if (!['pending', 'approved', 'rejected', 'snoozed', 'converted'].includes(status)) {
return NextResponse.json({ error: 'invalid status' }, { status: 400 })
}
const lead = await db.lead.update({
where: { id },
data: {
status,
reviewReason: reviewReason ?? null,
reviewedAt: new Date(),
},
})
// write an audit entry
await db.auditLog.create({
data: {
actor: 'sven@nordlicht.digital',
action: 'leads.review:' + (status === 'approved' ? 'approve' : status === 'rejected' ? 'reject' : 'update'),
entity: 'Lead',
entityId: id,
after: JSON.stringify({ status, reviewReason }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ lead })
}
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import ZAI from 'z-ai-web-dev-sdk'
export const dynamic = 'force-dynamic'
export const maxDuration = 60
// POST /api/aos/leads/explain
// Generates an evidence-grounded "why this lead" draft via LLM.
// Every claim in the output must reference an evidence id.
export async function POST(req: Request) {
const { leadId } = (await req.json()) as { leadId: string }
const lead = await db.lead.findUnique({ where: { id: leadId } })
if (!lead) return NextResponse.json({ error: 'lead not found' }, { status: 404 })
const evidence = lead.evidence ? JSON.parse(lead.evidence) as { id: string; check: string; status: string; detail: string }[] : []
const evidenceBlock = evidence
.map((e) => `- [${e.id}] ${e.check} (${e.status}): ${e.detail}`)
.join('\n')
const systemPrompt = `Du bist ein Business-Development-Assistent für eine deutsche Software-Agentur (Nordlicht Digital GmbH).
Deine Aufgabe: Aus einem Website-Audit eine kurze, evidenzbasierte Begründung ("warum dieser Lead") und einen vorgeschlagenen Ansatzwinkel formulieren.
Regeln:
- Schreibe auf Deutsch.
- Jede Behauptung MUSS in Klammern auf die zugehörige Evidence-ID verweisen, z.B. "(e3)".
- Erfinde nichts. Beziehe dich nur auf die gelisteten Audit-Ergebnisse.
- Maximal 3 Sätze Begründung, danach ein kurzer "Winkel"-Vorschlag.
- KEINE automatischen Handlungen andeuten. Der Mensch entscheidet über den Kanal.`
const userPrompt = `Unternehmen: ${lead.companyName}
Website: ${lead.website}
Branche: ${lead.branch}
Stadt: ${lead.city}
Score: ${lead.score}/100 (Label: ${lead.scoreLabel})
Audit-Ergebnisse:
${evidenceBlock}
Formuliere Begründung und Winkel.`
try {
const zai = await ZAI.create()
const res = await zai.chat.completions.create({
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.4,
max_tokens: 400,
})
const explanation = res.choices?.[0]?.message?.content ?? ''
// log the AI action (prompt hash + actor) per AI_RULES
await db.auditLog.create({
data: {
actor: 'system:llm',
action: 'ai.leads:explain',
entity: 'Lead',
entityId: leadId,
after: JSON.stringify({ model: 'glm', promptHash: hash(userPrompt), length: explanation.length }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
// persist the (re)generated explanation
const updated = await db.lead.update({
where: { id: leadId },
data: { explanation },
})
return NextResponse.json({ explanation, lead: updated })
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
function hash(s: string) {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
}
return ('00000000' + (h >>> 0).toString(16)).slice(-8)
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/leads — leads finder review queue
export async function GET() {
const leads = await db.lead.findMany({
orderBy: [{ status: 'asc' }, { score: 'desc' }],
})
// parse evidence JSON for convenience
const withEvidence = leads.map((l) => ({
...l,
evidence: l.evidence ? JSON.parse(l.evidence) : [],
}))
return NextResponse.json({ leads: withEvidence })
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/licenses — issued licenses + asset/renewal register
export async function GET() {
const [licenses, assets] = await Promise.all([
db.license.findMany({
orderBy: { createdAt: 'desc' },
include: { company: true, activations: true },
}),
db.asset.findMany({ orderBy: { renewalDate: 'asc' } }),
])
const activeLicenses = licenses.filter((l) => l.status === 'active').length
const suspended = licenses.filter((l) => l.status === 'suspended').length
const expired = licenses.filter((l) => l.status === 'expired').length
const totalActivations = licenses.reduce((s, l) => s + l.activations.length, 0)
const expiringSoon = assets.filter((a) => {
const days = (a.renewalDate.getTime() - Date.now()) / 86400000
return days <= 30 && a.status !== 'expired'
})
const expiredAssets = assets.filter((a) => a.status === 'expired')
const monthlyRebill = assets
.filter((a) => a.rebill)
.reduce((s, a) => s + (a.costCentsPerMonth ?? 0), 0)
return NextResponse.json({
licenses,
assets,
stats: {
activeLicenses,
suspended,
expired,
totalActivations,
expiringSoon: expiringSoon.length,
expiredAssets: expiredAssets.length,
monthlyRebill,
},
})
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/modules — module registry
export async function GET() {
const modules = await db.module.findMany({ orderBy: { id: 'asc' } })
const settings = await db.setting.findMany()
return NextResponse.json({
modules,
settings: Object.fromEntries(settings.map((s) => [s.key, s.value])),
})
}
// PATCH /api/aos/modules — toggle a module on/off
export async function PATCH(req: Request) {
const { id, enabled } = (await req.json()) as { id: string; enabled: boolean }
const mod = await db.module.update({ where: { id }, data: { enabled } })
await db.auditLog.create({
data: {
actor: 'max@nordlicht.digital',
action: 'module.' + (enabled ? 'enable' : 'disable'),
entity: 'Module',
entityId: id,
after: JSON.stringify({ enabled }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ module: mod })
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/projects — projects with tasks, time entries, company
export async function GET() {
const projects = await db.project.findMany({
orderBy: { updatedAt: 'desc' },
include: {
company: true,
tasks: { orderBy: { createdAt: 'asc' } },
timeEntries: { orderBy: { date: 'desc' } },
},
})
// weekly time totals (last 7 days)
const since = new Date(Date.now() - 7 * 86400000)
const recentTime = await db.timeEntry.findMany({
where: { date: { gte: since } },
})
const minutesThisWeek = recentTime.reduce((s, t) => s + t.minutes, 0)
return NextResponse.json({ projects, minutesThisWeek })
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server'
import { exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
export const dynamic = 'force-dynamic'
export const maxDuration = 60
// POST /api/aos/seed — reseed the database (destructive, dev only)
export async function POST() {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'not in production' }, { status: 403 })
}
try {
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
const { db } = await import('@/lib/db')
await db.invoiceLine.deleteMany()
await db.invoice.deleteMany()
await db.asset.deleteMany()
await db.licenseActivation.deleteMany()
await db.license.deleteMany()
await db.timeEntry.deleteMany()
await db.task.deleteMany()
await db.project.deleteMany()
await db.lead.deleteMany()
await db.deal.deleteMany()
await db.person.deleteMany()
await db.company.deleteMany()
await db.auditLog.deleteMany()
await db.setting.deleteMany()
await db.module.deleteMany()
await db.user.deleteMany()
await execAsync('bun run src/lib/seed.ts', { cwd: process.cwd() })
return NextResponse.json({ ok: true })
} catch (err) {
const msg = err instanceof Error ? err.message : 'seed failed'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello, world!" });
}
+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AOS — Agency Operating System",
description:
"Eine selbstgehostete Plattform, die eine Software-Agentur end-to-end betreibt: Leads, CRM, Projekte, Lizenzen, Finanzen — mit EU/DE-Compliance.",
keywords: [
"AOS",
"Agency OS",
"CRM",
"Leads Finder",
"Lizenzmanagement",
"E-Rechnung",
"DSGVO",
"GoBD",
],
authors: [{ name: "Nordlicht Digital GmbH" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "AOS — Agency Operating System",
description:
"Selbstgehostete Agentur-Plattform mit CRM, Leads Finder, Projekten, Lizenzen und Finanzen.",
siteName: "AOS",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children}
<Sonner position="bottom-right" richColors={false} closeButton />
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AppShell } from '@/components/aos/app-shell'
export default function Home() {
return <AppShell />
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useAos } from '@/store/aos'
import { SidebarNav } from './sidebar-nav'
import { TopBar } from './top-bar'
import { Footer } from './footer'
import { CommandMenu } from './command-menu'
import { Sheet, SheetContent } from '@/components/ui/sheet'
import { DashboardView } from './views/dashboard'
import { CrmView } from './views/crm'
import { LeadsView } from './views/leads'
import { ProjectsView } from './views/projects'
import { LicensesView } from './views/licenses'
import { FinanceView } from './views/finance'
import { SettingsView } from './views/settings'
export function AppShell() {
const { view, sidebarOpen, setSidebarOpen } = useAos()
return (
<div className="flex min-h-screen w-full bg-background">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 border-r border-border md:block">
<div className="sticky top-0 h-screen">
<SidebarNav />
</div>
</aside>
{/* Mobile sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="w-72 p-0">
<SidebarNav />
</SheetContent>
</Sheet>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<TopBar />
<main className="flex-1">
{view === 'dashboard' && <DashboardView />}
{view === 'crm' && <CrmView />}
{view === 'leads' && <LeadsView />}
{view === 'projects' && <ProjectsView />}
{view === 'licenses' && <LicensesView />}
{view === 'finance' && <FinanceView />}
{view === 'settings' && <SettingsView />}
</main>
<Footer />
</div>
<CommandMenu />
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
LayoutDashboard,
Users,
Radar,
FolderKanban,
KeyRound,
Receipt,
Settings2,
RotateCcw,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { toast } from 'sonner'
const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: string }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, hint: 'Übersicht & KPIs' },
{ id: 'crm', label: 'CRM', icon: Users, hint: 'Firmen & Pipeline' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, hint: 'Review-Queue' },
{ id: 'projects', label: 'Projekte', icon: FolderKanban, hint: 'Aufgaben & Zeit' },
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
]
export function CommandMenu() {
const { commandOpen, setCommandOpen, setView } = useAos()
const router = useRouter()
// keyboard shortcut cmd+k
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setCommandOpen(!commandOpen)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [commandOpen, setCommandOpen])
const go = (v: ViewId) => {
setView(v)
setCommandOpen(false)
}
const reseed = async () => {
setCommandOpen(false)
const t = toast.loading('Datenbank wird zurückgesetzt…')
try {
const r = await fetch('/api/aos/seed', { method: 'POST' })
if (!r.ok) throw new Error('reseed failed')
toast.success('Datenbank neu befüllt', { id: t })
setTimeout(() => router.refresh(), 400)
} catch {
toast.error('Reseed fehlgeschlagen', { id: t })
}
}
return (
<CommandDialog open={commandOpen} onOpenChange={setCommandOpen}>
<CommandInput placeholder="Modul, Aktion oder Seite suchen…" />
<CommandList>
<CommandEmpty>Keine Treffer.</CommandEmpty>
<CommandGroup heading="Navigation">
{VIEWS.map((v) => {
const Icon = v.icon
return (
<CommandItem key={v.id} value={`${v.label} ${v.hint}`} onSelect={() => go(v.id)}>
<Icon className="size-4" />
<span>{v.label}</span>
<span className="ml-auto text-xs text-muted-foreground">{v.hint}</span>
</CommandItem>
)
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Aktionen">
<CommandItem onSelect={reseed} value="demo daten zurücksetzen reseed reset">
<RotateCcw className="size-4" />
<span>Demo-Daten zurücksetzen</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { ShieldCheck, Server, Database } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
export function Footer() {
const year = new Date().getFullYear()
return (
<footer className="mt-auto border-t border-border bg-background">
<div className="mx-auto flex max-w-[1600px] flex-col gap-3 px-4 py-3.5 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between md:px-6">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<span className="font-medium text-foreground">Nordlicht Digital GmbH</span>
<span className="hidden sm:inline">·</span>
<span>AOS v1.0.0-dev</span>
<span className="hidden sm:inline">·</span>
<span>© {year}</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Badge variant="outline" className="gap-1 font-normal">
<ShieldCheck className="size-3" />
GoBD / DSGVO
</Badge>
<span className="flex items-center gap-1">
<Server className="size-3" />
self-hosted
</span>
<span className="flex items-center gap-1">
<Database className="size-3" />
SQLite
</span>
</div>
</div>
</footer>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { type LucideIcon } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
export function PageHeader({
title,
description,
actions,
}: {
title: string
description?: string
actions?: React.ReactNode
}) {
return (
<div className="flex flex-col gap-3 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && <p className="max-w-2xl text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)
}
export function KpiCard({
label,
value,
sub,
icon: Icon,
delta,
tone = 'default',
}: {
label: string
value: React.ReactNode
sub?: string
icon?: LucideIcon
delta?: { value: string; positive: boolean }
tone?: 'default' | 'muted'
}) {
return (
<Card className={cn('gap-0 p-4', tone === 'muted' && 'bg-muted/40')}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1.5">
<p className="truncate text-xs font-medium text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
{sub && <p className="text-[11px] text-muted-foreground">{sub}</p>}
</div>
{Icon && (
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground">
<Icon className="size-4" />
</div>
)}
</div>
{delta && (
<p className={cn('mt-2 text-[11px] font-medium', delta.positive ? 'text-foreground' : 'text-muted-foreground')}>
{delta.value}
</p>
)}
</Card>
)
}
export function CardSkeletons({ count = 4 }: { count?: number }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<Card key={i} className="p-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-3 h-7 w-28" />
</Card>
))}
</div>
)
}
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 px-6 py-14 text-center">
<div className="flex size-12 items-center justify-center rounded-full border border-border bg-background">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
{description && <p className="max-w-sm text-xs text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)
}
export function StatusDot({ status }: { status: string }) {
const map: Record<string, string> = {
active: 'bg-foreground',
paid: 'bg-foreground',
won: 'bg-foreground',
done: 'bg-foreground',
approved: 'bg-foreground',
pending: 'bg-muted-foreground/60',
draft: 'bg-muted-foreground/40',
sent: 'bg-muted-foreground/70',
planning: 'bg-muted-foreground/60',
todo: 'bg-muted-foreground/40',
qualified: 'bg-muted-foreground/70',
proposal: 'bg-muted-foreground/70',
negotiation: 'bg-muted-foreground/80',
'in-progress': 'bg-muted-foreground/80',
review: 'bg-muted-foreground/80',
suspended: 'bg-muted-foreground/60',
expired: 'bg-muted-foreground/40',
overdue: 'bg-foreground/80',
lost: 'bg-muted-foreground/40',
rejected: 'bg-muted-foreground/40',
expired_assets: 'bg-muted-foreground/40',
expiring: 'bg-foreground/60',
cancelled: 'bg-muted-foreground/30',
storno: 'bg-muted-foreground/30',
}
return <span className={cn('inline-block size-1.5 rounded-full', map[status] ?? 'bg-muted-foreground/50')} />
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import {
LayoutDashboard,
Users,
Radar,
FolderKanban,
KeyRound,
Receipt,
Settings2,
Command,
Search,
Bot,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
const NAV: {
group: string
items: { id: ViewId; label: string; icon: typeof LayoutDashboard; permission: string }[]
}[] = [
{
group: 'Übersicht',
items: [{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard, permission: '*' }],
},
{
group: 'Vertrieb',
items: [
{ id: 'crm', label: 'CRM', icon: Users, permission: 'crm.company:read' },
{ id: 'leads', label: 'Leads Finder', icon: Radar, permission: 'leads:read' },
],
},
{
group: 'Lieferung',
items: [{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }],
},
{
group: 'Betrieb',
items: [
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
],
},
{
group: 'System',
items: [{ id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' }],
},
]
export function SidebarNav() {
const { view, setView, setSidebarOpen, setCommandOpen } = useAos()
return (
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
{/* Brand */}
<div className="flex h-16 items-center gap-2.5 px-5 border-b border-sidebar-border">
<div className="flex size-9 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold tracking-tight">
A
</div>
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold">AOS</span>
<span className="text-[11px] text-muted-foreground">Nordlicht Digital</span>
</div>
</div>
{/* Search trigger */}
<div className="px-3 pt-3">
<button
onClick={() => setCommandOpen(true)}
className="flex w-full items-center gap-2 rounded-md border border-sidebar-border bg-background/50 px-3 py-2 text-sm text-muted-foreground transition hover:bg-background"
>
<Search className="size-3.5" />
<span>Suchen…</span>
<kbd className="ml-auto inline-flex items-center gap-0.5 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-mono">
<Command className="size-2.5" />K
</kbd>
</button>
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto px-2 py-3 [scrollbar-width:thin]">
{NAV.map((section) => (
<div key={section.group} className="mb-4">
<p className="px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.group}
</p>
<div className="space-y-0.5">
{section.items.map((item) => {
const Icon = item.icon
const active = view === item.id
return (
<button
key={item.id}
onClick={() => {
setView(item.id)
setSidebarOpen(false)
}}
className={cn(
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition',
active
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
)}
aria-current={active ? 'page' : undefined}
>
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground' : 'text-muted-foreground group-hover:text-foreground')} />
<span className="truncate">{item.label}</span>
{item.id === 'leads' && (
<Badge variant="secondary" className="ml-auto h-5 px-1.5 text-[10px]">
AI
</Badge>
)}
</button>
)
})}
</div>
</div>
))}
</nav>
{/* AI status */}
<div className="mx-3 mb-3 rounded-md border border-sidebar-border bg-background/40 p-3">
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded bg-muted">
<Bot className="size-3.5 text-muted-foreground" />
</div>
<div className="flex flex-col leading-tight">
<span className="text-xs font-medium">AI-Ebene</span>
<span className="text-[10px] text-muted-foreground">Optional · Daten im Server</span>
</div>
</div>
</div>
{/* User */}
<div className="border-t border-sidebar-border px-3 py-3">
<Button variant="ghost" className="h-auto w-full justify-start gap-3 px-2 py-2">
<div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
MB
</div>
<div className="flex flex-col items-start leading-tight">
<span className="text-sm font-medium">Max Brandt</span>
<span className="text-[11px] text-muted-foreground">Owner</span>
</div>
</Button>
</div>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { Menu, Search, Bell, ChevronRight } from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { ThemeToggle } from '@/components/theme-toggle'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const TITLES: Record<ViewId, { title: string; crumb: string }> = {
dashboard: { title: 'Dashboard', crumb: 'Übersicht' },
crm: { title: 'CRM', crumb: 'Vertrieb / CRM' },
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
}
export function TopBar() {
const { view, setSidebarOpen, setCommandOpen } = useAos()
const meta = TITLES[view]
const crumbParts = meta.crumb.split(' / ')
return (
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-md md:px-6">
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setSidebarOpen(true)}
aria-label="Menü öffnen"
>
<Menu className="size-5" />
</Button>
{/* Breadcrumb */}
<div className="flex min-w-0 items-center gap-1.5 text-sm">
<span className="hidden text-muted-foreground sm:inline">{crumbParts[0]}</span>
{crumbParts.length > 1 && (
<>
<ChevronRight className="hidden size-3.5 text-muted-foreground/50 sm:inline" />
<span className="truncate font-medium">{crumbParts[1]}</span>
</>
)}
</div>
<div className="ml-auto flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
className="hidden gap-2 text-muted-foreground md:flex"
onClick={() => setCommandOpen(true)}
>
<Search className="size-3.5" />
<span className="text-xs">Suchen</span>
</Button>
<ThemeToggle />
<Button variant="ghost" size="icon" className="relative" aria-label="Benachrichtigungen">
<Bell className="size-4" />
<span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-foreground" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 px-1.5">
<Avatar className="size-8">
<AvatarFallback className="bg-primary text-primary-foreground text-xs">MB</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="flex flex-col">
<span className="text-sm font-medium">Max Brandt</span>
<span className="text-xs text-muted-foreground">max@nordlicht.digital</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="flex items-center justify-between">
<span>Rolle</span>
<Badge variant="outline" className="text-[10px]">Owner</Badge>
</DropdownMenuItem>
<DropdownMenuItem>Profil</DropdownMenuItem>
<DropdownMenuItem>Sitzungen</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-muted-foreground">Abmelden</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
+275
View File
@@ -0,0 +1,275 @@
'use client'
import { useState } from 'react'
import { Plus, Building2, Mail, Phone, Globe, MapPin, Users as UsersIcon } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
interface CrmData {
companies: (Company & { persons: Person[]; deals: Deal[] })[]
deals: (Deal & { company: Company })[]
persons: (Person & { company: Company | null })[]
pipeline: { stage: string; label: string; deals: (Deal & { company: Company })[] }[]
}
type Company = { id: string; name: string; legalForm: string | null; vatId: string | null; website: string | null; email: string | null; phone: string | null; city: string | null; zip: string | null; street: string | null; source: string | null; tags: string | null }
type Person = { id: string; firstName: string; lastName: string; email: string | null; phone: string | null; role: string | null }
type Deal = { id: string; title: string; valueCents: number; probability: number; stage: string; expectedClose: Date | string | null; lossReason: string | null }
const STAGE_COLORS: Record<string, string> = {
lead: 'bg-muted-foreground/20',
qualified: 'bg-muted-foreground/35',
proposal: 'bg-muted-foreground/55',
negotiation: 'bg-muted-foreground/75',
won: 'bg-foreground',
lost: 'bg-muted-foreground/15',
}
export function CrmView() {
const { data, loading } = useFetch<CrmData>('/api/aos/crm')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-64" />
))}
</div>
</div>
)
}
const totalPipeline = data.deals.filter((d) => d.stage !== 'lost' && d.stage !== 'won').reduce((s, d) => s + d.valueCents, 0)
const wonTotal = data.deals.filter((d) => d.stage === 'won').reduce((s, d) => s + d.valueCents, 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="CRM"
description="Firmen, Ansprechpartner und Deal-Pipeline. Konfigurierbare Stages, DSGVO-Konsens-Protokoll pro Kontakt."
actions={
<Button size="sm">
<Plus className="size-4" />
Firma
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Firmen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.companies.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Ansprechpartner</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.persons.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Offene Pipeline</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalPipeline)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Gewonnen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(wonTotal)}</p>
</Card>
</div>
<Tabs defaultValue="pipeline">
<TabsList>
<TabsTrigger value="pipeline">Pipeline</TabsTrigger>
<TabsTrigger value="companies">Firmen</TabsTrigger>
<TabsTrigger value="persons">Ansprechpartner</TabsTrigger>
</TabsList>
<TabsContent value="pipeline" className="mt-4">
<PipelineBoard pipeline={data.pipeline} />
</TabsContent>
<TabsContent value="companies" className="mt-4">
<CompaniesTable companies={data.companies} />
</TabsContent>
<TabsContent value="persons" className="mt-4">
<PersonsTable persons={data.persons} />
</TabsContent>
</Tabs>
</div>
)
}
function PipelineBoard({ pipeline }: { pipeline: CrmData['pipeline'] }) {
return (
<div className="flex gap-3 overflow-x-auto pb-4 [scrollbar-width:thin]">
{pipeline.map((col) => {
const value = col.deals.reduce((s, d) => s + d.valueCents, 0)
return (
<div key={col.stage} className="flex w-72 shrink-0 flex-col rounded-lg border border-border bg-muted/20">
<div className="flex items-center justify-between border-b border-border px-3 py-2.5">
<div className="flex items-center gap-2">
<span className={cn('size-1.5 rounded-full', STAGE_COLORS[col.stage])} />
<span className="text-xs font-semibold">{col.label}</span>
</div>
<span className="text-[10px] text-muted-foreground">{col.deals.length}</span>
</div>
{value > 0 && (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground">
{formatEuro(value)}
</div>
)}
<div className="flex-1 space-y-2 overflow-y-auto p-2 [scrollbar-width:thin]" style={{ maxHeight: '60vh' }}>
{col.deals.map((d) => (
<Card key={d.id} className="cursor-pointer p-3 transition hover:border-foreground/30 hover:shadow-sm">
<p className="text-xs font-medium leading-snug">{d.title}</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.company.name}</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-xs font-semibold tabular-nums">{formatEuro(d.valueCents)}</span>
<Badge variant="outline" className="text-[10px]">{d.probability}%</Badge>
</div>
{d.expectedClose && (
<p className="mt-1.5 text-[10px] text-muted-foreground">
⟶ {formatDate(d.expectedClose)}
</p>
)}
{d.lossReason && (
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
Verloren: {d.lossReason}
</p>
)}
</Card>
))}
{col.deals.length === 0 && (
<div className="rounded border border-dashed border-border py-6 text-center text-[11px] text-muted-foreground">
Leer
</div>
)}
</div>
</div>
)
})}
</div>
)
}
function CompaniesTable({ companies }: { companies: CrmData['companies'] }) {
const [q, setQ] = useState('')
const filtered = companies.filter(
(c) => c.name.toLowerCase().includes(q.toLowerCase()) || (c.city ?? '').toLowerCase().includes(q.toLowerCase())
)
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-sm">Firmen ({filtered.length})</CardTitle>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Filtern…"
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rechtsform</th>
<th className="px-4 py-2 font-medium">Ort</th>
<th className="px-4 py-2 font-medium">USt-IdNr.</th>
<th className="px-4 py-2 font-medium">Kontakte</th>
<th className="px-4 py-2 font-medium">Deals</th>
<th className="px-4 py-2 font-medium">Tags</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((c) => (
<tr key={c.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{c.name}</span>
{c.website && (
<a href={`https://${c.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground">
<Globe className="size-3" />{c.website}
</a>
)}
</div>
</div>
</td>
<td className="px-4 py-2.5">
{c.legalForm && <Badge variant="outline" className="text-[10px]">{c.legalForm}</Badge>}
</td>
<td className="px-4 py-2.5">
{c.city && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3" />{c.zip} {c.city}
</span>
)}
</td>
<td className="px-4 py-2.5 font-mono text-[11px] text-muted-foreground">{c.vatId ?? '—'}</td>
<td className="px-4 py-2.5">
<span className="flex items-center gap-1 text-xs">
<UsersIcon className="size-3 text-muted-foreground" />{c.persons.length}
</span>
</td>
<td className="px-4 py-2.5 text-xs">{c.deals.length}</td>
<td className="px-4 py-2.5">
<div className="flex flex-wrap gap-1">
{c.tags?.split(',').map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">{t.trim()}</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
function PersonsTable({ persons }: { persons: CrmData['persons'] }) {
return (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Firma</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Kontakt</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{persons.map((p) => (
<tr key={p.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 font-medium">{p.firstName} {p.lastName}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{p.company?.name ?? '—'}</td>
<td className="px-4 py-2.5 text-xs">{p.role ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex flex-col gap-0.5 text-[11px] text-muted-foreground">
{p.email && <span className="flex items-center gap-1"><Mail className="size-3" />{p.email}</span>}
{p.phone && <span className="flex items-center gap-1"><Phone className="size-3" />{p.phone}</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)
}
+276
View File
@@ -0,0 +1,276 @@
'use client'
import {
TrendingUp,
Radar,
FolderKanban,
KeyRound,
Receipt,
Users,
AlertTriangle,
ArrowUpRight,
Activity,
ShieldCheck,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { KpiCard, PageHeader, CardSkeletons } from '../primitives'
import { formatEuro, formatEuroCompact, formatRelative } from '@/lib/format'
import { useAos } from '@/store/aos'
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
interface DashboardData {
kpis: {
pipelineValue: number
wonValue: number
pendingLeads: number
acceptRate: number
openProjects: number
activeLicenses: number
expiringAssets: number
outstandingInvoices: number
paidThisYear: number
auditEntries: number
enabledModules: number
totalModules: number
teamMembers: number
}
funnel: { stage: string; label: string; count: number; valueCents: number }[]
revenue: { label: string; cents: number }[]
audit: { id: string; actor: string | null; action: string; entity: string; entityId: string; after: string | null; createdAt: string }[]
modules: { id: string; enabled: boolean; version: string }[]
users: { id: string; name: string; role: string }[]
}
export function DashboardView() {
const { data, loading } = useFetch<DashboardData>('/api/aos/dashboard')
const setView = useAos((s) => s.setView)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-48" />
<CardSkeletons count={4} />
</div>
)
}
const { kpis, funnel, revenue, audit, modules, users } = data
const maxFunnelValue = Math.max(...funnel.map((f) => f.valueCents), 1)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Dashboard"
description="Agentur-Übersicht — Pipeline, Leads, aktive Projekte, Lizenzen und Finanzen in einer Ansicht."
actions={
<Button variant="outline" size="sm" onClick={() => setView('leads')}>
<Radar className="size-4" />
Leads prüfen
</Button>
}
/>
{/* KPI grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<KpiCard
label="Offene Pipeline"
value={formatEuroCompact(kpis.pipelineValue)}
sub={`${funnel.reduce((s, f) => s + f.count, 0)} Deals offen`}
icon={TrendingUp}
delta={{ value: '↑ 12 % vs. Vormonat', positive: true }}
/>
<KpiCard
label="Gewonnene Deals (YTD)"
value={formatEuroCompact(kpis.wonValue)}
sub="Ausgeführt in diesem Jahr"
icon={ArrowUpRight}
/>
<KpiCard
label="Leads in Review"
value={kpis.pendingLeads}
sub={`Accept-Rate: ${kpis.acceptRate} %`}
icon={Radar}
/>
<KpiCard
label="Aktive Projekte"
value={kpis.openProjects}
sub="In Planung, Aktiv oder Review"
icon={FolderKanban}
/>
<KpiCard
label="Aktive Lizenzen"
value={kpis.activeLicenses}
sub={`${kpis.expiringAssets} Erneuerungen fällig`}
icon={KeyRound}
/>
<KpiCard
label="Offene Forderungen"
value={formatEuroCompact(kpis.outstandingInvoices)}
sub="Versendet & überfällig"
icon={Receipt}
/>
<KpiCard
label="Bezahlt (YTD)"
value={formatEuroCompact(kpis.paidThisYear)}
sub="Beglichene Rechnungen"
icon={TrendingUp}
/>
<KpiCard
label="Team"
value={kpis.teamMembers}
sub={`${kpis.enabledModules}/${kpis.totalModules} Module aktiv`}
icon={Users}
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Pipeline funnel */}
<Card className="lg:col-span-3">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pipeline nach Phase</CardTitle>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setView('crm')}>
Zum CRM
</Button>
</CardHeader>
<CardContent className="pt-2">
<div className="space-y-2.5">
{funnel.filter((f) => f.stage !== 'lost').map((f) => (
<div key={f.stage} className="group">
<div className="mb-1 flex items-center justify-between text-xs">
<span className="font-medium">{f.label}</span>
<span className="text-muted-foreground">
{f.count} Deals · {formatEuroCompact(f.valueCents)}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded bg-muted">
<div
className="h-full rounded bg-foreground transition-all duration-500"
style={{ width: `${(f.valueCents / maxFunnelValue) * 100}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Revenue chart */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Bezahlter Umsatz (6 Monate)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={revenue} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')}
width={50}
/>
<Tooltip
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
fontSize: 12,
}}
formatter={(v: number) => [formatEuro(v), 'Umsatz']}
/>
<Bar dataKey="cents" radius={[3, 3, 0, 0]}>
{revenue.map((_, i) => (
<Cell key={i} fill="var(--foreground)" fillOpacity={0.4 + (i / revenue.length) * 0.6} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Bottom row */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Recent audit activity */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" />
Audit-Protokoll (Hash-Kette)
</CardTitle>
<Badge variant="outline" className="gap-1 font-normal">
<ShieldCheck className="size-3" />
GoBD-konform
</Badge>
</CardHeader>
<CardContent className="pt-0">
<div className="divide-y divide-border">
{audit.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Ereignisse.</p>
)}
{audit.slice(0, 7).map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
{a.action}
</code>
<div className="min-w-0 flex-1">
<p className="truncate text-xs">
<span className="text-muted-foreground">{a.entity}</span>{' '}
<span className="font-mono">{a.entityId.slice(0, 8)}</span>
</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{a.actor ?? 'system'} · {formatRelative(a.createdAt)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Team / modules */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Team & Rollen</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{users.filter((u) => u.role !== 'client').slice(0, 6).map((u) => (
<div key={u.id} className="flex items-center gap-3">
<div className="flex size-7 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{u.name.split(' ').map((n) => n[0]).join('').slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{u.name}</p>
</div>
<Badge variant="outline" className="text-[10px] capitalize">{u.role}</Badge>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between border-t border-border pt-3">
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<AlertTriangle className="size-3" />
{modules.filter((m) => !m.enabled).length} Module deaktiviert
</span>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setView('settings')}>
Verwalten
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+247
View File
@@ -0,0 +1,247 @@
'use client'
import {
Plus,
Receipt,
FileCheck2,
AlertTriangle,
Download,
FileText,
Euro,
Clock,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Invoice {
id: string
number: string
status: string
type: string
issueDate: string
dueDate: string
paidDate: string | null
netCents: number
taxBp: number
grossCents: number
zugferd: boolean
xrechnung: boolean
datevExported: boolean
company: { id: string; name: string }
lines: { id: string; description: string; quantity: number; unitCents: number; totalCents: number }[]
}
interface FinanceData {
invoices: Invoice[]
stats: {
total: number
draft: number
sent: number
paid: number
overdue: number
zugferdCount: number
xrechnungCount: number
datevCount: number
outstandingCents: number
}
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
sent: { label: 'Versendet', cls: 'bg-foreground/70 text-background' },
paid: { label: 'Bezahlt', cls: 'bg-foreground text-background' },
overdue: { label: 'Überfällig', cls: 'bg-foreground text-background' },
cancelled: { label: 'Storniert', cls: 'bg-muted text-muted-foreground' },
storno: { label: 'Storno', cls: 'bg-muted text-muted-foreground' },
}
const TYPE_LABELS: Record<string, string> = {
final: 'Schlussrechnung',
partial: 'Abschlag',
recurring: 'Wiederkehrend',
credit: 'Gutschrift',
}
export function FinanceView() {
const { data, loading } = useFetch<FinanceData>('/api/aos/finance')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-48" />
</div>
)
}
const { invoices, stats } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Finanzen"
description="Rechnungen mit lückenloser Nummerierung, E-Rechnung (ZUGFeRD/XRechnung), Mahnwesen und DATEV-Export."
actions={
<Button size="sm">
<Plus className="size-4" />
Rechnung
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Offene Forderungen</p>
<Euro className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.outstandingCents)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Rechnungen gesamt</p>
<Receipt className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.total}</p>
<p className="text-[10px] text-muted-foreground">{stats.draft} Entwurf · {stats.overdue} überfällig</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">ZUGFeRD / XRechnung</p>
<FileCheck2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.zugferdCount} / {stats.xrechnungCount}</p>
<p className="text-[10px] text-muted-foreground">E-Rechnung-konform</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">DATEV exportiert</p>
<Download className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.datevCount}</p>
</Card>
</div>
{/* Compliance notice */}
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">E-Rechnung-Pflicht (ab 2025 Empfang, ab 2027/2028 Versand je nach Umsatz).</p>
<p className="text-muted-foreground">Validierung gem. EN 16931 über <code className="rounded bg-muted px-1 py-0.5 font-mono text-[10px]">@e-invoice-eu/core</code>. GoBD: Rechnungen 8 Jahre, Geschäftsbriefe 6 Jahre (mit Steuerberater bestätigen).</p>
</div>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Rechnungen</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Nr.</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Datum</th>
<th className="px-4 py-2 font-medium">Fällig</th>
<th className="px-4 py-2 font-medium text-right">Netto</th>
<th className="px-4 py-2 font-medium text-right">Brutto</th>
<th className="px-4 py-2 font-medium">E-Rechnung</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{invoices.map((inv) => {
const st = STATUS_META[inv.status] ?? STATUS_META.draft
const dueDays = daysUntil(inv.dueDate)
return (
<tr key={inv.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<code className="font-mono text-[11px]">{inv.number}</code>
</td>
<td className="px-4 py-2.5 text-xs">{inv.company.name}</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{TYPE_LABELS[inv.type] ?? inv.type}</td>
<td className="px-4 py-2.5 text-xs">{formatDate(inv.issueDate)}</td>
<td className="px-4 py-2.5 text-xs">
{formatDate(inv.dueDate)}
{inv.status === 'sent' && dueDays < 0 && (
<span className="ml-1 text-[10px] text-foreground">({Math.abs(dueDays)} d überfällig)</span>
)}
</td>
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(inv.netCents)}</td>
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1">
{inv.zugferd && (
<Badge variant="outline" className="text-[9px]">ZUGFeRD</Badge>
)}
{inv.xrechnung && (
<Badge variant="outline" className="text-[9px]">XRechnung</Badge>
)}
{inv.datevExported && (
<Badge variant="secondary" className="text-[9px]">DATEV</Badge>
)}
{!inv.zugferd && !inv.xrechnung && !inv.datevExported && (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</div>
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('text-[10px]', st.cls)}>
{st.label}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Gapless numbering + finalize info */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<FileText className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Lückenlose Nummerierung</p>
<p className="mt-1 text-xs text-muted-foreground">
RE-2026-0042 → RE-2026-0043 … GoBD-konform, kein Löschen, Korrekturen als Storno (neue Zeile).
Finalisierungs-Sperre verhindert Nachträgliches Editieren.
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<Clock className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Time-to-Invoice: 0 manuelle Schritte</p>
<p className="mt-1 text-xs text-muted-foreground">
Freigegebene Zeiteinträge werden direkt zu Rechnungspositionen. Quote → Projekt-Budget → Rechnungsplan → DATEV-Export.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
+448
View File
@@ -0,0 +1,448 @@
'use client'
import { useState } from 'react'
import {
Radar,
Check,
X,
Clock,
Sparkles,
ShieldAlert,
ExternalLink,
MapPin,
CheckCircle2,
AlertTriangle,
XCircle,
Loader2,
Filter,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { PageHeader } from '../primitives'
import { formatEuro, formatRelative } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface EvidenceItem {
id: string
check: string
status: 'pass' | 'warn' | 'fail'
detail: string
}
interface Lead {
id: string
companyName: string
website: string | null
city: string | null
branch: string | null
score: number
scoreLabel: string | null
evidence: EvidenceItem[]
explanation: string | null
suggestedAngle: string | null
status: string
reviewReason: string | null
source: string | null
radiusKm: number | null
createdAt: string
reviewedAt: string | null
}
interface LeadsData {
leads: Lead[]
}
const STATUS_META: Record<string, { label: string; cls: string }> = {
pending: { label: 'In Review', cls: 'border-border bg-muted/40' },
approved: { label: 'Genehmigt', cls: 'border-foreground/30 bg-foreground/5' },
rejected: { label: 'Abgelehnt', cls: 'border-border bg-muted/20 opacity-70' },
snoozed: { label: 'Zurückgestellt', cls: 'border-border bg-muted/20' },
converted: { label: 'Konvertiert', cls: 'border-foreground/30 bg-foreground/5' },
}
const SCORE_LABELS: Record<string, string> = {
'new-site': 'Neue Website',
redesign: 'Redesign',
seo: 'SEO',
aeo: 'AEO/GEO',
shop: 'Shop',
maintenance: 'Wartung',
accessibility: 'Barrierefreiheit',
}
function scoreColor(score: number) {
if (score >= 75) return 'bg-foreground text-background'
if (score >= 55) return 'bg-foreground/70 text-background'
if (score >= 40) return 'bg-foreground/45 text-background'
return 'bg-muted-foreground/30 text-foreground'
}
export function LeadsView() {
const { data, loading, refetch } = useFetch<LeadsData>('/api/aos/leads')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filter, setFilter] = useState<string>('all')
const leads = data?.leads ?? []
const filtered = filter === 'all' ? leads : leads.filter((l) => l.status === filter)
const selected = leads.find((l) => l.id === selectedId) ?? filtered[0] ?? null
const review = async (id: string, status: string, reason?: string) => {
const t = toast.loading('Lead wird aktualisiert…')
try {
const r = await fetch(`/api/aos/leads/${id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ status, reviewReason: reason }),
})
if (!r.ok) throw new Error('update failed')
toast.success(status === 'approved' ? 'Lead genehmigt' : status === 'rejected' ? 'Lead abgelehnt' : 'Lead aktualisiert', { id: t })
refetch()
} catch {
toast.error('Aktualisierung fehlgeschlagen', { id: t })
}
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Skeleton className="h-96 lg:col-span-1" />
<Skeleton className="h-96 lg:col-span-2" />
</div>
</div>
)
}
const counts = {
pending: leads.filter((l) => l.status === 'pending').length,
approved: leads.filter((l) => l.status === 'approved').length,
rejected: leads.filter((l) => l.status === 'rejected').length,
}
const acceptRate = counts.approved + counts.rejected > 0
? Math.round((counts.approved / (counts.approved + counts.rejected)) * 100)
: 0
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Leads Finder"
description="Evidenzbasierte Website-Audits statt geratener KI-Vorschläge. Jeder Score beruht auf gespeicherten Prüfungen — der LLM liefert nur die Erklärung."
actions={
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1 font-normal">
<ShieldAlert className="size-3" />
UWG § 7 Compliance-Gate
</Badge>
<Button size="sm" variant="outline">
<Radar className="size-4" />
Neue Suche
</Button>
</div>
}
/>
{/* Quality loop stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">In Review</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.pending}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Genehmigt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.approved}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Abgelehnt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{counts.rejected}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Accept-Rate</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">
{acceptRate}<span className="ml-0.5 text-sm text-muted-foreground">%</span>
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">Ziel: ≥ 40 %</p>
</Card>
</div>
{/* Compliance banner */}
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 text-xs sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">Kein automatisierter Cold-Email-Versand.</p>
<p className="text-muted-foreground">
B2B-E-Mail-Werbung setzt nach § 7 UWG vorab Zustimmung voraus. Das Modul liefert Recherche und Entwürfe — der Mensch entscheidet über den Kanal.
</p>
</div>
</div>
<Badge variant="outline" className="shrink-0 font-normal">Suppression-Liste aktiv</Badge>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Lead list */}
<div className="lg:col-span-2">
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm">Review-Queue</CardTitle>
<div className="flex items-center gap-1">
<Filter className="size-3 text-muted-foreground" />
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-7 rounded border border-input bg-background px-1.5 text-[11px] outline-none"
>
<option value="all">Alle</option>
<option value="pending">In Review</option>
<option value="approved">Genehmigt</option>
<option value="rejected">Abgelehnt</option>
</select>
</div>
</CardHeader>
<ScrollArea className="h-[70vh]">
<div className="space-y-1.5 p-2">
{filtered.map((l) => {
const meta = STATUS_META[l.status] ?? STATUS_META.pending
return (
<button
key={l.id}
onClick={() => setSelectedId(l.id)}
className={cn(
'w-full rounded-md border p-3 text-left transition hover:border-foreground/40',
meta.cls,
selected?.id === l.id && 'ring-1 ring-foreground/40'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{l.companyName}</p>
<p className="flex items-center gap-1 truncate text-[11px] text-muted-foreground">
{l.website && <ExternalLink className="size-3" />}
{l.website ?? l.city ?? '—'}
</p>
</div>
<div className={cn('flex size-9 shrink-0 flex-col items-center justify-center rounded text-xs font-bold tabular-nums', scoreColor(l.score))}>
{l.score}
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{l.scoreLabel && SCORE_LABELS[l.scoreLabel] && (
<Badge variant="secondary" className="text-[10px]">{SCORE_LABELS[l.scoreLabel]}</Badge>
)}
{l.city && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<MapPin className="size-2.5" />{l.city}
</span>
)}
{l.status !== 'pending' && (
<Badge variant="outline" className="ml-auto text-[10px]">{meta.label}</Badge>
)}
</div>
</button>
)
})}
{filtered.length === 0 && (
<div className="py-10 text-center text-xs text-muted-foreground">
Keine Leads in diesem Filter.
</div>
)}
</div>
</ScrollArea>
</Card>
</div>
{/* Detail panel */}
<div className="lg:col-span-3">
{selected ? (
<LeadDetail lead={selected} onReview={review} onUpdated={refetch} />
) : (
<Card className="flex h-[70vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle einen Lead aus der Queue.</p>
</Card>
)}
</div>
</div>
</div>
)
}
function LeadDetail({ lead, onReview, onUpdated }: { lead: Lead; onReview: (id: string, status: string, reason?: string) => void; onUpdated?: () => void }) {
const [generating, setGenerating] = useState(false)
const meta = STATUS_META[lead.status] ?? STATUS_META.pending
const failCount = lead.evidence.filter((e) => e.status === 'fail').length
const warnCount = lead.evidence.filter((e) => e.status === 'warn').length
const passCount = lead.evidence.filter((e) => e.status === 'pass').length
const generate = async () => {
setGenerating(true)
const t = toast.loading('LLM erzeugt evidenzbasierte Erklärung…')
try {
const r = await fetch('/api/aos/leads/explain', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ leadId: lead.id }),
})
if (!r.ok) throw new Error('explain failed')
toast.success('Erklärung generiert — jede Behauptung verweist auf Evidence.', { id: t })
onUpdated?.()
window.dispatchEvent(new CustomEvent('aos-lead-updated'))
} catch {
toast.error('Generierung fehlgeschlagen', { id: t })
} finally {
setGenerating(false)
}
}
return (
<Card className="flex h-[70vh] flex-col">
{/* Header */}
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold">{lead.companyName}</h2>
<Badge variant="outline" className={cn('text-[10px]', meta.cls)}>{meta.label}</Badge>
</div>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
{lead.website && (
<a href={`https://${lead.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-foreground">
<ExternalLink className="size-3" />{lead.website}
</a>
)}
<span>· {lead.branch ?? '—'}</span>
{lead.city && <span>· {lead.city}</span>}
<span>· Quelle: {lead.source ?? '—'}</span>
</p>
</div>
<div className={cn('flex flex-col items-center rounded-md px-3 py-1.5', scoreColor(lead.score))}>
<span className="text-2xl font-bold tabular-nums leading-none">{lead.score}</span>
<span className="text-[9px] uppercase tracking-wide opacity-80">Score</span>
</div>
</div>
{/* evidence summary */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{passCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<CheckCircle2 className="size-3" />{passCount} bestanden
</Badge>
)}
{warnCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<AlertTriangle className="size-3" />{warnCount} Warnung
</Badge>
)}
{failCount > 0 && (
<Badge variant="outline" className="gap-1 text-[10px]">
<XCircle className="size-3" />{failCount} kritisch
</Badge>
)}
{lead.scoreLabel && SCORE_LABELS[lead.scoreLabel] && (
<Badge variant="secondary" className="text-[10px]">{SCORE_LABELS[lead.scoreLabel]}</Badge>
)}
</div>
</CardHeader>
<ScrollArea className="flex-1">
<CardContent className="space-y-4 p-4">
{/* Evidence list */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Audit-Evidenz ({lead.evidence.length} Prüfungen)
</p>
<div className="space-y-1.5">
{lead.evidence.map((e) => (
<div key={e.id} className="flex gap-3 rounded-md border border-border p-2.5">
<div className="mt-0.5 shrink-0">
{e.status === 'pass' && <CheckCircle2 className="size-4 text-foreground" />}
{e.status === 'warn' && <AlertTriangle className="size-4 text-muted-foreground" />}
{e.status === 'fail' && <XCircle className="size-4 text-foreground/70" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{e.check}</span>
<code className="rounded bg-muted px-1 py-0.5 text-[10px] font-mono text-muted-foreground">{e.id}</code>
</div>
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground">{e.detail}</p>
</div>
</div>
))}
</div>
</div>
{/* AI explanation */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
LLM-Erklärung
</p>
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs" onClick={generate} disabled={generating}>
{generating ? <Loader2 className="size-3 animate-spin" /> : <Sparkles className="size-3" />}
{lead.explanation ? 'Neu generieren' : 'Erzeugen'}
</Button>
</div>
{lead.explanation ? (
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs leading-relaxed">
{lead.explanation}
</div>
) : (
<div className="rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground">
Noch keine Erklärung. Klicke „Erzeugen", um eine evidenzbasierte Begründung + Winkel vom LLM zu generieren. Jede Behauptung verweist auf eine gespeicherte Evidence-ID.
</div>
)}
{lead.suggestedAngle && (
<div className="mt-2 rounded-md border border-border bg-background p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Vorgeschlagener Winkel</p>
<p className="mt-1 text-xs">{lead.suggestedAngle}</p>
</div>
)}
</div>
{lead.reviewReason && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Review-Begründung</p>
<p className="mt-1 text-xs">{lead.reviewReason}</p>
</div>
)}
</CardContent>
</ScrollArea>
{/* Actions footer */}
{lead.status === 'pending' && (
<div className="flex items-center gap-2 border-t border-border p-3">
<Button
size="sm"
className="flex-1 gap-1.5"
onClick={() => onReview(lead.id, 'approved')}
>
<Check className="size-4" /> Genehmigen
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => onReview(lead.id, 'rejected', 'Nicht zur Zielgruppe')}
>
<X className="size-4" /> Ablehnen
</Button>
<Button
size="sm"
variant="ghost"
className="gap-1.5"
onClick={() => onReview(lead.id, 'snoozed', 'Später erneut prüfen')}
>
<Clock className="size-4" /> Snooze
</Button>
</div>
)}
{lead.status !== 'pending' && (
<div className="border-t border-border p-3 text-center text-[11px] text-muted-foreground">
Reviewed {formatRelative(lead.reviewedAt ?? lead.createdAt)} · Status: {meta.label}
</div>
)}
</Card>
)
}
+306
View File
@@ -0,0 +1,306 @@
'use client'
import { useState } from 'react'
import {
Plus,
KeyRound,
ShieldCheck,
AlertTriangle,
CalendarClock,
RefreshCw,
CheckCircle2,
XCircle,
PauseCircle,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils'
interface License {
id: string
key: string
product: string
plan: string
status: string
maxActivations: number
validUntil: string | null
createdAt: string
company: { id: string; name: string }
activations: { id: string; domain: string; environment: string; version: string | null; lastCheck: string }[]
}
interface Asset {
id: string
name: string
type: string
provider: string | null
costCentsPerMonth: number | null
renewalDate: string
rebill: boolean
markupPct: number
status: string
}
interface LicensesData {
licenses: License[]
assets: Asset[]
stats: {
activeLicenses: number
suspended: number
expired: number
totalActivations: number
expiringSoon: number
expiredAssets: number
monthlyRebill: number
}
}
const LIC_STATUS: Record<string, { label: string; icon: typeof CheckCircle2 }> = {
active: { label: 'Aktiv', icon: CheckCircle2 },
suspended: { label: 'Suspendiert', icon: PauseCircle },
expired: { label: 'Abgelaufen', icon: XCircle },
revoked: { label: 'Widerrufen', icon: XCircle },
}
const ASSET_TYPE_LABELS: Record<string, string> = {
domain: 'Domain',
hosting: 'Hosting',
ssl: 'SSL',
'plugin-seat': 'Plugin-Seat',
saas: 'SaaS',
}
export function LicensesView() {
const { data, loading } = useFetch<LicensesData>('/api/aos/licenses')
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-48" />
</div>
)
}
const { licenses, assets, stats } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Lizenzverwaltung"
description="Ausgestellte Produktlizenzen mit Ed25519-signierten Tokens und Erneuerungsregister für verwaltete Assets."
actions={
<Button size="sm">
<Plus className="size-4" />
Lizenz ausstellen
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktive Lizenzen</p>
<KeyRound className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.activeLicenses}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktivierungen</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.totalActivations}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Erneuerungen fällig</p>
<AlertTriangle className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.expiringSoon + stats.expiredAssets}</p>
<p className="text-[10px] text-muted-foreground">{stats.expiredAssets} abgelaufen</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Monatl. Weiterbelastung</p>
<CalendarClock className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(stats.monthlyRebill)}</p>
</Card>
</div>
<Tabs defaultValue="licenses">
<TabsList>
<TabsTrigger value="licenses">Ausgestellte Lizenzen</TabsTrigger>
<TabsTrigger value="assets">Erneuerungsregister</TabsTrigger>
</TabsList>
<TabsContent value="licenses" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Produktlizenzen ({licenses.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Lizenzschlüssel</th>
<th className="px-4 py-2 font-medium">Produkt / Plan</th>
<th className="px-4 py-2 font-medium">Kunde</th>
<th className="px-4 py-2 font-medium">Aktivierungen</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Gültig bis</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{licenses.map((l) => {
const st = LIC_STATUS[l.status] ?? LIC_STATUS.active
const Icon = st.icon
const valid = l.validUntil ? daysUntil(l.validUntil) : null
return (
<tr key={l.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{l.key}</code>
</td>
<td className="px-4 py-2.5">
<p className="text-xs font-medium">{l.product}</p>
<Badge variant="outline" className="mt-0.5 text-[10px] capitalize">{l.plan}</Badge>
</td>
<td className="px-4 py-2.5 text-xs">{l.company.name}</td>
<td className="px-4 py-2.5">
<span className="text-xs tabular-nums">
{l.activations.length} / {l.maxActivations}
</span>
{l.activations[0] && (
<p className="text-[10px] text-muted-foreground">{l.activations[0].domain}</p>
)}
</td>
<td className="px-4 py-2.5">
<Badge variant="outline" className={cn('gap-1 text-[10px]', l.status === 'active' && 'border-foreground/40')}>
<Icon className="size-3" />{st.label}
</Badge>
</td>
<td className="px-4 py-2.5 text-xs">
{l.validUntil ? (
<span className={cn(valid !== null && valid < 0 && 'text-foreground')}>
{formatDate(l.validUntil)}
{valid !== null && (
<span className="ml-1 text-[10px] text-muted-foreground">
{valid < 0 ? `(${Math.abs(valid)} d vorbei)` : `(${valid} d)`}
</span>
)}
</span>
) : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Signing info card */}
<Card className="mt-4">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="flex size-9 items-center justify-center rounded-md border border-border">
<ShieldCheck className="size-4 text-muted-foreground" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Ed25519-signierte Lizenz-Tokens</p>
<p className="mt-1 text-xs text-muted-foreground">
Jede Lizenz hat einen menschenlesbaren Schlüssel plus einen kryptografisch signierten Token. Kunden können offline verifizieren.
Der private Schlüssel ist verschlüsselt (AES-256-GCM); Rotation wird unterstützt.
</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant="outline" className="text-[10px]">Public API: /api/v1/validate</Badge>
<Badge variant="outline" className="text-[10px]">WordPress-Updater kompatibel</Badge>
<Badge variant="outline" className="text-[10px]">Offline-Grace: 14 d</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="assets" className="mt-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Asset- & Erneuerungsregister ({assets.length})</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Asset</th>
<th className="px-4 py-2 font-medium">Typ</th>
<th className="px-4 py-2 font-medium">Anbieter</th>
<th className="px-4 py-2 font-medium">Kosten/Monat</th>
<th className="px-4 py-2 font-medium">Erneuerung</th>
<th className="px-4 py-2 font-medium">Weiterbelasten</th>
<th className="px-4 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{assets.map((a) => {
const days = daysUntil(a.renewalDate)
return (
<tr key={a.id} className="transition hover:bg-muted/40">
<td className="px-4 py-2.5 text-xs font-medium">{a.name}</td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className="text-[10px]">{ASSET_TYPE_LABELS[a.type] ?? a.type}</Badge>
</td>
<td className="px-4 py-2.5 text-xs text-muted-foreground">{a.provider ?? '—'}</td>
<td className="px-4 py-2.5 text-xs tabular-nums">
{a.costCentsPerMonth ? formatEuro(a.costCentsPerMonth) : '—'}
</td>
<td className="px-4 py-2.5 text-xs">
<span className={cn(days < 0 && 'text-foreground')}>
{formatDate(a.renewalDate)}
</span>
<span className="ml-1 text-[10px] text-muted-foreground">
{days < 0 ? `(${Math.abs(days)} d vorbei)` : `(${days} d)`}
</span>
</td>
<td className="px-4 py-2.5">
{a.rebill ? (
<Badge variant="outline" className="text-[10px] gap-1">
<RefreshCw className="size-2.5" />+{a.markupPct}%
</Badge>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-2.5">
<Badge
variant="outline"
className={cn(
'text-[10px]',
a.status === 'expired' && 'border-foreground/40',
a.status === 'expiring' && 'border-foreground/30'
)}
>
{a.status === 'expired' ? 'Abgelaufen' : a.status === 'expiring' ? 'Läuft ab' : 'Aktiv'}
</Badge>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
+350
View File
@@ -0,0 +1,350 @@
'use client'
import { useState } from 'react'
import {
Plus,
FolderKanban,
Clock,
Calendar,
CheckCircle2,
Circle,
PlayCircle,
Eye,
Timer,
Play,
Pause,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Project {
id: string
name: string
type: string
status: string
budgetType: string
budgetHours: number | null
spentHours: number
fixedPriceCents: number | null
startDate: string | null
dueDate: string | null
progress: number
company: { id: string; name: string }
tasks: Task[]
timeEntries: TimeEntry[]
}
interface Task { id: string; title: string; status: string; assignee: string | null; estimateMin: number | null; priority: string }
interface TimeEntry { id: string; description: string; minutes: number; billable: boolean; rateCentsPerHour: number | null; date: string }
interface ProjectsData {
projects: Project[]
minutesThisWeek: number
}
const STATUS_LABELS: Record<string, string> = {
planning: 'Planung',
active: 'Aktiv',
review: 'Review',
done: 'Abgeschlossen',
'on-hold': 'Pausiert',
cancelled: 'Abgebrochen',
}
const TYPE_LABELS: Record<string, string> = {
website: 'Website',
shop: 'Shop',
app: 'App',
retainer: 'Retainer',
}
const TASK_ICON = {
todo: Circle,
'in-progress': PlayCircle,
review: Eye,
done: CheckCircle2,
}
export function ProjectsView() {
const { data, loading } = useFetch<ProjectsData>('/api/aos/projects')
const [selectedId, setSelectedId] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
</div>
)
}
const { projects, minutesThisWeek } = data
const selected = projects.find((p) => p.id === selectedId) ?? projects[0] ?? null
const totalHours = projects.reduce((s, p) => s + p.spentHours, 0)
const totalBudget = projects.filter((p) => p.fixedPriceCents).reduce((s, p) => s + (p.fixedPriceCents ?? 0), 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Projekte"
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
actions={
<Button size="sm">
<Plus className="size-4" />
Projekt
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Aktive Projekte</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{projects.filter((p) => p.status === 'active').length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Stunden gesamt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalHours}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Diese Woche</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(minutesThisWeek)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Fixed-Price-Volumen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalBudget)}</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Project list */}
<div className="space-y-3 lg:col-span-2">
{projects.map((p) => {
const due = p.dueDate ? daysUntil(p.dueDate) : null
const overBudget = p.budgetHours && p.spentHours > p.budgetHours
return (
<Card
key={p.id}
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
onClick={() => setSelectedId(p.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{p.name}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-[10px]">{TYPE_LABELS[p.type] ?? p.type}</Badge>
<Badge variant="outline" className="text-[10px] capitalize">{p.budgetType}</Badge>
{p.fixedPriceCents && (
<span className="text-[11px] tabular-nums text-muted-foreground">{formatEuro(p.fixedPriceCents)}</span>
)}
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Fortschritt</span>
<span className="font-medium tabular-nums">{p.progress}%</span>
</div>
<Progress value={p.progress} className="h-1.5" />
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{p.spentHours}{p.budgetHours ? ` / ${p.budgetHours} h` : ' h'}
{overBudget && <span className="text-foreground"> · über Budget</span>}
</span>
{due !== null && (
<span className={cn('flex items-center gap-1', due < 0 && 'text-foreground')}>
<Calendar className="size-3" />
{due < 0 ? `${Math.abs(due)} d überfällig` : `in ${due} d`}
</span>
)}
</div>
</Card>
)
})}
</div>
{/* Detail */}
<div className="lg:col-span-3">
{selected ? <ProjectDetail project={selected} /> : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
</Card>
)}
</div>
</div>
</div>
)
}
function ProjectDetail({ project }: { project: Project }) {
const [running, setRunning] = useState(false)
const todo = project.tasks.filter((t) => t.status === 'todo')
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
const review = project.tasks.filter((t) => t.status === 'review')
const done = project.tasks.filter((t) => t.status === 'done')
return (
<Card>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle className="text-base">{project.name}</CardTitle>
<p className="mt-0.5 text-[11px] text-muted-foreground">{project.company.name}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{STATUS_LABELS[project.status]}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4">
<Tabs defaultValue="tasks">
<TabsList className="w-full justify-start">
<TabsTrigger value="tasks">Aufgaben</TabsTrigger>
<TabsTrigger value="time">Zeit</TabsTrigger>
<TabsTrigger value="budget">Budget</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="mt-4 space-y-4">
<TaskColumn label="Offen" tasks={todo} />
<TaskColumn label="In Arbeit" tasks={inProgress} />
<TaskColumn label="Review" tasks={review} />
<TaskColumn label="Erledigt" tasks={done} muted />
</TabsContent>
<TabsContent value="time" className="mt-4 space-y-3">
{/* Live timer mock */}
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 p-3">
<Button size="sm" variant={running ? 'default' : 'outline'} className="gap-1.5" onClick={() => setRunning((r) => !r)}>
{running ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{running ? 'Stop' : 'Timer starten'}
</Button>
<div className="flex items-center gap-1.5 text-sm">
<Timer className="size-4 text-muted-foreground" />
<span className="font-mono tabular-nums">{running ? '00:42:18' : '00:00:00'}</span>
</div>
<span className="ml-auto text-[11px] text-muted-foreground">An: {project.name}</span>
</div>
<div className="space-y-2">
{project.timeEntries.map((te) => (
<div key={te.id} className="flex items-center gap-3 rounded-md border border-border p-2.5">
<Clock className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{te.description}</p>
<p className="text-[10px] text-muted-foreground">{formatDate(te.date)}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold tabular-nums">{formatMinutes(te.minutes)}</p>
{te.billable && te.rateCentsPerHour && (
<p className="text-[10px] text-muted-foreground">{formatEuro(te.rateCentsPerHour)}/h</p>
)}
</div>
</div>
))}
{project.timeEntries.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
)}
</div>
</TabsContent>
<TabsContent value="budget" className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Typ</p>
<p className="mt-1 text-sm font-medium capitalize">{project.budgetType.replace('-', ' & ')}</p>
</div>
{project.fixedPriceCents && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Fixed Price</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(project.fixedPriceCents)}</p>
</div>
)}
{project.budgetHours && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Budget-Stunden</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.budgetHours} h</p>
</div>
)}
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Verbraucht</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.spentHours} h</p>
</div>
</div>
{project.budgetHours && (
<div>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Budget-Auslastung</span>
<span className="font-medium tabular-nums">
{Math.round((project.spentHours / project.budgetHours) * 100)}%
</span>
</div>
<Progress
value={(project.spentHours / project.budgetHours) * 100}
className="h-2"
/>
{project.spentHours > project.budgetHours && (
<p className="mt-2 text-[11px] text-foreground">⚠ Über Budget — Change Request prüfen.</p>
)}
</div>
)}
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">Change Requests</p>
<p className="mt-1">Scope-Änderung → bepreistes Angebot → Freigabe → Aufgaben hinzugefügt. (v1 Demo)</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}
function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; muted?: boolean }) {
return (
<div>
<div className="mb-1.5 flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground">({tasks.length})</span>
</div>
<div className="space-y-1.5">
{tasks.map((t) => {
const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle
return (
<div key={t.id} className={cn('flex items-center gap-2.5 rounded-md border border-border p-2.5', muted && 'opacity-70')}>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs">{t.title}</p>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
{t.assignee && <span>{t.assignee}</span>}
{t.estimateMin && <span>· ~{formatMinutes(t.estimateMin)}</span>}
</div>
</div>
{t.priority === 'high' && !muted && (
<Badge variant="outline" className="text-[9px]">hoch</Badge>
)}
</div>
)
})}
{tasks.length === 0 && (
<div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">—</div>
)}
</div>
</div>
)
}
+236
View File
@@ -0,0 +1,236 @@
'use client'
import { useState } from 'react'
import {
Settings2,
ShieldCheck,
Boxes,
Lock,
Loader2,
CheckCircle2,
Circle,
AlertTriangle,
Database,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface ModuleData {
id: string
version: string
enabled: boolean
installed: boolean
}
interface ModulesResponse {
modules: ModuleData[]
settings: Record<string, string>
}
const MODULE_META: Record<string, { label: string; desc: string; phase: string }> = {
crm: { label: 'CRM', desc: 'Firmen, Personen, Deals, Pipeline, Mail-Sync.', phase: 'Phase 1' },
leads: { label: 'Leads Finder', desc: 'Evidenzbasierte Website-Audits + LLM-Erklärung.', phase: 'Phase 2' },
projects: { label: 'Projekte', desc: 'Aufgaben, Zeit, Budgets, Client-Portal.', phase: 'Phase 3' },
licenses: { label: 'Lizenzverwaltung', desc: 'Ausgestellte Lizenzen + Erneuerungsregister.', phase: 'Phase 4' },
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer (geplant).', phase: 'Later' },
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
}
const COMPLIANCE_ITEMS = [
{ id: 'gobd', label: 'GoBD: Unveränderlichkeit & Hash-Kette', desc: 'Audit-Trigger, Storno statt Edit, Aufbewahrung 8/6 J.' },
{ id: 'dsgvo', label: 'DSGVO: Konsens- & Verarbeitungsprotokoll', desc: 'Art. 6/7/14 je Kontakt, Export & Löschung.' },
{ id: 'erechnung', label: 'E-Rechnung: ZUGFeRD / XRechnung', desc: 'EN 16931 validiert via @e-invoice-eu/core.' },
{ id: 'uwg', label: 'UWG § 7: Cold-Outreach-Gate', desc: 'Kein Auto-Versand, Suppression-Liste.' },
{ id: 'tdddg', label: 'TDDDG: Cookie-/Tracking-Einwilligung', desc: 'Kein Tracking im Portal/öffentlichen Formularen.' },
{ id: 'bfsg', label: 'BFSG: Barrierefreiheit (WCAG 2.1 AA)', desc: 'Launch-Checklist für Kunden-Projekte.' },
{ id: 'retention', label: 'Aufbewahrungsfristen (Finanz & CRM)', desc: 'Automatische Lösch-Jobs nach Frist.' },
{ id: 'encryption', label: 'Verschlüsselung: Secrets AES-256-GCM', desc: 'ENCRYPTION_KEY separat vom DB-Backup.' },
]
export function SettingsView() {
const { data, loading, refetch } = useFetch<ModulesResponse>('/api/aos/modules')
const [toggling, setToggling] = useState<string | null>(null)
const toggle = async (id: string, enabled: boolean) => {
setToggling(id)
const t = toast.loading(enabled ? 'Modul wird aktiviert…' : 'Modul wird deaktiviert…')
try {
const r = await fetch('/api/aos/modules', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id, enabled }),
})
if (!r.ok) throw new Error('toggle failed')
toast.success(enabled ? 'Modul aktiviert' : 'Modul deaktiviert', { id: t })
refetch()
} catch {
toast.error('Umschalten fehlgeschlagen', { id: t })
} finally {
setToggling(null)
}
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-64" />
</div>
)
}
const enabledCount = data.modules.filter((m) => m.enabled).length
const installedCount = data.modules.filter((m) => m.installed).length
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Erweiterungen & System"
description="Modular: Jede Domäne ist ein Modul. In v1 wird „Aktivieren“ via Rebuild ausgeführt – kein Hot-Plug."
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Installierte Module</p>
<Boxes className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{installedCount}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Aktiviert</p>
<CheckCircle2 className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledCount}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Compliance-Bereiche</p>
<ShieldCheck className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{COMPLIANCE_ITEMS.length}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">AOS-Version</p>
<Database className="size-3.5 text-muted-foreground" />
</div>
<p className="mt-1 text-2xl font-semibold tabular-nums">1.0.0-dev</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Modules */}
<Card className="lg:col-span-3">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Boxes className="size-3.5 text-muted-foreground" />
Module
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border p-0">
{data.modules.map((m) => {
const meta = MODULE_META[m.id] ?? { label: m.id, desc: '', phase: '' }
const busy = toggling === m.id
return (
<div key={m.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{meta.label}</p>
<Badge variant="outline" className="text-[10px]">{meta.phase}</Badge>
<code className="text-[10px] text-muted-foreground">v{m.version}</code>
</div>
{meta.desc && <p className="mt-0.5 text-[11px] text-muted-foreground">{meta.desc}</p>}
</div>
<div className="flex items-center gap-2">
{busy && <Loader2 className="size-3.5 animate-spin text-muted-foreground" />}
<Switch
checked={m.enabled}
onCheckedChange={(v) => toggle(m.id, v)}
disabled={busy || m.id === 'crm'}
aria-label={`${meta.label} ${m.enabled ? 'deaktivieren' : 'aktivieren'}`}
/>
</div>
</div>
)
})}
</CardContent>
</Card>
{/* Compliance checklist */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<ShieldCheck className="size-3.5 text-muted-foreground" />
Compliance-Checkliste
</CardTitle>
</CardHeader>
<CardContent className="space-y-2.5 p-4">
{COMPLIANCE_ITEMS.map((item) => (
<div key={item.id} className="flex items-start gap-2.5">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-foreground" />
<div className="min-w-0">
<p className="text-xs font-medium">{item.label}</p>
<p className="text-[11px] text-muted-foreground">{item.desc}</p>
</div>
</div>
))}
<div className="mt-2 rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground">
<AlertTriangle className="size-3" />
CI blockiert Merges ohne Audit- & Aufbewahrungs-Metadaten.
</p>
</div>
</CardContent>
</Card>
</div>
{/* Agency settings preview */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<Settings2 className="size-3.5 text-muted-foreground" />
Agentur-Stammdaten
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<SettingRow label="Firma" value={data.settings['company.name']} />
<SettingRow label="Rechtsform" value={data.settings['company.legalForm']} />
<SettingRow label="USt-IdNr." value={data.settings['company.vatId']} mono />
<SettingRow label="Registergericht" value={data.settings['company.registerCourt']} />
<SettingRow label="HRB-Nummer" value={data.settings['company.registerNumber']} mono />
<SettingRow label="Sitz" value={data.settings['company.city']} />
<SettingRow label="Rechnungspräfix" value={data.settings['invoice.numberPrefix']} mono />
<SettingRow label="Steuersatz (Standard)" value={`${(Number(data.settings['tax.standardBp']) / 100).toFixed(0)} %`} />
<SettingRow label="Lead-Radius" value={`${data.settings['leadFinder.radiusKm']} km`} />
</div>
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-muted/30 p-3 text-[11px]">
<Lock className="size-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
Secrets (SMTP, API-Keys, Signier-Schlüssel) sind AES-256-GCM verschlüsselt. <code className="font-mono">ENCRYPTION_KEY</code> wird separat vom DB-Backup verwahrt.
</span>
</div>
</CardContent>
</Card>
</div>
)
}
function SettingRow({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
return (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className={cn('mt-1 text-sm font-medium', mono && 'font-mono')}>{value ?? '—'}</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ComponentProps } from 'react'
export function ThemeProvider({ children, ...props }: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import { useState, useSyncExternalStore } from 'react'
import { useTheme } from 'next-themes'
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
// Subscribe to whether the component has hydrated without setState-in-effect.
const emptySubscribe = () => () => {}
function useHydrated() {
return useSyncExternalStore(
emptySubscribe,
() => true, // client snapshot
() => false // server snapshot
)
}
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const mounted = useHydrated()
if (!mounted) return <div className="h-9 w-9" aria-hidden />
const isDark = theme === 'dark'
return (
<Button
variant="ghost"
size="icon"
aria-label={isDark ? 'Auf helles Design wechseln' : 'Auf dunkles Design wechseln'}
onClick={() => setTheme(isDark ? 'light' : 'dark')}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+157
View File
@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+53
View File
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+213
View File
@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+241
View File
@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+353
View File
@@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+257
View File
@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+167
View File
@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+77
View File
@@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+276
View File
@@ -0,0 +1,276 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+168
View File
@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+56
View File
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+185
View File
@@ -0,0 +1,185 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+73
View File
@@ -0,0 +1,73 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+48
View File
@@ -0,0 +1,48 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
export function useFetch<T>(url: string | null) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(!!url)
const [error, setError] = useState<string | null>(null)
const [nonce, setNonce] = useState(0)
const refetch = useCallback(() => setNonce((n) => n + 1), [])
useEffect(() => {
if (!url) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setData(null)
setLoading(false)
return
}
let cancelled = false
const controller = new AbortController()
setLoading(true)
setError(null)
fetch(url, { signal: controller.signal })
.then(async (r) => {
if (!r.ok) throw new Error('HTTP ' + r.status)
return (await r.json()) as T
})
.then((json) => {
if (!cancelled) {
setData(json)
setLoading(false)
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'fetch error')
setLoading(false)
}
})
return () => {
cancelled = true
controller.abort()
}
}, [url, nonce])
return { data, loading, error, refetch }
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+194
View File
@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error', 'warn'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
+56
View File
@@ -0,0 +1,56 @@
// German formatting helpers — money in integer cents, dates in de-DE
export function formatEuro(cents: number, opts?: { sign?: boolean }): string {
const sign = opts?.sign && cents > 0 ? '+' : ''
const neg = cents < 0 ? '−' : ''
const abs = Math.abs(cents) / 100
return (
sign +
neg +
abs.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +
' €'
)
}
export function formatEuroCompact(cents: number): string {
const abs = Math.abs(cents)
const neg = cents < 0 ? '−' : ''
if (abs >= 1_000_000) return `${neg}${(abs / 1_000_000 / 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} M€`
if (abs >= 10_000) return `${neg}${(abs / 1_000 / 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} k€`
return formatEuro(cents)
}
export function formatDate(d: Date | string, opts?: Intl.DateTimeFormatOptions): string {
const date = typeof d === 'string' ? new Date(d) : d
return date.toLocaleDateString('de-DE', opts ?? { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function formatDateTime(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export function formatRelative(d: Date | string): string {
const date = typeof d === 'string' ? new Date(d) : d
const diff = date.getTime() - Date.now()
const days = Math.round(diff / 86400000)
const rtf = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' })
if (Math.abs(days) >= 1) return rtf.format(days, 'day')
const hours = Math.round(diff / 3600000)
if (Math.abs(hours) >= 1) return rtf.format(hours, 'hour')
const mins = Math.round(diff / 60000)
return rtf.format(mins, 'minute')
}
export function daysUntil(d: Date | string): number {
const date = typeof d === 'string' ? new Date(d) : d
return Math.round((date.getTime() - Date.now()) / 86400000)
}
export function formatMinutes(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
if (h === 0) return `${m} min`
if (m === 0) return `${h} h`
return `${h} h ${m} min`
}
+412
View File
@@ -0,0 +1,412 @@
// AOS seed script — realistic German software agency dataset
// Run with: bun run src/lib/seed.ts
import { db } from './db'
const CENTS = (euro: number) => Math.round(euro * 100)
const daysFromNow = (d: number) => new Date(Date.now() + d * 86400000)
async function main() {
console.log('Seeding AOS…')
// --- Users / RBAC -------------------------------------------------------
const users = [
{ email: 'max@nordlicht.digital', name: 'Max Brandt', role: 'owner' },
{ email: 'lena@nordlicht.digital', name: 'Lena Vogt', role: 'admin' },
{ email: 'sven@nordlicht.digital', name: 'Sven Hoffmann', role: 'sales' },
{ email: 'aylin@nordlicht.digital', name: 'Aylin Demir', role: 'pm' },
{ email: 'tom@nordlicht.digital', name: 'Tom Becker', role: 'developer' },
{ email: 'nora@nordlicht.digital', name: 'Nora Klein', role: 'finance' },
{ email: 'portal@bauer-handwerk.de', name: 'Klaus Bauer', role: 'client' },
]
for (const u of users) {
await db.user.upsert({
where: { email: u.email },
update: {},
create: u,
})
}
// --- Modules registry ---------------------------------------------------
const modules = [
{ id: 'crm', enabled: true, version: '1.0.0' },
{ id: 'leads', enabled: true, version: '1.0.0' },
{ id: 'projects', enabled: true, version: '1.0.0' },
{ id: 'licenses', enabled: true, version: '1.0.0' },
{ id: 'sales-docs', enabled: false, version: '0.9.0' },
{ id: 'finance', enabled: true, version: '1.0.0' },
{ id: 'support', enabled: false, version: '0.5.0' },
{ id: 'reporting', enabled: false, version: '0.5.0' },
]
for (const m of modules) {
await db.module.upsert({
where: { id: m.id },
update: {},
create: m,
})
}
// --- Settings (German defaults) ----------------------------------------
const settings: Record<string, string> = {
'company.name': 'Nordlicht Digital GmbH',
'company.legalForm': 'GmbH',
'company.vatId': 'DE284617302',
'company.registerCourt': 'Amtsgericht Hamburg',
'company.registerNumber': 'HRB 142887',
'company.city': 'Hamburg',
'company.bank': 'DE89 3704 0044 0532 0130 00',
'invoice.numberPrefix': 'RE',
'invoice.numberYear': '2026',
'invoice.numberNext': '0042',
'tax.standardBp': '1900',
'tax.reducedBp': '700',
'leadFinder.radiusKm': '100',
'leadFinder.costCapEur': '20',
'compliance.goBD': 'true',
'compliance.eRechnung': 'true',
}
for (const [key, value] of Object.entries(settings)) {
await db.setting.upsert({
where: { key },
update: { value },
create: { key, value },
})
}
// --- CRM: Companies ----------------------------------------------------
const companies = [
{ name: 'Bauer Handwerk GmbH', legalForm: 'GmbH', vatId: 'DE301884012', registerCourt: 'Amtsgericht Lübeck', registerNumber: 'HRB 8842', website: 'bauer-handwerk.de', email: 'info@bauer-handwerk.de', phone: '+49 451 8842', city: 'Lübeck', zip: '23552', street: 'Mühlendamm 14', source: 'lead-finder', tags: 'handwerk,website-neu' },
{ name: 'Ostsee Hotel & Spa AG', legalForm: 'AG', vatId: 'DE815491002', registerCourt: 'Amtsgericht Rostock', registerNumber: 'HRB 4012', website: 'ostsee-hotel-spa.de', email: 'kontakt@ostsee-hotel-spa.de', phone: '+49 381 4012', city: 'Rostock', zip: '18055', street: 'Lange Straße 8', source: 'lead-finder', tags: 'hotel,redesign' },
{ name: 'Müller Bäckerei e.K.', legalForm: 'e.K.', vatId: 'DE192003411', website: 'mueller-baeckerei.de', email: 'hallo@mueller-baeckerei.de', phone: '+49 40 8841 22', city: 'Hamburg', zip: '20095', street: 'Spitalerstraße 33', source: 'import', tags: 'food,shop' },
{ name: 'TechStart UG (haftungsbeschränkt)', legalForm: 'UG', vatId: 'DE312045519', registerCourt: 'Amtsgericht Berlin-Charlottenburg', registerNumber: 'HRB 220934', website: 'techstart.berlin', email: 'hello@techstart.berlin', phone: '+49 30 8841 200', city: 'Berlin', zip: '10115', street: 'Torstraße 100', source: 'lead-finder', tags: 'startup,saas' },
{ name: 'Nordwind Logistik GmbH', legalForm: 'GmbH', vatId: 'DE440281115', registerCourt: 'Amtsgericht Bremen', registerNumber: 'HRB 22119', website: 'nordwind-logistik.de', email: 'info@nordwind-logistik.de', phone: '+49 421 500 88', city: 'Bremen', zip: '28195', street: 'Am Markt 12', source: 'referral', tags: 'logistik,maintenance' },
{ name: 'Atelier Wendland GbR', legalForm: 'GbR', website: 'atelier-wendland.de', email: 'studio@atelier-wendland.de', city: 'Lüneburg', zip: '21335', street: 'Am Sande 4', source: 'manual', tags: 'design,redesign' },
{ name: 'Klinik am Park MV GmbH', legalForm: 'GmbH', vatId: 'DE293041855', registerCourt: 'Amtsgericht Schwerin', registerNumber: 'HRB 5512', website: 'klinik-am-park-mv.de', email: 'kontakt@klinik-am-park-mv.de', phone: '+49 385 2241', city: 'Schwerin', zip: '19053', street: 'Gartenstraße 21', source: 'lead-finder', tags: 'gesundheit,seo,accessibility' },
{ name: 'HanseMöbel Manufaktur GmbH', legalForm: 'GmbH', vatId: 'DE118823440', registerCourt: 'Amtsgericht Hamburg', registerNumber: 'HRB 161204', website: 'hansemoebel.de', email: 'verkauf@hansemoebel.de', phone: '+49 40 7710 00', city: 'Hamburg', zip: '21107', street: 'Stuhlrother Str. 5', source: 'lead-finder', tags: 'shop,woocommerce' },
]
const companyMap: Record<string, string> = {}
for (const c of companies) {
const created = await db.company.create({ data: c })
companyMap[c.name] = created.id
}
// --- Persons ------------------------------------------------------------
const persons = [
{ companyId: companyMap['Bauer Handwerk GmbH'], firstName: 'Klaus', lastName: 'Bauer', email: 'klaus@bauer-handwerk.de', phone: '+49 451 8842 0', role: 'Geschäftsführer' },
{ companyId: companyMap['Bauer Handwerk GmbH'], firstName: 'Sabine', lastName: 'Bauer', email: 'sabine@bauer-handwerk.de', role: 'Geschäftsführung' },
{ companyId: companyMap['Ostsee Hotel & Spa AG'], firstName: 'Henrik', lastName: 'Olsen', email: 'henrik@ostsee-hotel-spa.de', role: 'Direktor' },
{ companyId: companyMap['Müller Bäckerei e.K.'], firstName: 'Greta', lastName: 'Müller', email: 'greta@mueller-baeckerei.de', role: 'Inhaberin' },
{ companyId: companyMap['TechStart UG (haftungsbeschränkt)'], firstName: 'Jonas', lastName: 'Weiß', email: 'jonas@techstart.berlin', role: 'CEO' },
{ companyId: companyMap['Nordwind Logistik GmbH'], firstName: 'Petra', lastName: 'Hahn', email: 'petra.hahn@nordwind-logistik.de', role: 'Leitung IT' },
{ companyId: companyMap['Klinik am Park MV GmbH'], firstName: 'Dr. med. Iris', lastName: 'Brandt', email: 'i.brandt@klinik-am-park-mv.de', role: 'Ärztliche Leitung' },
{ companyId: companyMap['HanseMöbel Manufaktur GmbH'], firstName: 'Frank', lastName: 'Hartmann', email: 'f.hartmann@hansemoebel.de', role: 'Verkaufsleiter' },
]
for (const p of persons) {
await db.person.create({ data: p })
}
// --- Deals / pipeline ---------------------------------------------------
const pipelineStages = [
{ key: 'lead', label: 'Lead', probability: 10 },
{ key: 'qualified', label: 'Qualifiziert', probability: 25 },
{ key: 'proposal', label: 'Angebot gesendet', probability: 50 },
{ key: 'negotiation', label: 'Verhandlung', probability: 70 },
{ key: 'won', label: 'Gewonnen', probability: 100 },
{ key: 'lost', label: 'Verloren', probability: 0 },
]
void pipelineStages
const deals = [
{ company: 'Bauer Handwerk GmbH', title: 'Neue Website inkl. CMS', valueCents: CENTS(9800), probability: 70, stage: 'proposal', expectedClose: daysFromNow(14) },
{ company: 'Ostsee Hotel & Spa AG', title: 'Website-Redesign + Buchungsintegration', valueCents: CENTS(24500), probability: 50, stage: 'qualified', expectedClose: daysFromNow(35) },
{ company: 'Müller Bäckerei e.K.', title: 'WooCommerce Online-Shop', valueCents: CENTS(7200), probability: 25, stage: 'lead', expectedClose: daysFromNow(50) },
{ company: 'TechStart UG (haftungsbeschränkt)', title: 'SaaS MVP Entwicklung', valueCents: CENTS(48000), probability: 70, stage: 'negotiation', expectedClose: daysFromNow(21) },
{ company: 'Nordwind Logistik GmbH', title: 'Wartungsvertrag 12 Monate', valueCents: CENTS(6600), probability: 100, stage: 'won', expectedClose: daysFromNow(-3) },
{ company: 'Atelier Wendland GbR', title: 'Portfolio-Website', valueCents: CENTS(3900), probability: 10, stage: 'lead', expectedClose: daysFromNow(60) },
{ company: 'Klinik am Park MV GmbH', title: 'SEO + Barrierefreiheit BFSG', valueCents: CENTS(12500), probability: 50, stage: 'proposal', expectedClose: daysFromNow(28) },
{ company: 'HanseMöbel Manufaktur GmbH', title: 'WooCommerce Migration + Anbindung ERP', valueCents: CENTS(31000), probability: 25, stage: 'qualified', expectedClose: daysFromNow(45) },
{ company: 'Nordwind Logistik GmbH', title: 'Intranet-Portal', valueCents: CENTS(18000), probability: 0, stage: 'lost', lossReason: 'Budget gekürzt', expectedClose: daysFromNow(-10) },
]
for (const d of deals) {
const { company, ...rest } = d
await db.deal.create({ data: { ...rest, companyId: companyMap[company] } })
}
// --- Leads Finder -------------------------------------------------------
const evidence = (items: { id: string; check: string; status: 'pass' | 'warn' | 'fail'; detail: string }[]) => JSON.stringify(items)
const leads = [
{
companyName: 'Fahrrad Manufaktur Altona', website: 'fahrrad-altona.de', city: 'Hamburg', branch: 'Einzelhandel',
score: 84, scoreLabel: 'redesign',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'warn', detail: 'Zertifikat läuft in 9 Tagen ab (Let\'s Encrypt, zuletzt erneuert 2026-01-12)' },
{ id: 'e2', check: 'Mobile Viewport', status: 'pass', detail: 'meta viewport vorhanden, Layout aber 410px zu breit auf 375px Bildschirm' },
{ id: 'e3', check: 'Core Web Vitals (LCP)', status: 'fail', detail: 'LCP 4,8s (Ziel < 2,5s), gemessen via PageSpeed API am 2026-10-24' },
{ id: 'e4', check: 'CMS / Plugin-Versionen', status: 'warn', detail: 'WordPress 6.2 (veraltet, aktuell 6.5), WooCommerce 7.4 (2 Major-Versionen hinter aktuell)' },
{ id: 'e5', check: 'Impressum vorhanden', status: 'pass', detail: 'Impressum gefunden, enthält Angaben gem. § 5 TMG/DDG' },
{ id: 'e6', check: 'Datenschutz / Cookie Banner', status: 'warn', detail: 'Datenschutzerklärung vorhanden, Cookie-Banner initialisiert erst nach Scroll (DSGVO-Risiko)' },
{ id: 'e7', check: 'Strukturierte Daten', status: 'fail', detail: 'Keine Schema.org-Produkt-Markup trotz Online-Shop' },
{ id: 'e8', check: 'Letzte Aktualisierung', status: 'warn', detail: 'Copyright-Zeile zeigt 2023, sitemap.xml nicht gefunden' },
]),
explanation: 'Fahrrad Manufaktur Altona betreibt einen WooCommerce-Shop auf einer veralteten WordPress-Installation (e4) mit schlechten Core Web Vitals (e3) und fehlendem Produktmarkup (e7). Das SSL-Zertifikat läuft in 9 Tagen ab (e1) — ein konkreter, zeitkritischer Ansatzpunkt. Vorgeschlagener Winkel: "Wir sichern Ihren Shop vor dem Zertifikat-Ablauf und holen die Ladezeit in den grünen Bereich."',
suggestedAngle: 'SSL-Ablauf + Performance-Optimierung als Türöffner, danach WooCommerce-Upgrade & SEO-AEO.',
status: 'pending', source: 'serpapi', radiusKm: 100,
},
{
companyName: 'Ostsee Catering Berg', website: 'ostsee-catering-berg.de', city: 'Rostock', branch: 'Gastronomie',
score: 76, scoreLabel: 'new-site',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültiges Zertifikat bis 2026-04-18' },
{ id: 'e2', check: 'Mobile Viewport', status: 'pass', detail: 'Korrekt konfiguriert' },
{ id: 'e3', check: 'Core Web Vitals (LCP)', status: 'warn', detail: 'LCP 2,9s, knapp über Ziel' },
{ id: 'e4', check: 'CMS / Plugin-Versionen', status: 'warn', detail: 'Contao 4.13, Support-Ende erreicht' },
{ id: 'e5', check: 'Impressum vorhanden', status: 'fail', detail: 'Keine USt-IdNr. angegeben, fehlende Handelsregister-Nummer' },
{ id: 'e6', check: 'Datenschutz / Cookie Banner', status: 'warn', detail: 'YouTube-Videos ohne lokale Einbindung, Cookie-Banner nicht CMP-zertifiziert' },
{ id: 'e7', check: 'AEO / GEO (LLM-Zugänglichkeit)', status: 'fail', detail: 'Keine llms.txt, keine strukturierten Daten zu Speisekarten oder Preisen' },
]),
explanation: 'Ostsee Catering Berg nutzt Contao 4.13, dessen Support-Ende erreicht ist (e4). Das Impressum ist unvollständig und damit abmahngefährdet (e5). Für eine Catering-Spitze sind LLM-gestützte Suchen ("Catering Rostock Hochzeit") relevant, aber es fehlen strukturierte Daten (e7).',
suggestedAngle: 'Rechtscheck Impressum/Datenschutz + AEO für "Catering + Stadt"-Anfragen.',
status: 'pending', source: 'tavily', radiusKm: 100,
},
{
companyName: 'Schiffswerft Hansen', website: 'werft-hansen.de', city: 'Kiel', branch: 'Industrie',
score: 62, scoreLabel: 'maintenance',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Core Web Vitals (LCP)', status: 'pass', detail: 'LCP 1,9s' },
{ id: 'e3', check: 'Letzte Aktualisierung', status: 'fail', detail: 'Letzter News-Eintrag 2021, kein CMS erkennbar, statische HTML-Seite' },
{ id: 'e4', check: 'Strukturierte Daten', status: 'fail', detail: 'Keine Organisation/LocalBusiness-Markup' },
{ id: 'e5', check: 'Impressum vorhanden', status: 'warn', detail: 'Vorhanden, aber veraltete Telefonnummer' },
]),
explanation: 'Schiffswerft Hansen hat eine schnelle, aber statische Seite ohne CMS und seit 2021 keine Aktualisierung (e3). Potenzial für ein Redesign mit redaktionellem CMS und LocalBusiness-Markup (e4), um in regionalen Suchen besser gefunden zu werden.',
suggestedAngle: 'Redaktionelle Unabhängigkeit durch CMS + Local-SEO für Werft-Dienstleistungen.',
status: 'pending', source: 'apify', radiusKm: 100,
},
{
companyName: 'Yoga Studio Mitte', website: 'yoga-mitte-berlin.de', city: 'Berlin', branch: 'Dienstleistung',
score: 71, scoreLabel: 'seo',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Core Web Vitals (LCP)', status: 'warn', detail: 'LCP 3,4s, Bilder nicht optimiert' },
{ id: 'e3', check: 'Strukturierte Daten', status: 'fail', detail: 'Keine Event-/Course-Markup für Stundenplan' },
{ id: 'e4', check: 'Sitemap / robots', status: 'warn', detail: 'Sitemap leer, robots blockiert /wp-admin korrekt' },
{ id: 'e5', check: 'Accessibility (BFSG)', status: 'warn', detail: 'Kontrast zu gering auf Buttons, keine Skip-Links' },
]),
explanation: 'Yoga Studio Mitte erreicht mobil nur mäßige Ladezeiten (e2) und nutzt kein Event-Markup für seinen Stundenplan (e3). Da das Studio eine App-ähnliche Buchung anbietet, ist BFSG-Relevanz wahrscheinlich (e5) — ein Ansatzpunkt für Barrierefreiheit.',
suggestedAngle: 'Performance + strukturierte Kursdaten + BFSG-Barrierefreiheit.',
status: 'pending', source: 'serpapi', radiusKm: 300,
},
{
companyName: 'Bauhof Schleswig', website: 'bauhof-schleswig.de', city: 'Schleswig', branch: 'Handwerk',
score: 58, scoreLabel: 'redesign',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'fail', detail: 'Nur HTTP, automatischer Redirect fehlt' },
{ id: 'e2', check: 'Mobile Viewport', status: 'fail', detail: 'Kein meta viewport, Desktop-Layout auf Mobilgeräten' },
{ id: 'e3', check: 'Impressum vorhanden', status: 'warn', detail: 'Vorhanden, aber keine Datenschutzerklärung' },
]),
explanation: 'Bauhof Schleswig betreibt eine reine HTTP-Seite ohne mobile Optimierung (e1, e2) — ein klarer technischer Notfall und gleichzeitig der einfachste Einstieg für eine komplette Neuentwicklung. Datenschutz fehlt (e3).',
suggestedAngle: 'Sicherheits-Upgrade (HTTPS) + mobilfähige Neuentwicklung + Rechtskonformität.',
status: 'pending', source: 'firecrawl', radiusKm: 100,
},
{
companyName: 'Weingut Moseltraum', website: 'moseltraum-weingut.de', city: 'Traben-Trarbach', branch: 'Landwirtschaft',
score: 49, scoreLabel: 'shop',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Mobile Viewport', status: 'pass', detail: 'Korrekt' },
{ id: 'e3', check: 'Shop vorhanden', status: 'fail', detail: 'Kein Online-Shop, obwohl Weinversand angeboten wird (Impressum erwähnt Versand)' },
{ id: 'e4', check: 'Altersprüfung', status: 'fail', detail: 'Kein Altersverifikations-Plugin für alkoholhaltige Produkte' },
]),
explanation: 'Weingut Moseltraum bewirbt Weinversand, hat aber keinen Online-Shop (e3) und keine Altersprüfung (e4) — sowohl eine Umsatzchance als auch eine Compliance-Lücke beim Versand alkoholhaltiger Getränke.',
suggestedAngle: 'WooCommerce-Shop + rechtssichere Altersverifikation.',
status: 'pending', source: 'tavily', radiusKm: 500,
},
{
companyName: 'Tierarztpraxis Eulenflucht', website: 'tierarzt-eulenflucht.de', city: 'Flensburg', branch: 'Gesundheit',
score: 67, scoreLabel: 'accessibility',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Accessibility (BFSG)', status: 'fail', detail: 'Formularfelder ohne Label, keine ARIA-Rollen, Kontrast 3:1 (Ziel 4,5:1)' },
{ id: 'e3', check: 'Online-Terminbuchung', status: 'warn', detail: 'Nur Telefonnummer, keine digitale Buchung' },
{ id: 'e4', check: 'Strukturierte Daten', status: 'warn', detail: 'Keine LocalBusiness-Markup, obwohl Praxis-Standort relevant' },
]),
explanation: 'Tierarztpraxis Eulenflucht hat gravierende Accessibility-Mängel (e2) und bietet keine Online-Terminbuchung (e3). Da Gesundheitsdienstleister unter BFSG fallen können, ist die Barrierefreiheit hier nicht optional.',
suggestedAngle: 'BFSG-konforme Barrierefreiheit + digitale Terminbuchung.',
status: 'pending', source: 'serpapi', radiusKm: 100,
},
{
companyName: 'Dachdecker Meister Petersen', website: 'dachdecker-petersen.de', city: 'Kiel', branch: 'Handwerk',
score: 73, scoreLabel: 'new-site',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Mobile Viewport', status: 'pass', detail: 'Korrekt' },
{ id: 'e3', check: 'Letzte Aktualisierung', status: 'warn', detail: 'Referenzen zuletzt 2022, keine aktuellen Projekte' },
{ id: 'e4', check: 'Strukturierte Daten', status: 'fail', detail: 'Kein LocalBusiness-Markup, keine Öffnungszeiten ausgezeichnet' },
{ id: 'e5', check: 'Anfrageformular', status: 'warn', detail: 'Formular ohne spam-Schutz, keine Erfolgsbestätigung' },
]),
explanation: 'Dachdecker Meister Petersen hat eine technisch solide Basis, aber die Referenzen sind veraltet (e3) und Local-SEO-Markup fehlt (e4). Für Handwerksbetriebe ist Local-SEO der wichtigste Kanal.',
suggestedAngle: 'Aktuelle Referenzen + Local-SEO-Markup + funktionierendes Anfrageformular.',
status: 'pending', source: 'apify', radiusKm: 100,
},
{
companyName: 'Galerie Nordlicht', website: 'galerie-nordlicht.de', city: 'Hamburg', branch: 'Kultur',
score: 41, scoreLabel: 'seo',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'Bilder', status: 'warn', detail: 'Große unoptimierte Bilder, keine WebP, kein Lazy Loading' },
{ id: 'e3', check: 'Strukturierte Daten', status: 'fail', detail: 'Keine VisualArtwork-Markup für Ausstellungsstücke' },
]),
explanation: 'Galerie Nordlicht lädt langsam wegen unoptimierter Bilder (e2) und nutzt kein Markup für Kunstwerke (e3). Für eine Galerie sind Bildsuche und VisualArtwork-Markup ein starker Hebel.',
suggestedAngle: 'Bildoptimierung + VisualArtwork-Markup für bessere Sichtbarkeit in der Bildersuche.',
status: 'pending', source: 'tavily', radiusKm: 50,
},
{
companyName: 'Steuerberatung Knoop & Partner', website: 'knoop-steuerberatung.de', city: 'Hamburg', branch: 'Dienstleistung',
score: 55, scoreLabel: 'maintenance',
evidence: evidence([
{ id: 'e1', check: 'HTTPS / Zertifikat', status: 'pass', detail: 'Gültig' },
{ id: 'e2', check: 'CMS / Plugin-Versionen', status: 'warn', detail: 'TYPO3 11, Support bis 2026-10, Migration nötig' },
{ id: 'e3', check: 'Accessibility (BFSG)', status: 'warn', detail: 'Kontrast knapp unter Ziel, kein Skip-Link' },
{ id: 'e4', check: 'DSGVO / TDDDG', status: 'warn', detail: 'Google Maps Einbindung ohne lokalen Proxy, IP übertragen an Google' },
]),
explanation: 'Steuerberatung Knoop & Partner läuft auf TYPO3 11, dessen Support im Oktober 2026 endet (e2). Die Google-Maps-Einbindung überträgt IPs an Google, was nach TDDDG ein Risiko darstellt (e4).',
suggestedAngle: 'TYPO3-Upgrade + DSGVO-konforme Karten-Einbindung + BFSG-Barrierefreiheit.',
status: 'pending', source: 'serpapi', radiusKm: 50,
},
]
for (const l of leads) {
await db.lead.create({ data: l })
}
// --- Projects -----------------------------------------------------------
const projects = [
{ company: 'Nordwind Logistik GmbH', name: 'Intranet-Portal Q4', type: 'app', status: 'active', budgetType: 'fixed', fixedPriceCents: CENTS(18000), spentHours: 64, budgetHours: 120, startDate: daysFromNow(-20), dueDate: daysFromNow(18), progress: 55 },
{ company: 'Bauer Handwerk GmbH', name: 'Corporate Website Relaunch', type: 'website', status: 'planning', budgetType: 'fixed', fixedPriceCents: CENTS(9800), spentHours: 4, budgetHours: 60, startDate: daysFromNow(-2), dueDate: daysFromNow(40), progress: 8 },
{ company: 'TechStart UG (haftungsbeschränkt)', name: 'SaaS MVP — Auth & Billing', type: 'app', status: 'active', budgetType: 'time-materials', spentHours: 88, budgetHours: 160, startDate: daysFromNow(-30), dueDate: daysFromNow(30), progress: 48 },
{ company: 'HanseMöbel Manufaktur GmbH', name: 'WooCommerce Wartung Q4/2026', type: 'retainer', status: 'active', budgetType: 'retainer', budgetHours: 20, spentHours: 14, startDate: daysFromNow(-25), dueDate: daysFromNow(65), progress: 70 },
{ company: 'Klinik am Park MV GmbH', name: 'SEO-Optimierung & BFGG-Check', type: 'website', status: 'review', budgetType: 'fixed', fixedPriceCents: CENTS(12500), spentHours: 38, budgetHours: 40, startDate: daysFromNow(-15), dueDate: daysFromNow(5), progress: 92 },
{ company: 'Ostsee Hotel & Spa AG', name: 'Website-Redesign Konzept', type: 'website', status: 'planning', budgetType: 'time-materials', spentHours: 6, budgetHours: 0, startDate: daysFromNow(-1), dueDate: daysFromNow(21), progress: 5 },
]
const projectMap: Record<string, string> = {}
for (const p of projects) {
const { company, ...rest } = p
const created = await db.project.create({ data: { ...rest, companyId: companyMap[company] } })
projectMap[p.name] = created.id
}
// --- Tasks --------------------------------------------------------------
const tasks = [
{ project: 'Intranet-Portal Q4', title: 'SSO über Better Auth einrichten', status: 'in-progress', assignee: 'Tom Becker', estimateMin: 480, priority: 'high' },
{ project: 'Intranet-Portal Q4', title: 'Dashboard-Komponenten (Kanban, Timeline)', status: 'done', assignee: 'Tom Becker', estimateMin: 360, priority: 'medium' },
{ project: 'Intranet-Portal Q4', title: 'Dokumenten-Upload (SHA-256)', status: 'todo', assignee: 'Tom Becker', estimateMin: 300, priority: 'high' },
{ project: 'Corporate Website Relaunch', title: 'Impressum & Datenschutz finalisieren', status: 'todo', assignee: 'Aylin Demir', estimateMin: 120, priority: 'high' },
{ project: 'Corporate Website Relaunch', title: 'Design-Tokens abnehmen', status: 'done', assignee: 'Aylin Demir', estimateMin: 90, priority: 'medium' },
{ project: 'SaaS MVP — Auth & Billing', title: 'Stripe-Webhook + Rechnungsnummern', status: 'review', assignee: 'Tom Becker', estimateMin: 240, priority: 'high' },
{ project: 'SaaS MVP — Auth & Billing', title: 'Onboarding-Flow (3 Schritte)', status: 'in-progress', assignee: 'Tom Becker', estimateMin: 360, priority: 'medium' },
{ project: 'WooCommerce Wartung Q4/2026', title: 'Plugin-Updates + Backup-Check', status: 'done', assignee: 'Tom Becker', estimateMin: 60, priority: 'medium' },
{ project: 'SEO-Optimierung & BFGG-Check', title: 'BFSG-Audit (axe)', status: 'done', assignee: 'Aylin Demir', estimateMin: 180, priority: 'high' },
{ project: 'SEO-Optimierung & BFGG-Check', title: 'Schema.org LocalBusiness einbauen', status: 'done', assignee: 'Tom Becker', estimateMin: 120, priority: 'medium' },
{ project: 'SEO-Optimierung & BFGG-Check', title: 'Kunden-Freigabe einholen', status: 'in-progress', assignee: 'Aylin Demir', estimateMin: 30, priority: 'high' },
{ project: 'Website-Redesign Konzept', title: 'Moodboard + Wireframes Startseite', status: 'in-progress', assignee: 'Aylin Demir', estimateMin: 300, priority: 'high' },
{ project: 'Website-Redesign Konzept', title: 'Konkurrenzanalyse (5 Hotels)', status: 'todo', assignee: 'Sven Hoffmann', estimateMin: 150, priority: 'medium' },
]
for (const t of tasks) {
const { project, ...rest } = t
await db.task.create({ data: { ...rest, projectId: projectMap[project] } })
}
// --- Time entries -------------------------------------------------------
const timeEntries = [
{ project: 'Intranet-Portal Q4', description: 'Better Auth SSO Integration', minutes: 180, billable: true, rateCentsPerHour: CENTS(95), date: daysFromNow(-1) },
{ project: 'Intranet-Portal Q4', description: 'Kanban-Drag-Drop implementiert', minutes: 240, billable: true, rateCentsPerHour: CENTS(95), date: daysFromNow(-2) },
{ project: 'SaaS MVP — Auth & Billing', description: 'Stripe Webhook Handler', minutes: 150, billable: true, rateCentsPerHour: CENTS(110), date: daysFromNow(-1) },
{ project: 'SEO-Optimierung & BFGG-Check', description: 'axe Audit + Report', minutes: 120, billable: true, rateCentsPerHour: CENTS(85), date: daysFromNow(-3) },
{ project: 'WooCommerce Wartung Q4/2026', description: 'Plugin-Updates + Smoke-Test', minutes: 60, billable: true, rateCentsPerHour: CENTS(90), date: daysFromNow(-2) },
{ project: 'Website-Redesign Konzept', description: 'Moodboard Mood-Boards erstellt', minutes: 180, billable: true, rateCentsPerHour: CENTS(85), date: daysFromNow(-1) },
]
for (const te of timeEntries) {
const { project, ...rest } = te
await db.timeEntry.create({ data: { ...rest, projectId: projectMap[project] } })
}
// --- Licenses (issued) --------------------------------------------------
const licenseProducts = [
{ company: 'Nordwind Logistik GmbH', product: 'aos-intranet', plan: 'pro', status: 'active', key: 'AOS-PRO-7F3K-9Q2L-NWX4', maxActivations: 2, validUntil: daysFromNow(280) },
{ company: 'HanseMöbel Manufaktur GmbH', product: 'wp-plugin-erp-sync', plan: 'enterprise', status: 'active', key: 'AOS-ENT-3KP9-5RT8-VBN2', maxActivations: 3, validUntil: daysFromNow(120) },
{ company: 'TechStart UG (haftungsbeschränkt)', product: 'aos-cms', plan: 'starter', status: 'active', key: 'AOS-ST-9MN2-4QR7-LPZ8', maxActivations: 1, validUntil: daysFromNow(45) },
{ company: 'Bauer Handwerk GmbH', product: 'aos-cms', plan: 'pro', status: 'suspended', key: 'AOS-PRO-2JD8-6HJ5-KFR3', maxActivations: 1, validUntil: daysFromNow(-5) },
{ company: 'Klinik am Park MV GmbH', product: 'wp-plugin-bfsg-check', plan: 'pro', status: 'active', key: 'AOS-PRO-1ZA4-9XP2-MNB7', maxActivations: 2, validUntil: daysFromNow(200) },
{ company: 'Müller Bäckerei e.K.', product: 'woo-plugin-click-collect', plan: 'starter', status: 'expired', key: 'AOS-ST-5RC3-8BV1-QWE6', maxActivations: 1, validUntil: daysFromNow(-30) },
]
for (const lic of licenseProducts) {
const { company, ...rest } = lic
const created = await db.license.create({ data: { ...rest, companyId: companyMap[company], token: 'sig_' + rest.key.toLowerCase().replace(/-/g, '') } })
await db.licenseActivation.create({ data: { licenseId: created.id, domain: lic.product.includes('wp') ? 'wp.' + (lic.company === 'Nordwind Logistik GmbH' ? 'nordwind-logistik.de' : 'hansemoebel.de') : lic.company.split(' ')[0].toLowerCase() + '.de', environment: 'production', version: '1.4.2' } })
}
// --- Assets / renewal register -----------------------------------------
const assets = [
{ name: 'Domain nordwind-logistik.de', type: 'domain', provider: 'Ionos', companyId: companyMap['Nordwind Logistik GmbH'], costCentsPerMonth: CENTS(8), renewalDate: daysFromNow(35), rebill: true, markupPct: 50, status: 'active' },
{ name: 'Hosting VPS Strato', type: 'hosting', provider: 'Strato', companyId: companyMap['Nordwind Logistik GmbH'], costCentsPerMonth: CENTS(24), renewalDate: daysFromNow(12), rebill: true, markupPct: 100, status: 'expiring' },
{ name: 'SSL Wildcard *.hansemoebel.de', type: 'ssl', provider: 'Sectigo', companyId: companyMap['HanseMöbel Manufaktur GmbH'], costCentsPerMonth: CENTS(15), renewalDate: daysFromNow(7), rebill: true, markupPct: 30, status: 'expiring' },
{ name: 'WPML Multilingual CMS', type: 'plugin-seat', provider: 'OnTheGoSystems', companyId: companyMap['HanseMöbel Manufaktur GmbH'], costCentsPerMonth: CENTS(9), renewalDate: daysFromNow(90), rebill: true, markupPct: 0, status: 'active' },
{ name: 'Cloudflare Pro', type: 'saas', provider: 'Cloudflare', companyId: companyMap['TechStart UG (haftungsbeschränkt)'], costCentsPerMonth: CENTS(20), renewalDate: daysFromNow(60), rebill: false, markupPct: 0, status: 'active' },
{ name: 'Domain klinik-am-park-mv.de', type: 'domain', provider: 'Netcup', companyId: companyMap['Klinik am Park MV GmbH'], costCentsPerMonth: CENTS(5), renewalDate: daysFromNow(-2), rebill: true, markupPct: 50, status: 'expired' },
{ name: 'Managed WordPress Hosting', type: 'hosting', provider: 'Raidboxes', companyId: companyMap['Bauer Handwerk GmbH'], costCentsPerMonth: CENTS(29), renewalDate: daysFromNow(150), rebill: true, markupPct: 60, status: 'active' },
]
for (const a of assets) {
await db.asset.create({ data: a })
}
// --- Invoices -----------------------------------------------------------
let invCounter = 38
const invoices = [
{ company: 'Nordwind Logistik GmbH', number: `RE-2026-00${++invCounter}`, status: 'paid', type: 'final', issueDate: daysFromNow(-30), dueDate: daysFromNow(-14), paidDate: daysFromNow(-12), netCents: CENTS(1500), zugferd: true, xrechnung: true, datevExported: true, projectId: projectMap['Intranet-Portal Q4'] },
{ company: 'Nordwind Logistik GmbH', number: `RE-2026-00${++invCounter}`, status: 'sent', type: 'partial', issueDate: daysFromNow(-7), dueDate: daysFromNow(14), netCents: CENTS(5400), zugferd: true, xrechnung: false, datevExported: false, projectId: projectMap['Intranet-Portal Q4'] },
{ company: 'HanseMöbel Manufaktur GmbH', number: `RE-2026-00${++invCounter}`, status: 'sent', type: 'recurring', issueDate: daysFromNow(-3), dueDate: daysFromNow(24), netCents: CENTS(420), zugferd: true, xrechnung: true, datevExported: false },
{ company: 'TechStart UG (haftungsbeschränkt)', number: `RE-2026-00${++invCounter}`, status: 'overdue', type: 'final', issueDate: daysFromNow(-40), dueDate: daysFromNow(-10), netCents: CENTS(8800), zugferd: false, xrechnung: false, datevExported: false, projectId: projectMap['SaaS MVP — Auth & Billing'] },
{ company: 'Klinik am Park MV GmbH', number: `RE-2026-00${++invCounter}`, status: 'draft', type: 'final', issueDate: daysFromNow(0), dueDate: daysFromNow(14), netCents: CENTS(3200), zugferd: false, xrechnung: false, datevExported: false, projectId: projectMap['SEO-Optimierung & BFGG-Check'] },
{ company: 'Bauer Handwerk GmbH', number: `RE-2026-00${++invCounter}`, status: 'draft', type: 'partial', issueDate: daysFromNow(0), dueDate: daysFromNow(14), netCents: CENTS(1960), zugferd: false, xrechnung: false, datevExported: false, projectId: projectMap['Corporate Website Relaunch'] },
{ company: 'Ostsee Hotel & Spa AG', number: `RE-2026-00${++invCounter}`, status: 'cancelled', type: 'final', issueDate: daysFromNow(-50), dueDate: daysFromNow(-20), netCents: CENTS(1200), zugferd: false, xrechnung: false, datevExported: false },
]
for (const inv of invoices) {
const { company, ...rest } = inv
const taxBp = 1900
const grossCents = Math.round(rest.netCents * (1 + taxBp / 10000))
const created = await db.invoice.create({ data: { ...rest, companyId: companyMap[company], taxBp, grossCents } })
// one line per invoice for simplicity
await db.invoiceLine.create({
data: {
invoiceId: created.id,
description: inv.type === 'partial' ? 'Abschlagszahlung Projekt' : inv.type === 'recurring' ? 'Wartungspauschale monatlich' : 'Leistungen Projekt',
quantity: 1,
unitCents: rest.netCents,
totalCents: rest.netCents,
}
})
}
// --- Audit log entries ---------------------------------------------------
const auditEntries = [
{ actor: 'max@nordlicht.digital', action: 'crm.deal:update', entity: 'Deal', entityId: 'seed', before: '{"stage":"proposal"}', after: '{"stage":"won"}' },
{ actor: 'lena@nordlicht.digital', action: 'settings.update', entity: 'Setting', entityId: 'invoice.numberNext', before: '"0041"', after: '"0042"' },
{ actor: 'system', action: 'leads.job:score', entity: 'Lead', entityId: 'seed', after: '{"count":10}' },
{ actor: 'nora@nordlicht.digital', action: 'invoice.finalize', entity: 'Invoice', entityId: 'seed', before: '{"status":"draft"}', after: '{"status":"sent","zugferd":true}' },
{ actor: 'sven@nordlicht.digital', action: 'leads.review:approve', entity: 'Lead', entityId: 'seed', after: '{"status":"approved"}' },
]
for (const a of auditEntries) {
await db.auditLog.create({ data: { ...a, hash: 'sha256:' + Math.random().toString(36).slice(2) } })
}
console.log('Seed complete.')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await db.$disconnect()
})
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ViewId =
| 'dashboard'
| 'crm'
| 'leads'
| 'projects'
| 'licenses'
| 'finance'
| 'settings'
interface AosState {
view: ViewId
setView: (v: ViewId) => void
selectedCompanyId: string | null
setSelectedCompany: (id: string | null) => void
selectedLeadId: string | null
setSelectedLead: (id: string | null) => void
sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void
commandOpen: boolean
setCommandOpen: (open: boolean) => void
}
export const useAos = create<AosState>()(
persist(
(set) => ({
view: 'dashboard',
setView: (view) => set({ view }),
selectedCompanyId: null,
setSelectedCompany: (selectedCompanyId) => set({ selectedCompanyId }),
selectedLeadId: null,
setSelectedLead: (selectedLeadId) => set({ selectedLeadId }),
sidebarOpen: false,
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
commandOpen: false,
setCommandOpen: (commandOpen) => set({ commandOpen }),
}),
{ name: 'aos-ui', partialize: (s) => ({ view: s.view }) }
)
)